From 56dc580f50e01a313837acd2da29e7145d3a6858 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:24:14 +0530 Subject: [PATCH 1/8] feat: support manual setup for WhatsApp calls inbox creation (#14976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-enables the "WhatsApp Calls" inbox creation option, which disappeared when embedded signup was disabled on production. The channel card now only requires the `channel_voice` account feature, and the creation flow offers the manual WhatsApp Cloud API setup form. Once the channel is created with manual credentials, calling is enabled automatically — the same post-setup step the embedded signup flow used to perform. When an installation has embedded signup configured, the flow still uses it; the manual form is the fallback (and the effective path on Chatwoot Cloud today). ## How to test 1. Enable the `channel_voice` feature on the account. 2. Go to Add Inbox → the "WhatsApp Calls" card is visible again → select it. 3. Fill in the manual Cloud API credentials (inbox name, phone number, phone number ID, business account ID, API key) and submit. 4. The inbox is created and calling is enabled: `provider_config.calling_enabled` is true and the Calls tab toggle is on. If the number isn't enrolled in the Business Calling API, an alert explains the enable failure but the messaging inbox is still created. ## What changed - `ChannelItem.vue`: the `whatsapp_call` card is gated only on `channel_voice` (previously also required the embedded signup app ID). - `CloudWhatsapp.vue`: new `enableCallingOnComplete` prop that calls the enable-calling API after channel creation. - `WhatsappCall.vue`: renders embedded signup when available, otherwise the manual setup form — both with calling enabled on completion. --- .../components/widgets/ChannelItem.vue | 11 +---------- .../settings/inbox/channels/CloudWhatsapp.vue | 15 +++++++++++++++ .../settings/inbox/channels/WhatsappCall.vue | 17 ++++++++++++++++- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 2652b582b..7ed2505c1 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -56,19 +56,10 @@ const isActive = computed(() => { return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value; } - if (key === 'voice') { + if (key === 'voice' || key === 'whatsapp_call') { return props.enabledFeatures.channel_voice; } - if (key === 'whatsapp_call') { - return ( - !IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED && - props.enabledFeatures.channel_voice && - !!window.chatwootConfig?.whatsappAppId && - window.chatwootConfig.whatsappAppId !== 'none' - ); - } - return [ 'website', 'twilio', diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/CloudWhatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/CloudWhatsapp.vue index 0c1ac3a14..cb739ac93 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/CloudWhatsapp.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/CloudWhatsapp.vue @@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables'; import { required } from '@vuelidate/validators'; import router from '../../../../index'; import { isPhoneE164OrEmpty, isNumber } from 'shared/helpers/Validators'; +import InboxesAPI from 'dashboard/api/inboxes'; import NextButton from 'dashboard/components-next/button/Button.vue'; @@ -12,6 +13,12 @@ export default { components: { NextButton, }, + props: { + enableCallingOnComplete: { + type: Boolean, + default: false, + }, + }, setup() { return { v$: useVuelidate() }; }, @@ -59,6 +66,14 @@ export default { } ); + if (this.enableCallingOnComplete) { + try { + await InboxesAPI.enableWhatsappCalling(whatsappChannel.id); + } catch (_) { + useAlert(this.$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE_FAILED')); + } + } + router.replace({ name: 'settings_inboxes_add_agents', params: { diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue index c27cd7d1f..d9ac031e1 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue @@ -1,11 +1,26 @@ From 848e94bcf2e3a6293fb36eb5e92336a8f15313f7 Mon Sep 17 00:00:00 2001 From: Sony Mathew Date: Fri, 10 Jul 2026 17:23:00 +0530 Subject: [PATCH 2/8] fix: throttle filtered unread count rebuilds (#14980) Reduces database pressure from filtered unread-count cache rebuilds during high-traffic account rollouts by serving stale snapshots longer and limiting inline saved-filter rebuild fanout. ## Closes None ## What changed - Increase filtered unread-count refresh throttling from 30 seconds to 5 minutes. - Increase the stale snapshot window from 30 minutes to 1 hour. - Reduce inline saved-filter count rebuilds per request from 10 to 3. - Update unread-count specs to assert refresh and stale behavior through the shared constants. ## How to test - Enable `conversation_unread_counts` and `unread_count_for_filters` for an account with conversation custom filters. - Open the dashboard and verify unread-count badges still return values. - Mutate conversations and verify stale filtered counts are served while rebuilds are throttled, instead of repeatedly rebuilding every 30 seconds. --- app/services/conversations/unread_counts.rb | 6 ++--- .../filtered_count_store_spec.rb | 23 ++++++++++++++++--- .../unread_counts/filtered_counter_spec.rb | 22 +++++++++++++++--- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/app/services/conversations/unread_counts.rb b/app/services/conversations/unread_counts.rb index 1b3ee3fb2..e00f8357d 100644 --- a/app/services/conversations/unread_counts.rb +++ b/app/services/conversations/unread_counts.rb @@ -2,9 +2,9 @@ module Conversations::UnreadCounts READY_TTL = 24.hours.to_i SET_TTL = 25.hours.to_i FILTERED_COUNT_FRESH_TTL = 5.minutes.to_i - FILTERED_COUNT_STALE_WINDOW = 30.minutes.to_i + FILTERED_COUNT_STALE_WINDOW = 1.hour.to_i FILTERED_COUNT_REDIS_TTL = FILTERED_COUNT_FRESH_TTL + FILTERED_COUNT_STALE_WINDOW FILTERED_COUNT_VERSION_TTL = SET_TTL - FILTERED_COUNT_MIN_REFRESH_INTERVAL = 30.seconds.to_i - MAX_INLINE_FILTER_BUILDS = 10 + FILTERED_COUNT_MIN_REFRESH_INTERVAL = 5.minutes.to_i + MAX_INLINE_FILTER_BUILDS = 3 end diff --git a/spec/services/conversations/unread_counts/filtered_count_store_spec.rb b/spec/services/conversations/unread_counts/filtered_count_store_spec.rb index 7732eb2dd..3a7cd52d8 100644 --- a/spec/services/conversations/unread_counts/filtered_count_store_spec.rb +++ b/spec/services/conversations/unread_counts/filtered_count_store_spec.rb @@ -96,7 +96,14 @@ RSpec.describe Conversations::UnreadCounts::FilteredCountStore do described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id) expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 2.minutes)).to be_stale - expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 36.minutes)).to be_expired + expect( + described_class.built_in_filter_counts_state( + account_id: account_id, + user_id: user_id, + now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_FRESH_TTL + + Conversations::UnreadCounts::FILTERED_COUNT_STALE_WINDOW + 1.second + ) + ).to be_expired Redis::Alfred.delete(described_class.built_in_filter_counts_key(account_id, user_id)) expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id)).to be_missing @@ -202,8 +209,18 @@ RSpec.describe Conversations::UnreadCounts::FilteredCountStore do ) snapshot = described_class.built_in_filter_counts(account_id: account_id, user_id: user_id) - expect(described_class.refresh_due?(snapshot, now: built_at + 10.seconds)).to be(false) - expect(described_class.refresh_due?(snapshot, now: built_at + 31.seconds)).to be(true) + expect( + described_class.refresh_due?( + snapshot, + now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL - 1.second + ) + ).to be(false) + expect( + described_class.refresh_due?( + snapshot, + now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second + ) + ).to be(true) expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(true) expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(false) diff --git a/spec/services/conversations/unread_counts/filtered_counter_spec.rb b/spec/services/conversations/unread_counts/filtered_counter_spec.rb index bb5d419a6..2904d1e4e 100644 --- a/spec/services/conversations/unread_counts/filtered_counter_spec.rb +++ b/spec/services/conversations/unread_counts/filtered_counter_spec.rb @@ -48,10 +48,22 @@ RSpec.describe Conversations::UnreadCounts::FilteredCounter do create(:mention, account: account, conversation: second_mention, user: agent) store.bump_conversation_version!(account.id) - expect(described_class.new(account: account, user: agent, now: now + 10.seconds).perform[:mentions_count]).to eq(1) + expect( + described_class.new( + account: account, + user: agent, + now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL - 1.second + ).perform[:mentions_count] + ).to eq(1) Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id)) - expect(described_class.new(account: account, user: agent, now: now + 31.seconds).perform[:mentions_count]).to eq(2) + expect( + described_class.new( + account: account, + user: agent, + now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second + ).perform[:mentions_count] + ).to eq(2) end it 'returns stale built-in counts when a refresh build hits a database error' do @@ -62,7 +74,11 @@ RSpec.describe Conversations::UnreadCounts::FilteredCounter do store.bump_conversation_version!(account.id) Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id)) - failing_counter = described_class.new(account: account, user: agent, now: now + 31.seconds) + failing_counter = described_class.new( + account: account, + user: agent, + now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second + ) allow(failing_counter).to receive(:built_in_counts_from_database).and_raise(ActiveRecord::StatementInvalid.new('statement timeout')) expect(failing_counter.perform[:mentions_count]).to eq(1) From 03a1b1dbc14094aaf1b62c7d7ebd0b6915a2c56a Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:24:34 +0530 Subject: [PATCH 3/8] chore: insert resolved variable value in reply editor (#14921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request Template ## Description This PR makes reply editor variables insert their resolved value (for example, the contact's name) instead of the raw `{{contact.name}}` placeholder, matching canned response behavior. This works both when picking a variable from the `{{` menu and when an agent manually types out `{{contact.name}}` — it resolves the moment the closing `}}` is typed. If a variable has no value, the `{{placeholder}}` is kept so the backend can still resolve it when the message is sent. Private notes are left untouched. For safety, a resolved value that itself contains Liquid syntax `({{ }}` or `{% %})` also keeps its placeholder, so customer-controlled fields can never inject Liquid into the outgoing message. Fixes https://linear.app/chatwoot/issue/CW-7528/reply-editor-inserts-variable-placeholder-instead-of-the-value ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? 1. Open a conversation and add a reply. 2. Type `{{` and pick a variable that has a value (e.g. Contact name) → it inserts the actual value. 3. Manually type `{{contact.name}}` and close the braces → it auto-resolves to the value. 4. Insert/type a variable with no value → the `{{placeholder}}` stays; confirm it resolves correctly on send. 5. Repeat in a private note → placeholders are left as-is. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../components/widgets/WootWriter/Editor.vue | 5 + .../widgets/conversation/ReplyBox.vue | 10 +- .../dashboard/helper/editorHelper.js | 60 ++++++- .../helper/specs/editorContentHelper.spec.js | 58 +++++-- .../helper/specs/editorHelper.spec.js | 149 ++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 7 +- 7 files changed, 275 insertions(+), 15 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 09dc23819..634276361 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -62,6 +62,7 @@ import { calculateMenuPosition, getEffectiveChannelType, stripUnsupportedFormatting, + createVariableInputRule, } from 'dashboard/helper/editorHelper'; import { hasPressedEnterAndNotCmdOrShift, @@ -306,6 +307,10 @@ const plugins = computed(() => { searchTerm: variableSearchTerm, isAllowed: () => !props.isPrivate, }), + createVariableInputRule({ + isPrivate: () => props.isPrivate, + getVariables: () => props.variables, + }), createSuggestionPlugin({ trigger: ':', minChars: 2, diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 6af876cc6..471d10f3c 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -48,6 +48,8 @@ import { appendSignature, removeSignature, getEffectiveChannelType, + getAgentVariables, + getContactVariables, } from 'dashboard/helper/editorHelper'; import { useCopilotReply } from 'dashboard/composables/useCopilotReply'; import { useKbd } from 'dashboard/composables/utils/useKbd'; @@ -393,7 +395,13 @@ export default { contact: this.currentContact, inbox: this.inbox, }); - return variables; + // Match the backend drops: names are Ruby-capitalized and + // {{agent.*}} is the message sender, not the assignee. + return { + ...variables, + ...getContactVariables(this.currentContact), + ...getAgentVariables(this.currentUser), + }; }, connectedPortalSlug() { const { help_center: portal = {} } = this.inbox; diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index 32f56172a..2d3c75777 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -9,6 +9,7 @@ import * as Sentry from '@sentry/vue'; import camelcaseKeys from 'camelcase-keys'; import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor'; import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox'; +import { InputRule, inputRules } from 'prosemirror-inputrules'; /** * Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc. @@ -428,6 +429,55 @@ export function stripUnsupportedFormatting(content, schema) { * - emoji */ +// Liquid delimiters ({{ }} / {% %}) the backend evaluates on send. +const LIQUID_SYNTAX = /\{\{|\{%/; + +// Value when set (and not itself Liquid), else the {{placeholder}} for the backend. +export const resolveVariableText = (key, variables) => { + const value = String(variables?.[key] ?? ''); + return value && !LIQUID_SYNTAX.test(value) ? value : `{{${key}}}`; +}; + +// Name variables normalized like the backend drops (UserDrop/ContactDrop): +// name split on whitespace, each word Ruby-capitalized (rest downcased). +const getNameVariables = (prefix, name) => { + const names = (name || '') + .split(/\s+/) + .filter(Boolean) + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()); + return { + [`${prefix}.name`]: names.join(' '), + [`${prefix}.first_name`]: names[0] || '', + [`${prefix}.last_name`]: names.length > 1 ? names[names.length - 1] : '', + }; +}; + +// {{agent.*}} values for the message sender. +export const getAgentVariables = user => ({ + ...getNameVariables('agent', user.name), + 'agent.email': user.email, +}); + +// {{contact.*}} name values. +export const getContactVariables = contact => + getNameVariables('contact', contact?.name); + +// Resolves a manually typed {{variable}} to its value on the closing braces. +// Leaves the placeholder when there's no value, the value is Liquid, or it's a private note. +export const createVariableInputRule = ({ isPrivate, getVariables }) => { + const rule = new InputRule( + /\{\{([^{}]+)\}\}$/, + (editorState, match, from, to) => { + if (isPrivate()) return null; + const [, key] = match; + const text = resolveVariableText(key, getVariables()); + if (text === `{{${key}}}`) return null; + return editorState.tr.insertText(text, from, to); + } + ); + return inputRules({ rules: [rule] }); +}; + /** * Centralized node creation function that handles the creation of different types of nodes based on the specified type. * @param {Object} editorView - The editor view instance. @@ -462,7 +512,7 @@ const createNode = (editorView, nodeType, content) => { ); } case 'variable': - return state.schema.text(`{{${content}}}`); + return state.schema.text(content); case 'emoji': return state.schema.text(content); case 'tool': { @@ -497,8 +547,12 @@ const nodeCreators = { to, }; }, - variable: (editorView, content, from, to) => ({ - node: createNode(editorView, 'variable', content), + variable: (editorView, content, from, to, variables) => ({ + node: createNode( + editorView, + 'variable', + resolveVariableText(content, variables) + ), from, to, }), diff --git a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js index 4efb4d1d9..57d8bd533 100644 --- a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js @@ -94,16 +94,56 @@ describe('getContentNode', () => { }); describe('getVariableNode', () => { - it('should create a variable node', () => { - const content = 'name'; - const from = 0; - const to = 10; - getContentNode(editorView, 'variable', content, { - from, - to, - }); + it('should render the resolved value directly when the variable has a value', () => { + getContentNode( + editorView, + 'variable', + 'contact.name', + { from: 0, to: 10 }, + { 'contact.name': 'John' } + ); - expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}'); + expect(editorView.state.schema.text).toHaveBeenCalledWith('John'); + }); + + it('should resolve camelCase custom attributes and non-string values', () => { + getContentNode( + editorView, + 'variable', + 'contact.custom_attribute.cloudCustomer', + { from: 0, to: 10 }, + { 'contact.custom_attribute.cloudCustomer': true } + ); + + expect(editorView.state.schema.text).toHaveBeenCalledWith('true'); + }); + + it('should keep the placeholder when the variable has no value', () => { + getContentNode( + editorView, + 'variable', + 'contact.email', + { from: 0, to: 10 }, + {} + ); + + expect(editorView.state.schema.text).toHaveBeenCalledWith( + '{{contact.email}}' + ); + }); + + it('should keep the placeholder when the value contains Liquid syntax', () => { + getContentNode( + editorView, + 'variable', + 'contact.name', + { from: 0, to: 10 }, + { 'contact.name': '{{agent.email}}' } + ); + + expect(editorView.state.schema.text).toHaveBeenCalledWith( + '{{contact.name}}' + ); }); }); diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index 220b9903e..fafe1bc56 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -11,9 +11,12 @@ import { calculateMenuPosition, cleanSignature, collapseSelection, + createVariableInputRule, extractTextFromMarkdown, findNodeToInsertImage, findSignatureInBody, + getAgentVariables, + getContactVariables, getContentNode, getFormattingForEditor, getMenuAnchor, @@ -1228,3 +1231,149 @@ describe('Menu positioning helpers', () => { }); }); }); + +describe('getAgentVariables', () => { + it('builds agent variables from the user', () => { + expect( + getAgentVariables({ name: 'John Doe', email: 'john@example.com' }) + ).toEqual({ + 'agent.name': 'John Doe', + 'agent.first_name': 'John', + 'agent.last_name': 'Doe', + 'agent.email': 'john@example.com', + }); + }); + + it('normalizes casing like the backend UserDrop (Ruby capitalize)', () => { + const variables = getAgentVariables({ name: 'JANE doE' }); + + expect(variables['agent.name']).toBe('Jane Doe'); + expect(variables['agent.first_name']).toBe('Jane'); + expect(variables['agent.last_name']).toBe('Doe'); + }); + + it('ignores extra whitespace between words', () => { + expect(getAgentVariables({ name: ' john doe ' })['agent.name']).toBe( + 'John Doe' + ); + }); + + it('leaves last_name empty for single-word names', () => { + const variables = getAgentVariables({ name: 'john' }); + + expect(variables['agent.first_name']).toBe('John'); + expect(variables['agent.last_name']).toBe(''); + }); + + it('handles a missing name', () => { + const variables = getAgentVariables({ email: 'john@example.com' }); + + expect(variables['agent.name']).toBe(''); + expect(variables['agent.first_name']).toBe(''); + expect(variables['agent.last_name']).toBe(''); + }); +}); + +describe('getContactVariables', () => { + it('normalizes casing like the backend ContactDrop (Ruby capitalize)', () => { + expect(getContactVariables({ name: 'JANE doE' })).toEqual({ + 'contact.name': 'Jane Doe', + 'contact.first_name': 'Jane', + 'contact.last_name': 'Doe', + }); + }); + + it('leaves last_name empty for single-word names', () => { + const variables = getContactVariables({ name: 'john' }); + + expect(variables['contact.first_name']).toBe('John'); + expect(variables['contact.last_name']).toBe(''); + }); + + it('handles a missing contact', () => { + expect(getContactVariables(undefined)['contact.name']).toBe(''); + }); +}); + +describe('createVariableInputRule', () => { + // Editor holding `{{key}` so we can simulate typing the final `}`. + const buildView = (typed, { isPrivate = false, variables = {} } = {}) => { + const plugin = createVariableInputRule({ + isPrivate: () => isPrivate, + getVariables: () => variables, + }); + const state = EditorState.create({ + schema, + doc: schema.node('doc', null, [ + schema.node('paragraph', null, [schema.text(typed)]), + ]), + plugins: [plugin], + }); + return new EditorView(document.body, { state }); + }; + + // Types the closing `}`; when the rule declines, insert it like the browser would. + const typeClosingBrace = view => { + const end = view.state.doc.content.size - 1; + const handled = view.someProp('handleTextInput', fn => + fn(view, end, end, '}') + ); + if (!handled) { + view.dispatch(view.state.tr.insertText('}', end, end)); + } + }; + + it('resolves a manually typed {{variable}} to its value on the closing brace', () => { + const view = buildView('{{contact.name}', { + variables: { 'contact.name': 'John' }, + }); + + typeClosingBrace(view); + + expect(view.state.doc.textContent).toBe('John'); + view.destroy(); + }); + + it('resolves boolean/non-string values', () => { + const view = buildView('{{contact.custom_attribute.cloudCustomer}', { + variables: { 'contact.custom_attribute.cloudCustomer': true }, + }); + + typeClosingBrace(view); + + expect(view.state.doc.textContent).toBe('true'); + view.destroy(); + }); + + it('keeps the placeholder when the variable has no value', () => { + const view = buildView('{{contact.email}', { variables: {} }); + + typeClosingBrace(view); + + expect(view.state.doc.textContent).toBe('{{contact.email}}'); + view.destroy(); + }); + + it('keeps the placeholder when the value itself contains Liquid syntax', () => { + const view = buildView('{{contact.name}', { + variables: { 'contact.name': '{{agent.email}}' }, + }); + + typeClosingBrace(view); + + expect(view.state.doc.textContent).toBe('{{contact.name}}'); + view.destroy(); + }); + + it('does not resolve inside a private note', () => { + const view = buildView('{{contact.name}', { + isPrivate: true, + variables: { 'contact.name': 'John' }, + }); + + typeClosingBrace(view); + + expect(view.state.doc.textContent).toBe('{{contact.name}}'); + view.destroy(); + }); +}); diff --git a/package.json b/package.json index 917a1b97d..d964a30a4 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "opus-recorder": "^8.0.5", "pinia": "^3.0.4", "prosemirror-commands": "^1.7.1", + "prosemirror-inputrules": "^1.4.0", "prosemirror-schema-list": "^1.5.1", "qrcode": "^1.5.4", "semver": "7.6.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80fbf318a..0ffb85c18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,9 @@ importers: prosemirror-commands: specifier: ^1.7.1 version: 1.7.1 + prosemirror-inputrules: + specifier: ^1.4.0 + version: 1.4.0 prosemirror-schema-list: specifier: ^1.5.1 version: 1.5.1 @@ -9037,7 +9040,7 @@ snapshots: prosemirror-state@1.4.3: dependencies: prosemirror-model: 1.22.3 - prosemirror-transform: 1.10.0 + prosemirror-transform: 1.12.0 prosemirror-view: 1.34.1 prosemirror-tables@1.5.0: @@ -9065,7 +9068,7 @@ snapshots: dependencies: prosemirror-model: 1.22.3 prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.0 + prosemirror-transform: 1.12.0 proto-list@1.2.4: {} From 98154bbeab2f5ea888dcb61bfd3a35a109ebb9a0 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Fri, 10 Jul 2026 16:22:49 +0400 Subject: [PATCH 4/8] fix(meta): show restriction alerts for inbox setup (#14974) Instagram inbox creation and WhatsApp embedded signup on Chatwoot Cloud now reflect the temporary Meta restriction. Instagram is hidden from onboarding on Cloud, while the regular Instagram inbox creation page shows a disabled action with a status-linked amber warning. WhatsApp embedded signup on Cloud stays visible with its connect action disabled. WhatsApp Call setup always uses the manual WhatsApp form. Existing Instagram conversations and Instagram inbox settings on Cloud also show amber warning banners with the public incident link. Self-hosted installations keep their existing Instagram, WhatsApp, and WhatsApp Call setup behavior because the temporary restriction is based only on the Chatwoot Cloud environment check. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../components/widgets/ChannelItem.vue | 6 +-- .../widgets/conversation/MessagesView.vue | 21 ++++++++- app/javascript/dashboard/constants/globals.js | 7 +-- .../i18n/locale/en/conversation.json | 2 + .../dashboard/i18n/locale/en/inboxMgmt.json | 7 ++- .../inbox-setup/useChannelConfig.js | 7 ++- .../inbox-setup/useChannelConnect.js | 7 +++ .../inbox-setup/useDetectedChannels.spec.js | 36 +++++++++++---- .../dashboard/settings/inbox/Settings.vue | 34 ++++++++++++++ .../settings/inbox/channels/CloudWhatsapp.vue | 1 + .../settings/inbox/channels/Instagram.vue | 39 ++++++++++++++-- .../settings/inbox/channels/Whatsapp.vue | 15 +++++-- .../settings/inbox/channels/WhatsappCall.vue | 17 +------ .../inbox/channels/WhatsappEmbeddedSignup.vue | 45 ++++++++++++++++++- 14 files changed, 196 insertions(+), 48 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 7ed2505c1..e055c2d9e 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -1,7 +1,6 @@ diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue index 3dda0ad8e..0972e4b95 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue @@ -7,6 +7,7 @@ import { useAlert } from 'dashboard/composables'; import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup'; import Icon from 'next/icon/Icon.vue'; import NextButton from 'next/button/Button.vue'; +import Banner from 'next/banner/Banner.vue'; import LoadingState from 'dashboard/components/widgets/LoadingState.vue'; import InboxesAPI from 'dashboard/api/inboxes'; import { parseAPIErrorResponse } from 'dashboard/store/utils/api'; @@ -17,6 +18,22 @@ const props = defineProps({ type: Boolean, default: false, }, + isDisabled: { + type: Boolean, + default: false, + }, + showRestrictionAlert: { + type: Boolean, + default: false, + }, + restrictionStatusUrl: { + type: String, + default: '', + }, + restrictionWarningText: { + type: String, + default: '', + }, }); const store = useStore(); @@ -81,6 +98,8 @@ const handleSignupSuccess = async inboxData => { }; const launchEmbeddedSignup = async () => { + if (props.isDisabled) return; + let credentials; try { credentials = await runEmbeddedSignup(); @@ -174,9 +193,33 @@ const launchEmbeddedSignup = async () => { + +
+ + + {{ + restrictionWarningText || + $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.RESTRICTED_WARNING') + }} + + {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STATUS_LINK') }} + + +
+
+
Date: Mon, 13 Jul 2026 12:59:36 +0530 Subject: [PATCH 5/8] feat(captain): expand assistant description limit (#14985) # Pull Request Template ## Description Increases description for Captain. Why? We are planning to include business context in description and 255 char limit on the column and 200 char limit on the UI are very limiting to get proper context. ## Type of change Improvement to accommodate business context ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. locally ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../captain/assistant/AddNewScenariosDialog.vue | 1 + .../components-next/captain/assistant/ScenariosCard.vue | 1 + .../captain/pageComponents/assistant/AssistantForm.vue | 1 + .../assistant/settings/AssistantBasicSettingsForm.vue | 1 + ...00000_change_captain_assistant_description_to_text.rb | 9 +++++++++ db/schema.rb | 4 ++-- enterprise/app/models/captain/assistant.rb | 6 ++++-- enterprise/app/models/captain/scenario.rb | 4 +++- .../captain/onboarding/website_analyzer_service.rb | 2 +- 9 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 db/migrate/20260710000000_change_captain_assistant_description_to_text.rb diff --git a/app/javascript/dashboard/components-next/captain/assistant/AddNewScenariosDialog.vue b/app/javascript/dashboard/components-next/captain/assistant/AddNewScenariosDialog.vue index 89f115a64..08d79d27c 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/AddNewScenariosDialog.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/AddNewScenariosDialog.vue @@ -107,6 +107,7 @@ const onClickCancel = () => {