From eae9841eb438bd0611729a8a545b04ac138abcb6 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 20 Jul 2026 15:28:33 -0700 Subject: [PATCH 01/11] fix: restore token access to account APIs (#15088) Token-authenticated requests to Agent Bots, Labels, and affected Captain endpoints return normal responses again. The regression was caused by duplicate `current_account` callbacks in subclasses moving account resolution behind the API entitlement check, leaving `Current.account` unset. ## Closes - https://linear.app/chatwoot/issue/CW-7641/5xx-errors-in-agent-bot-apis ## How to reproduce 1. Send `GET /api/v1/accounts/:account_id/agent_bots` with a valid administrator API access token. 2. Observe a `500` from `validate_token_api_access` because `Current.account` is `nil`. 3. With this change, account resolution runs in the base-controller order and the request succeeds. ## What changed - Removed redundant `current_account` callbacks from account-scoped controllers that already inherit the callback from `Api::V1::Accounts::BaseController`. - Kept the standalone direct-upload controller callback unchanged. - Added regression coverage for administrator API-token access to Agent Bots. --- app/controllers/api/v1/accounts/agent_bots_controller.rb | 1 - .../api/v1/accounts/captain/preferences_controller.rb | 1 - app/controllers/api/v1/accounts/labels_controller.rb | 1 - .../v1/accounts/captain/assistant_responses_controller.rb | 1 - .../api/v1/accounts/captain/assistants_controller.rb | 1 - .../api/v1/accounts/captain/bulk_actions_controller.rb | 1 - .../api/v1/accounts/captain/custom_tools_controller.rb | 1 - .../api/v1/accounts/captain/documents_controller.rb | 1 - .../api/v1/accounts/captain/inboxes_controller.rb | 1 - .../api/v1/accounts/captain/scenarios_controller.rb | 1 - .../api/v1/accounts/agent_bots_controller_spec.rb | 8 ++++++++ 11 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb index de3d10081..3f0309024 100644 --- a/app/controllers/api/v1/accounts/agent_bots_controller.rb +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController - before_action :current_account before_action :check_authorization before_action :agent_bot, except: [:index, :create] diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb index 482b001d6..3b7fafad5 100644 --- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb +++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController - before_action :current_account before_action :authorize_account_update, only: [:update] def show diff --git a/app/controllers/api/v1/accounts/labels_controller.rb b/app/controllers/api/v1/accounts/labels_controller.rb index 6889d30a4..f678fee42 100644 --- a/app/controllers/api/v1/accounts/labels_controller.rb +++ b/app/controllers/api/v1/accounts/labels_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController - before_action :current_account before_action :fetch_label, except: [:index, :create] before_action :check_authorization diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistant_responses_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistant_responses_controller.rb index 151cf279c..80e05e912 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/assistant_responses_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/assistant_responses_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::BaseController - before_action :current_account before_action -> { check_authorization(Captain::Assistant) } before_action :set_current_page, only: [:index] diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb index 4fbb93d20..8a6f158cf 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController - before_action :current_account before_action -> { check_authorization(Captain::Assistant) } before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown] diff --git a/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb index 7e2817f69..2bd7c8eed 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::BaseController - before_action :current_account before_action -> { check_authorization(Captain::Assistant) } before_action :validate_params before_action :type_matches? diff --git a/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb index 874197870..f0222434c 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController - before_action :current_account before_action :ensure_custom_tools_enabled before_action -> { check_authorization(Captain::CustomTool) } before_action :set_custom_tool, only: [:show, :update, :destroy] diff --git a/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb index d88cc6b48..cb6fb782d 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseController - before_action :current_account before_action -> { check_authorization(Captain::Assistant) } before_action :set_current_page, only: [:index] diff --git a/enterprise/app/controllers/api/v1/accounts/captain/inboxes_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/inboxes_controller.rb index f4ec303b6..88b8f2fb3 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/inboxes_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/inboxes_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::Captain::InboxesController < Api::V1::Accounts::BaseController - before_action :current_account before_action -> { check_authorization(Captain::Assistant) } before_action :set_assistant diff --git a/enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb index 376cee0b3..fa7157dae 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseController - before_action :current_account before_action -> { check_authorization(Captain::Scenario) } before_action :set_assistant before_action :set_scenario, only: [:show, :update, :destroy] diff --git a/spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb b/spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb index a98f787e0..6e1b03bac 100644 --- a/spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb @@ -64,6 +64,14 @@ RSpec.describe 'Agent Bot API', type: :request do expect(response).to have_http_status(:success) expect(response.body).to include(agent_bot.access_token.token) end + + it 'supports API token authentication' do + get "/api/v1/accounts/#{account.id}/agent_bots", + headers: { api_access_token: admin.access_token.token }, + as: :json + + expect(response).to have_http_status(:success) + end end end From 71fffdd2b91a405fbb70f35a7aa01c21f8862097 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Tue, 21 Jul 2026 04:39:29 +0530 Subject: [PATCH 02/11] fix: rate limit widget conversation transcript API (#15085) ## Description The widget conversation transcript endpoint (`POST /api/v1/widget/conversations/transcript`) has no rate limit. Every other comparable endpoint does: the agent-facing transcript API and the widget conversation-create and contact-update endpoints are all throttled. This gap lets a single client trigger a large burst of transcript emails from one conversation. This adds an IP-based throttle (5 requests/hour) for the endpoint, placed inside the existing widget-API throttle block so it inherits the `ENABLE_RACK_ATTACK_WIDGET_API` opt-out used by embedded/iframe clients. The limit is generous for legitimate use (a visitor emailing themselves a transcript) while stopping abusive loops. Throttled requests get the standard 429 the widget already handles. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? `config/initializers/rack_attack.rb` throttles have no existing specs in this file, so this follows the established convention (no new spec). Verified `ruby -c` and `rubocop` pass on the file. The new throttle mirrors the sibling widget throttles directly above it (same IP key, path guard, and structure). ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] My changes generate no new warnings --------- Co-authored-by: Sojan Jose --- config/initializers/rack_attack.rb | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 41ccae971..caf49c047 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -31,10 +31,11 @@ class Rack::Attack (default_allowed_ips + env_allowed_ips).include?(remote_ip) end - # Rails would allow requests to paths with extensions, so lets compare against the path with extension stripped - # example /auth & /auth.json would both work + # Rails allows paths with extensions and trailing slashes, so compare against a normalized path. + # For example, /auth, /auth.json, and /auth/ should all use the same throttle. def path_without_extensions - path[/^[^.]+/] + normalized_path = path[/^[^.]+/] + normalized_path == '/' ? normalized_path : normalized_path.sub(%r{/+\z}, '') end end @@ -188,6 +189,11 @@ class Rack::Attack throttle('widget?website_token={website_token}&cw_conversation={x-auth-token}', limit: 5, period: 1.hour) do |req| req.ip if req.path_without_extensions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank? end + + ## Prevent Transcript Bombing on Widget API ### + throttle('api/v1/widget/conversations/transcript', limit: 5, period: 1.hour) do |req| + req.ip if req.path_without_extensions == '/api/v1/widget/conversations/transcript' && req.post? + end end ##-----------------------------------------------## From d1fa8d8c2f844e5868a8f327b73249d203624f3f Mon Sep 17 00:00:00 2001 From: Devi R Date: Tue, 21 Jul 2026 11:38:23 +0530 Subject: [PATCH 03/11] refactor: share whatsapp/twilio template logic via @chatwoot/utils (#15001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request Template ## Description Moves the WhatsApp & Twilio content-template logic to the shared [`@chatwoot/utils`](https://github.com/chatwoot/utils) ([PR](https://github.com/chatwoot/utils/pull/62)) package so web and mobile share one implementation. The neutral core takes the raw template and returns `processed_params`, the same shape the web parsers already use, so it's a drop-in with no behavior change. - `templateHelper.js` / `URLHelper.js` → source `MEDIA_FORMATS`, `findComponentByType`, `processVariable`, `buildTemplateParameters`, `extractFilenameFromUrl` from the package - `inboxes.js` → filters with shared `isSendableTemplate` - `WhatsAppTemplateParser.vue` / `ContentTemplateParser.vue` → `isFormInvalid` and Twilio media helpers now use the shared `isWhatsAppComplete` / `isTwilioComplete` / `applyTwilioMediaFilename` > Depends on the `@chatwoot/utils` release adding the shared template API, bump `package.json` from `^0.0.55` to the published version before merge. Fixes [CW-7540](https://linear.app/chatwoot/issue/CW-7540/web-templates-integration-with-utils) ## Type of change - [x] Breaking change (Refactor) --------- Co-authored-by: Muhsin Keloth Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> --- .../ContentTemplateParser.vue | 78 +++++---------- .../whatsapp/WhatsAppTemplateParser.vue | 28 +----- app/javascript/dashboard/helper/URLHelper.js | 21 +--- .../helper/specs/templateHelper.spec.js | 12 ++- .../dashboard/helper/templateHelper.js | 96 +++---------------- .../dashboard/store/modules/inboxes.js | 42 +------- package.json | 2 +- pnpm-lock.yaml | 10 +- 8 files changed, 66 insertions(+), 223 deletions(-) diff --git a/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue b/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue index 4d526d558..1390ea0f7 100644 --- a/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue +++ b/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue @@ -3,8 +3,13 @@ import { ref, computed, onMounted, watch } from 'vue'; import { useVuelidate } from '@vuelidate/core'; import { requiredIf } from '@vuelidate/validators'; import { useI18n } from 'vue-i18n'; -import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper'; -import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages'; +import { + isTwilioComplete, + isTwilioMediaTemplate, + getTwilioMediaVariableKey, + getTwilioMediaUrl, + applyTwilioMediaFilename, +} from '@chatwoot/utils'; import Input from 'dashboard/components-next/input/Input.vue'; @@ -40,30 +45,23 @@ const templateBody = computed(() => { return props.template.body || ''; }); -const hasMediaTemplate = computed(() => { - return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA; -}); +// Media-template detection and variable extraction are shared with the mobile +// app via @chatwoot/utils. +const hasMediaTemplate = computed(() => isTwilioMediaTemplate(props.template)); const hasVariables = computed(() => { return templateBody.value?.match(VARIABLE_PATTERN) !== null; }); -const mediaVariableKey = computed(() => { - if (!hasMediaTemplate.value) return null; - const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0]; - if (!mediaUrl) return null; - return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null; -}); +const mediaVariableKey = computed(() => + getTwilioMediaVariableKey(props.template) +); -const hasMediaVariable = computed(() => { - return hasMediaTemplate.value && mediaVariableKey.value !== null; -}); +const hasMediaVariable = computed(() => mediaVariableKey.value !== null); -const templateMediaUrl = computed(() => { - if (!hasMediaTemplate.value) return ''; - - return props.template?.types?.['twilio/media']?.media?.[0] || ''; -}); +const templateMediaUrl = computed(() => + hasMediaTemplate.value ? getTwilioMediaUrl(props.template) : '' +); const variablePattern = computed(() => { if (!hasVariables.value) return []; @@ -83,26 +81,10 @@ const renderedTemplate = computed(() => { return rendered; }); -const isFormInvalid = computed(() => { - if (!hasVariables.value && !hasMediaVariable.value) return false; - - if (hasVariables.value) { - const hasEmptyVariable = variablePattern.value.some( - variable => !processedParams.value[variable] - ); - if (hasEmptyVariable) return true; - } - - if ( - hasMediaVariable.value && - mediaVariableKey.value && - !processedParams.value[mediaVariableKey.value] - ) { - return true; - } - - return false; -}); +// Completeness validation is shared with the mobile app via @chatwoot/utils. +const isFormInvalid = computed( + () => !isTwilioComplete(props.template, processedParams.value) +); const v$ = useVuelidate( { @@ -135,19 +117,11 @@ const sendMessage = () => { const { friendly_name, language } = props.template; - // Process parameters and extract filename from media URL if needed - const processedParameters = { ...processedParams.value }; - - // For media templates, extract filename from full URL - if ( - hasMediaVariable.value && - mediaVariableKey.value && - processedParameters[mediaVariableKey.value] - ) { - processedParameters[mediaVariableKey.value] = extractFilenameFromUrl( - processedParameters[mediaVariableKey.value] - ); - } + // For media templates, reduce the media URL to a filename before sending. + const processedParameters = applyTwilioMediaFilename( + props.template, + processedParams.value + ); const payload = { message: renderedTemplate.value, diff --git a/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue b/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue index 6df06642c..620955cb4 100644 --- a/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue +++ b/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue @@ -13,6 +13,7 @@ import { useVuelidate } from '@vuelidate/core'; import { requiredIf } from '@vuelidate/validators'; import { useI18n } from 'vue-i18n'; +import { isWhatsAppComplete } from '@chatwoot/utils'; import Input from 'dashboard/components-next/input/Input.vue'; import { buildTemplateParameters, @@ -84,29 +85,10 @@ const renderedTemplate = computed(() => { return replaceTemplateVariables(bodyText.value, processedParams.value); }); -const isFormInvalid = computed(() => { - if (!hasVariables.value && !hasMediaHeader.value) return false; - - if (hasMediaHeader.value && !processedParams.value.header?.media_url) { - return true; - } - - if (hasVariables.value && processedParams.value.body) { - const hasEmptyBodyVariable = Object.values(processedParams.value.body).some( - value => !value - ); - if (hasEmptyBodyVariable) return true; - } - - if (processedParams.value.buttons) { - const hasEmptyButtonParameter = processedParams.value.buttons.some( - button => !button.parameter - ); - if (hasEmptyButtonParameter) return true; - } - - return false; -}); +// Completeness validation is shared with the mobile app via @chatwoot/utils. +const isFormInvalid = computed( + () => !isWhatsAppComplete(props.template, processedParams.value) +); const v$ = useVuelidate( { diff --git a/app/javascript/dashboard/helper/URLHelper.js b/app/javascript/dashboard/helper/URLHelper.js index 76a5d8bd4..8437e116f 100644 --- a/app/javascript/dashboard/helper/URLHelper.js +++ b/app/javascript/dashboard/helper/URLHelper.js @@ -127,25 +127,8 @@ export const getHostNameFromURL = url => { } }; -/** - * Extracts filename from a URL - * @param {string} url - The URL to extract filename from - * @returns {string} - The extracted filename or original URL if extraction fails - */ -export const extractFilenameFromUrl = url => { - if (!url || typeof url !== 'string') return url; - - try { - const urlObj = new URL(url); - const pathname = urlObj.pathname; - const filename = pathname.split('/').pop(); - return filename || url; - } catch (error) { - // If URL parsing fails, try to extract filename using regex - const match = url.match(/\/([^/?#]+)(?:[?#]|$)/); - return match ? match[1] : url; - } -}; +// Shared with the mobile app via @chatwoot/utils. +export { extractFilenameFromUrl } from '@chatwoot/utils'; /** * Normalizes a comma/newline separated list of domains diff --git a/app/javascript/dashboard/helper/specs/templateHelper.spec.js b/app/javascript/dashboard/helper/specs/templateHelper.spec.js index 375e38a2d..ba056c317 100644 --- a/app/javascript/dashboard/helper/specs/templateHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/templateHelper.spec.js @@ -156,12 +156,18 @@ describe('templateHelper', () => { ]); }); - it('should handle templates with no variables', () => { + it('should handle templates with no variables but a media header', () => { const emptyTemplate = templates.find( t => t.name === 'no_variable_template' ); - const result = buildTemplateParameters(emptyTemplate, false); - expect(result).toEqual({}); + const result = buildTemplateParameters(emptyTemplate); + // hasMediaHeader is derived from the template, so the document header is kept. + expect(result.body).toBeUndefined(); + expect(result.header).toEqual({ + media_url: '', + media_type: 'document', + media_name: '', + }); }); it('should build parameters for templates with multiple component types', () => { diff --git a/app/javascript/dashboard/helper/templateHelper.js b/app/javascript/dashboard/helper/templateHelper.js index 1fb61d760..c875871f4 100644 --- a/app/javascript/dashboard/helper/templateHelper.js +++ b/app/javascript/dashboard/helper/templateHelper.js @@ -1,19 +1,16 @@ -// Constants +import { processVariable, buildWhatsAppProcessedParams } from '@chatwoot/utils'; + +// Constants and pure template helpers are shared with the mobile app via +// @chatwoot/utils so the logic lives in one place. +export { + MEDIA_FORMATS, + COMPONENT_TYPES, + findComponentByType, + processVariable, +} from '@chatwoot/utils'; + export const DEFAULT_LANGUAGE = 'en'; export const DEFAULT_CATEGORY = 'UTILITY'; -export const COMPONENT_TYPES = { - HEADER: 'HEADER', - BODY: 'BODY', - BUTTONS: 'BUTTONS', -}; -export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT']; - -export const findComponentByType = (template, type) => - template.components?.find(component => component.type === type); - -export const processVariable = str => { - return str.replace(/{{|}}/g, ''); -}; export const allKeysRequired = value => { const keys = Object.keys(value); @@ -27,70 +24,7 @@ export const replaceTemplateVariables = (templateText, processedParams) => { }); }; -export const buildTemplateParameters = (template, hasMediaHeaderValue) => { - const allVariables = {}; - - const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY); - const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER); - - if (!bodyComponent) return allVariables; - - const templateString = bodyComponent.text; - - // Process body variables - const matchedVariables = templateString.match(/{{([^}]+)}}/g); - if (matchedVariables) { - allVariables.body = {}; - matchedVariables.forEach(variable => { - const key = processVariable(variable); - allVariables.body[key] = ''; - }); - } - - if (hasMediaHeaderValue) { - if (!allVariables.header) allVariables.header = {}; - allVariables.header.media_url = ''; - allVariables.header.media_type = headerComponent.format.toLowerCase(); - - // For document templates, include media_name field for filename support - if (headerComponent.format.toLowerCase() === 'document') { - allVariables.header.media_name = ''; - } - } - - // Process button variables - const buttonComponents = template.components.filter( - component => component.type === COMPONENT_TYPES.BUTTONS - ); - - buttonComponents.forEach(buttonComponent => { - if (buttonComponent.buttons) { - buttonComponent.buttons.forEach((button, index) => { - // Handle URL buttons with variables - if (button.type === 'URL' && button.url && button.url.includes('{{')) { - const buttonVars = button.url.match(/{{([^}]+)}}/g) || []; - if (buttonVars.length > 0) { - if (!allVariables.buttons) allVariables.buttons = []; - allVariables.buttons[index] = { - type: 'url', - parameter: '', - url: button.url, - variables: buttonVars.map(v => processVariable(v)), - }; - } - } - - // Handle copy code buttons - if (button.type === 'COPY_CODE') { - if (!allVariables.buttons) allVariables.buttons = []; - allVariables.buttons[index] = { - type: 'copy_code', - parameter: '', - }; - } - }); - } - }); - - return allVariables; -}; +// The media-header flag is derived from the template inside the shared helper; +// the second argument is kept for backwards-compatible call sites. +export const buildTemplateParameters = template => + buildWhatsAppProcessedParams(template); diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index ce24ef653..64a29c215 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -7,6 +7,7 @@ import FBChannel from '../../api/channel/fbChannel'; import TwilioChannel from '../../api/channel/twilioChannel'; import WhatsappChannel from '../../api/channel/whatsappChannel'; import { throwErrorMessage } from '../utils/api'; +import { isSendableTemplate } from '@chatwoot/utils'; import AnalyticsHelper from '../../helper/AnalyticsHelper'; import camelcaseKeys from 'camelcase-keys'; import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events'; @@ -67,45 +68,8 @@ export const getters = { return []; } - return templates.filter(template => { - // Ensure template has required properties - if (!template || !template.status || !template.components) { - return false; - } - - // Only show approved templates - if (template.status.toLowerCase() !== 'approved') { - return false; - } - - // Filter out authentication templates - if (template.category === 'AUTHENTICATION') { - return false; - } - - // Filter out CSAT templates (customer_satisfaction_survey and its versions) - if ( - template.name && - template.name.startsWith('customer_satisfaction_survey') - ) { - return false; - } - - // Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates - const hasUnsupportedComponents = template.components.some( - component => - ['LIST', 'PRODUCT', 'CATALOG', 'CALL_PERMISSION_REQUEST'].includes( - component.type - ) || - (component.type === 'HEADER' && component.format === 'LOCATION') - ); - - if (hasUnsupportedComponents) { - return false; - } - - return true; - }); + // Sendable-template filtering is shared with the mobile app via @chatwoot/utils. + return templates.filter(isSendableTemplate); }, getNewConversationInboxes($state) { return $state.records.filter(inbox => { diff --git a/package.json b/package.json index bf00dde8a..c699787f2 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", "@chatwoot/prosemirror-schema": "1.3.22", - "@chatwoot/utils": "^0.0.55", + "@chatwoot/utils": "^0.0.56", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", "@hcaptcha/vue3-hcaptcha": "^1.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ffb85c18..40f84477a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,8 +28,8 @@ importers: specifier: 1.3.22 version: 1.3.22 '@chatwoot/utils': - specifier: ^0.0.55 - version: 0.0.55 + specifier: ^0.0.56 + version: 0.0.56 '@formkit/core': specifier: ^1.7.2 version: 1.7.2 @@ -464,8 +464,8 @@ packages: '@chatwoot/prosemirror-schema@1.3.22': resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==} - '@chatwoot/utils@0.0.55': - resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==} + '@chatwoot/utils@0.0.56': + resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==} engines: {node: '>=10'} '@codemirror/commands@6.7.0': @@ -5154,7 +5154,7 @@ snapshots: prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3) prosemirror-view: 1.34.1 - '@chatwoot/utils@0.0.55': + '@chatwoot/utils@0.0.56': dependencies: date-fns: 2.30.0 From ae49af354d018f17935f8824921c9c29c9d27948 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:04:12 +0530 Subject: [PATCH 04/11] fix: serialize multimodal Captain session content (#15096) Captain now saves agent session records when a user message includes an image. The saved record keeps the image URL and excludes downloaded image bytes, so image replies no longer report a JSON serialization error after delivery. Fixes: https://chatwoot-p3.sentry.io/issues/7618423184/?alert_rule_id=13673680&alert_type=issue¬ification_uuid=d22a7ab9-95d6-4bba-85e0-733a28466775&project=6382945 ## Root cause RubyLLM downloads image attachments and caches the binary bytes inside `RubyLLM::Content`. `SessionCaptureService` passed the live object to the `run_context` JSON column. Rails then tried to encode the cached JPEG bytes as UTF-8 and raised `JSON::GeneratorError`. The error did not block replies, handoffs, or credit updates because session capture rescues its own failures. The failed write meant that Chatwoot lost the agent session record for the response. ## How to reproduce 1. Send an image to a Captain V2 assistant. 2. Let RubyLLM load the image during the model request. 3. Save the resulting conversation history in an agent session. 4. Observe the JSON encoding error when Rails reaches the cached image bytes. ## What changed `SessionCaptureService` now converts `RubyLLM::Content` to its JSON safe hash before saving the current turn. The hash contains the message text and attachment URL without the cached bytes. Other message content is unchanged. The focused service spec covers a cached JPEG byte payload and passes with 12 examples. RuboCop reports no offenses in the changed service and spec. --- .../assistant/session_capture_service.rb | 7 ++++++- .../assistant/session_capture_service_spec.rb | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/enterprise/app/services/captain/assistant/session_capture_service.rb b/enterprise/app/services/captain/assistant/session_capture_service.rb index 36af50308..ceeb28210 100644 --- a/enterprise/app/services/captain/assistant/session_capture_service.rb +++ b/enterprise/app/services/captain/assistant/session_capture_service.rb @@ -59,6 +59,11 @@ class Captain::Assistant::SessionCaptureService def current_turn_history history = Array(context[:conversation_history]) last_user_index = history.rindex { |message| message[:role].to_s == 'user' } - last_user_index ? history[last_user_index..] : history + current_turn = last_user_index ? history[last_user_index..] : history + + current_turn.map do |message| + content = message[:content] + content.is_a?(RubyLLM::Content) ? message.merge(content: content.to_h) : message + end end end diff --git a/spec/enterprise/services/captain/assistant/session_capture_service_spec.rb b/spec/enterprise/services/captain/assistant/session_capture_service_spec.rb index ef25b1e2b..daf38c8d6 100644 --- a/spec/enterprise/services/captain/assistant/session_capture_service_spec.rb +++ b/spec/enterprise/services/captain/assistant/session_capture_service_spec.rb @@ -111,6 +111,25 @@ RSpec.describe Captain::Assistant::SessionCaptureService do expect(history.first).to include('role' => 'user', 'content' => 'CUST001') end + it 'stores multimodal content without cached attachment bytes' do + content = RubyLLM::Content.new('See image', ['https://example.com/image.jpg']) + content.attachments.first.instance_variable_set(:@content, "\xFF\xD8\xFF\xE0JFIF".b) + run_context[:conversation_history] = [ + { role: :user, content: content }, + { role: :assistant, content: 'I can see the image', agent_name: 'Assistant' } + ] + + history = service.capture!.run_context + + expect(history.first).to include( + 'role' => 'user', + 'content' => { + 'text' => 'See image', + 'attachments' => [{ 'type' => 'image', 'source' => 'https://example.com/image.jpg' }] + } + ) + end + it 'stores the full history when it contains no user message' do run_context[:conversation_history] = conversation_history.reject { |message| message[:role] == :user } From 920a98ccf463ef390a2fefedb69f622054bf8cf1 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:00:08 +0530 Subject: [PATCH 05/11] fix: calls dashboard load race (#15094) --- .../components-next/Calls/CallListItem.vue | 7 +++-- .../dashboard/calls/pages/CallsIndex.vue | 30 ++++++++++++------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/app/javascript/dashboard/components-next/Calls/CallListItem.vue b/app/javascript/dashboard/components-next/Calls/CallListItem.vue index 8b0a56bfd..dd2b3af37 100644 --- a/app/javascript/dashboard/components-next/Calls/CallListItem.vue +++ b/app/javascript/dashboard/components-next/Calls/CallListItem.vue @@ -25,8 +25,11 @@ const route = useRoute(); const kind = computed(() => getCallKind(props.call)); -const contactName = computed( - () => props.call.contact.name || props.call.contact.phoneNumber +const contactName = computed(() => + (props.call.contact.name || props.call.contact.phoneNumber || '').replace( + /^\+/, + '' + ) ); const agentActionLabel = computed(() => { diff --git a/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue b/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue index 80f8e08c5..553cbceac 100644 --- a/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue +++ b/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue @@ -1,5 +1,6 @@ diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index fe71b8974..f3c81615b 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -72,6 +72,20 @@ "RATING_TITLE": "Rating", "FEEDBACK_TITLE": "Feedback", "REPLY_MESSAGE_NOT_FOUND": "Message not available", + "CAPTAIN_GENERATION": { + "TITLE": "How was this reply generated?", + "GENERATED_BY": "Generated by Captain", + "LOADING": "Loading details…", + "EMPTY": "No generation details available for this message.", + "TIMELINE": "Generation steps", + "STEP_TOOL": "Called {name}", + "STEP_HANDOFF": "Handed off to {name}", + "REASONING": "Reasoning", + "SOURCES": "Knowledge base", + "SOURCES_SUMMARY": "{count} result | {count} results", + "MODEL": "Generated with {model}", + "CREDITS": "Credits: {credits}" + }, "CARD": { "SHOW_LABELS": "Show labels", "HIDE_LABELS": "Hide labels", diff --git a/app/javascript/dashboard/store/captain/agentSessions.js b/app/javascript/dashboard/store/captain/agentSessions.js new file mode 100644 index 000000000..e72d2e58c --- /dev/null +++ b/app/javascript/dashboard/store/captain/agentSessions.js @@ -0,0 +1,62 @@ +import CaptainAgentSessionsAPI from 'dashboard/api/captain/agentSessions'; +import camelcaseKeys from 'camelcase-keys'; + +const SET_SESSION = 'SET_SESSION'; +const SET_FETCHING = 'SET_FETCHING'; + +// Session capture runs right after the message is broadcast (and well after, +// for handoff notes created mid-run), so a 404 on a fresh message may just +// mean the session isn't written yet. Skip caching those so a later +// hover/click retries; older misses are permanent (V1 messages, failed runs). +const RECENT_MESSAGE_WINDOW_SECONDS = 60; + +// Caches Captain agent-session metadata per message id. A missing session +// (404) is cached as null so the UI shows an empty state without refetching. +export default { + namespaced: true, + state: { + sessions: {}, + fetchingIds: [], + }, + getters: { + getSessionByMessageId: state => messageId => state.sessions[messageId], + isFetching: state => messageId => state.fetchingIds.includes(messageId), + hasFetched: state => messageId => messageId in state.sessions, + }, + actions: { + fetch: async ({ state, commit }, { messageId, createdAt }) => { + if (messageId in state.sessions) return; + if (state.fetchingIds.includes(messageId)) return; + + commit(SET_FETCHING, { messageId, isFetching: true }); + try { + const { data } = await CaptainAgentSessionsAPI.show(messageId); + commit(SET_SESSION, { + messageId, + session: camelcaseKeys(data, { deep: true }), + }); + } catch (error) { + const isRecentMessage = + createdAt && + Date.now() / 1000 - createdAt < RECENT_MESSAGE_WINDOW_SECONDS; + // Only a 404 means "no session exists"; transient failures (5xx, + // network errors) stay uncached so a later hover retries. + if (error.response?.status === 404 && !isRecentMessage) { + commit(SET_SESSION, { messageId, session: null }); + } + } finally { + commit(SET_FETCHING, { messageId, isFetching: false }); + } + }, + }, + mutations: { + [SET_SESSION](state, { messageId, session }) { + state.sessions = { ...state.sessions, [messageId]: session }; + }, + [SET_FETCHING](state, { messageId, isFetching }) { + state.fetchingIds = isFetching + ? [...state.fetchingIds, messageId] + : state.fetchingIds.filter(id => id !== messageId); + }, + }, +}; diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index 23685bfad..d0c8e5002 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -50,6 +50,7 @@ import teamMembers from './modules/teamMembers'; import teams from './modules/teams'; import userNotificationSettings from './modules/userNotificationSettings'; import webhooks from './modules/webhooks'; +import captainAgentSessions from './captain/agentSessions'; import captainAssistants from './captain/assistant'; import captainDocuments from './captain/document'; import captainResponses from './captain/response'; @@ -115,6 +116,7 @@ export default createStore({ teams, userNotificationSettings, webhooks, + captainAgentSessions, captainAssistants, captainDocuments, captainResponses, diff --git a/config/routes.rb b/config/routes.rb index dfdc23727..0bf6b40c8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -76,6 +76,7 @@ Rails.application.routes.draw do resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id resources :scenarios end + resources :agent_sessions, only: [:show] resources :assistant_responses resources :message_reports, only: [:create] resources :bulk_actions, only: [:create] diff --git a/enterprise/app/controllers/api/v1/accounts/captain/agent_sessions_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/agent_sessions_controller.rb new file mode 100644 index 000000000..f9163de04 --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/captain/agent_sessions_controller.rb @@ -0,0 +1,25 @@ +class Api::V1::Accounts::Captain::AgentSessionsController < Api::V1::Accounts::BaseController + before_action :set_message + before_action :authorize_conversation + + def show + @agent_session = Current.account.captain_agent_sessions.find_by(result_type: 'Message', result_id: @message.id) + return head :not_found if @agent_session.blank? + + @citations = Current.account.captain_assistant_responses + .where(id: @agent_session.faq_ids) + .includes(:documentable) + @scenario_titles = Captain::Scenario.where(account_id: Current.account.id, id: @agent_session.scenario_ids) + .pluck(:id, :title).to_h + end + + private + + def set_message + @message = Current.account.messages.find(params[:id]) + end + + def authorize_conversation + authorize @message.conversation, :show? + end +end diff --git a/enterprise/app/services/captain/assistant/session_capture_service.rb b/enterprise/app/services/captain/assistant/session_capture_service.rb index ceeb28210..816fe5355 100644 --- a/enterprise/app/services/captain/assistant/session_capture_service.rb +++ b/enterprise/app/services/captain/assistant/session_capture_service.rb @@ -22,13 +22,12 @@ class Captain::Assistant::SessionCaptureService def capture! model = @assistant.agent_model - metadata = context.dig(:state, :cw_metadata) || {} Captain::AgentSession.create!( assistant: @assistant, session_type: :assistant, subject: @conversation, - result: @result_message, + result: result_message, llm_model: "#{Llm::Models.provider_for(model)}-#{model}", credits_consumed: @credits_consumed, faq_ids: metadata[:faq_ids] || [], @@ -44,6 +43,23 @@ class Captain::Assistant::SessionCaptureService @run_result.context || {} end + def metadata + @metadata ||= context.dig(:state, :cw_metadata) || {} + end + + # On handoff, HandoffTool records the private reason note it created; the session + # attaches there so agents can inspect the generation path on the note itself. + def result_message + handoff_note || @result_message + end + + def handoff_note + note_id = metadata[:handoff_note_id] + return if note_id.blank? + + @conversation.messages.find_by(id: note_id) + end + def scenario_ids ids = current_turn_history.filter_map do |message| next unless message[:role].to_s == 'assistant' diff --git a/enterprise/app/views/api/v1/accounts/captain/agent_sessions/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/agent_sessions/show.json.jbuilder new file mode 100644 index 000000000..1e1cd1d7d --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/agent_sessions/show.json.jbuilder @@ -0,0 +1,18 @@ +json.id @agent_session.id +json.message_id @agent_session.result_id +json.llm_model @agent_session.llm_model +json.credits_consumed @agent_session.credits_consumed +json.run_context @agent_session.run_context.is_a?(Array) ? @agent_session.run_context : [] +json.citations @citations do |citation| + json.id citation.id + json.title citation.question + # display_url resolves uploaded PDFs to their blob URL; external_link holds a + # "PDF: ..." placeholder for those. Guard on scheme so placeholders render as + # plain text instead of dead anchors. + link = citation.documentable.is_a?(Captain::Document) ? citation.documentable.display_url : nil + json.link link&.match?(%r{\Ahttps?://}) ? link : nil +end +json.scenarios @scenario_titles do |id, title| + json.id id + json.title title +end diff --git a/enterprise/lib/captain/tools/handoff_tool.rb b/enterprise/lib/captain/tools/handoff_tool.rb index d126840be..f3ca8b1fe 100644 --- a/enterprise/lib/captain/tools/handoff_tool.rb +++ b/enterprise/lib/captain/tools/handoff_tool.rb @@ -13,7 +13,7 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool }) # Use existing handoff mechanism from ResponseBuilderJob - trigger_handoff(conversation, reason) + trigger_handoff(tool_context, conversation, reason) "Conversation handed off to human support team#{" (Reason: #{reason})" if reason}" rescue StandardError => e @@ -23,9 +23,9 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool private - def trigger_handoff(conversation, reason) + def trigger_handoff(tool_context, conversation, reason) # post the reason as a private note - conversation.messages.create!( + note = conversation.messages.create!( message_type: :outgoing, private: true, sender: @assistant, @@ -34,6 +34,15 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool content: reason ) + # Session capture attributes the run to this note so agents can inspect the + # generation path on the handoff reason instead of the canned follow-up message. + # A reason-less note has no content and never renders in the dashboard, so + # leave it unrecorded and let capture fall back to the follow-up message. + if reason.present? + metadata = tool_context.state[:cw_metadata] ||= {} + metadata[:handoff_note_id] = note.id + end + # Trigger the bot handoff (sets status to open + dispatches events) conversation.bot_handoff! diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/agent_sessions_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/agent_sessions_controller_spec.rb new file mode 100644 index 000000000..1444dc0a8 --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/captain/agent_sessions_controller_spec.rb @@ -0,0 +1,120 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Accounts::Captain::AgentSessions', type: :request do + let(:account) { create(:account) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:inbox) { create(:inbox, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:message) do + create(:message, account: account, conversation: conversation, message_type: :outgoing, sender: assistant) + end + + before { create(:inbox_member, user: agent, inbox: inbox) } + + def json_response + JSON.parse(response.body, symbolize_names: true) + end + + describe 'GET /api/v1/accounts/:account_id/captain/agent_sessions/:id' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}", as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when the message has an agent session' do + let(:document) { create(:captain_document, account: account, assistant: assistant) } + let(:documented_faq) do + create(:captain_assistant_response, account: account, assistant: assistant, + question: 'How do I reset my password?', documentable: document) + end + let(:plain_faq) do + create(:captain_assistant_response, account: account, assistant: assistant, question: 'How do I change my email?') + end + let(:pdf_document) do + create(:captain_document, account: account, assistant: assistant, external_link: nil, + pdf_file: Rack::Test::UploadedFile.new(Rails.root.join('spec/assets/sample.pdf'), 'application/pdf')) + end + let(:pdf_faq) do + create(:captain_assistant_response, account: account, assistant: assistant, + question: 'What are the pricing tiers?', documentable: pdf_document) + end + let(:scenario) { create(:captain_scenario, account: account, assistant: assistant, title: 'Refund flow') } + let(:run_context) do + [ + { 'role' => 'user', 'content' => 'I want a refund' }, + { 'role' => 'assistant', 'content' => '', 'agent_name' => 'Assistant', + 'tool_calls' => [{ 'id' => 'call_1', 'name' => 'faq_lookup', 'arguments' => { 'query' => 'refund' } }] }, + { 'role' => 'tool', 'content' => 'Refunds take 5 days', 'tool_call_id' => 'call_1' }, + { 'role' => 'assistant', 'content' => 'Refunds take 5 days', 'agent_name' => "scenario_#{scenario.id}_refund_flow" } + ] + end + let!(:agent_session) do + create(:captain_agent_session, account: account, assistant: assistant, + subject: conversation, result: message, + llm_model: 'openai-gpt-5.2', credits_consumed: 1.0, + faq_ids: [documented_faq.id, plain_faq.id, pdf_faq.id, documented_faq.id + 100_000], + scenario_ids: [scenario.id], + run_context: run_context) + end + + it 'returns the session with hydrated citations and scenarios' do + get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}", + headers: agent.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + aggregate_failures do + expect(json_response[:id]).to eq(agent_session.id) + expect(json_response[:message_id]).to eq(message.id) + expect(json_response[:llm_model]).to eq('openai-gpt-5.2') + expect(json_response[:credits_consumed]).to eq(1.0) + expect(json_response[:run_context].length).to eq(4) + expect(json_response[:run_context].second[:tool_calls].first[:arguments][:query]).to eq('refund') + + citations = json_response[:citations].index_by { |citation| citation[:id] } + expect(citations.keys).to contain_exactly(documented_faq.id, plain_faq.id, pdf_faq.id) + expect(citations[documented_faq.id][:title]).to eq('How do I reset my password?') + expect(citations[documented_faq.id][:link]).to eq(document.external_link) + expect(citations[plain_faq.id][:link]).to be_nil + expect(pdf_document.external_link).to start_with('PDF:') + expect(citations[pdf_faq.id][:link]).to eq(pdf_document.display_url) + expect(citations[pdf_faq.id][:link]).to match(%r{\Ahttps?://}) + + expect(json_response[:scenarios]).to eq([{ id: scenario.id, title: 'Refund flow' }]) + end + end + + it 'does not allow an agent without access to the conversation' do + other_agent = create(:user, account: account, role: :agent) + + get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}", + headers: other_agent.create_new_auth_token, as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when the message has no agent session' do + it 'returns not found' do + get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}", + headers: agent.create_new_auth_token, as: :json + + expect(response).to have_http_status(:not_found) + end + end + + context 'when the message does not belong to the account' do + it 'returns not found' do + other_message = create(:message) + + get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{other_message.id}", + headers: agent.create_new_auth_token, as: :json + + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb index eb1cb641c..08bb2a50d 100644 --- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb +++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb @@ -561,6 +561,22 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0) end + it 'attributes the handoff session to the private reason note when the tool recorded one' do + handoff_note = create(:message, conversation: conversation, account: account, message_type: :outgoing, + private: true, sender: assistant, content: 'Needs a human') + run_context[:state][:cw_metadata][:handoff_note_id] = handoff_note.id + allow(mock_agent_runner_service).to receive(:generate_response) do + conversation.update!(status: :open) + { 'response' => 'Let me connect you', 'handoff_tool_called' => true } + end + + described_class.perform_now(conversation, assistant) + + session = Captain::AgentSession.last + expect(session.credits_consumed).to eq(0.0) + expect(session.result_id).to eq(handoff_note.id) + end + it 'creates a zero-credit session when the handoff tool fired but failed to commit' do allow(mock_agent_runner_service).to receive(:generate_response).and_return({ 'response' => 'I tried to hand off', diff --git a/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb index 492d24f32..db5ee462c 100644 --- a/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb +++ b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb @@ -86,6 +86,12 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do tool.perform(tool_context, reason: reason) end + + it 'records the handoff note id in the run state for session capture' do + tool.perform(tool_context, reason: 'Customer needs specialized support') + + expect(tool_context.state[:cw_metadata][:handoff_note_id]).to eq(Message.last.id) + end end context 'without reason provided' do @@ -107,6 +113,12 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do tool.perform(tool_context) end + + it 'does not record a handoff note id since the empty note never renders' do + tool.perform(tool_context) + + expect(tool_context.state[:cw_metadata]).to be_nil + end end context 'when handoff fails' do From 0e376f4fe238ec5011c1ab63d81774d3ac186fc5 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:19:53 +0530 Subject: [PATCH 08/11] feat(whatsapp-call): support BSUID callers for inbound voice calls (#14743) ## Linear Ticket - https://linear.app/chatwoot/issue/CW-7276/bsuid-support-to-whatsapp-voice-calling ## Description Keeps WhatsApp voice calls in the same thread as the chat when a caller has adopted a **WhatsApp username** and hidden their phone number. This makes the inbound-call path BSUID-aware, reusing the same identifier the messaging pipeline keys on so calls land on the existing `ContactInbox`/conversation. ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - Locally via UI ## 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 --------- Co-authored-by: Muhsin Keloth --- .../controllers/twilio/voice_controller.rb | 4 +- .../services/voice/inbound_call_builder.rb | 59 +++------ .../whatsapp/inbound_call_identity_builder.rb | 48 ++++++++ .../whatsapp/incoming_call_service.rb | 25 ++-- .../twilio/voice_controller_spec.rb | 4 +- .../voice/inbound_call_builder_spec.rb | 21 ++-- .../whatsapp/incoming_call_service_spec.rb | 114 ++++++++++++++++++ 7 files changed, 203 insertions(+), 72 deletions(-) create mode 100644 enterprise/app/services/whatsapp/inbound_call_identity_builder.rb diff --git a/enterprise/app/controllers/twilio/voice_controller.rb b/enterprise/app/controllers/twilio/voice_controller.rb index 66fef9583..52ec2637b 100644 --- a/enterprise/app/controllers/twilio/voice_controller.rb +++ b/enterprise/app/controllers/twilio/voice_controller.rb @@ -107,8 +107,8 @@ class Twilio::VoiceController < ApplicationController when 'inbound' Voice::InboundCallBuilder.perform!( inbox: inbox, - from_number: twilio_from, - call_sid: twilio_call_sid + call_sid: twilio_call_sid, + caller: { source_ids: [twilio_from], contact_attributes: { name: twilio_from, phone_number: twilio_from } } ) when 'outbound-api', 'outbound-dial' sync_outbound_leg(call_sid: twilio_call_sid, direction: twilio_direction) diff --git a/enterprise/app/services/voice/inbound_call_builder.rb b/enterprise/app/services/voice/inbound_call_builder.rb index eef70e76b..c928da43f 100644 --- a/enterprise/app/services/voice/inbound_call_builder.rb +++ b/enterprise/app/services/voice/inbound_call_builder.rb @@ -1,17 +1,19 @@ class Voice::InboundCallBuilder - attr_reader :inbox, :from_number, :call_sid, :provider, :extra_meta + attr_reader :inbox, :call_sid, :provider, :extra_meta, :source_ids, :contact_attributes - def self.perform!(inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {}) - new(inbox: inbox, from_number: from_number, call_sid: call_sid, - provider: provider, extra_meta: extra_meta).perform! + # `caller` carries the contact identity: { source_ids:, contact_attributes: }. Twilio passes + # its single +phone source_id; WhatsApp passes the message-path phone/user_id/parent_user_id set. + def self.perform!(inbox:, call_sid:, caller:, provider: :twilio, extra_meta: {}) + new(inbox: inbox, call_sid: call_sid, caller: caller, provider: provider, extra_meta: extra_meta).perform! end - def initialize(inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {}) + def initialize(inbox:, call_sid:, caller:, provider: :twilio, extra_meta: {}) @inbox = inbox - @from_number = from_number @call_sid = call_sid @provider = provider.to_sym @extra_meta = extra_meta || {} + @source_ids = Array(caller[:source_ids]).compact_blank + @contact_attributes = caller[:contact_attributes] || {} end def perform! @@ -43,46 +45,17 @@ class Voice::InboundCallBuilder .find_by(provider: provider, provider_call_id: call_sid) end - # Always look up by (inbox, source_id) first — that pair has a UNIQUE index, so - # creating with a colliding source_id under a different contact would raise - # RecordNotUnique. Reuse the existing ContactInbox (and its contact) when found. - # A concurrent message webhook for the same wa_id can win the (inbox_id, source_id) - # race; rescue and re-find so the call path doesn't drop the connect. + # Resolve the contact/ContactInbox the same way inbound messages do — match across every + # candidate source_id (phone + BSUID aliases) so a call reuses the existing thread, creating + # one keyed on the first (phone, else BSUID) only when none exists. Shared with messaging via + # ContactInboxSourceIdResolver, which also rescues the concurrent-webhook create race. def ensure_contact_inbox! - sid = source_id_for_provider - existing = inbox.contact_inboxes.find_by(source_id: sid) - return existing if existing - - ContactInbox.create!(contact: ensure_contact!, inbox: inbox, source_id: sid) - rescue ActiveRecord::RecordNotUnique - inbox.contact_inboxes.find_by!(source_id: sid) + ContactInboxSourceIdResolver.new( + inbox: inbox, source_ids: source_ids, contact_attributes: contact_attributes + ).perform end - def ensure_contact! - contact = account.contacts.find_or_create_by!(phone_number: from_number) do |record| - record.name = contact_name.presence || from_number - end - contact.update!(name: contact_name) if contact_name.present? && contact.name == from_number - contact - end - - # WhatsApp inbound calls carry the caller's profile name in extra_meta; Twilio - # calls don't, so contact naming falls back to the phone number. - def contact_name - extra_meta['contact_name'].presence - end - - # WhatsApp ContactInbox.source_id must be digits-only (the wa_id); Twilio accepts the +. - # Run BR/AR-style wa_id normalization (same path messaging uses) so an inbound call - # finds the existing ContactInbox instead of forking a new contact/conversation. - def source_id_for_provider - return from_number unless provider == :whatsapp - - digits = from_number.to_s.delete_prefix('+') - Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(digits, :cloud) - end - - # Mirror incoming-message routing: reuse the open conversation (or the last one when locked), else create new. + # Mirror Whatsapp::IncomingMessageBaseService#set_conversation: reuse this row's open conversation (or last when locked), else create. def resolve_conversation!(contact, contact_inbox) reusable = if inbox.lock_to_single_conversation contact_inbox.conversations.last diff --git a/enterprise/app/services/whatsapp/inbound_call_identity_builder.rb b/enterprise/app/services/whatsapp/inbound_call_identity_builder.rb new file mode 100644 index 000000000..590a1f923 --- /dev/null +++ b/enterprise/app/services/whatsapp/inbound_call_identity_builder.rb @@ -0,0 +1,48 @@ +class Whatsapp::InboundCallIdentityBuilder + pattr_initialize [:inbox!, :params!] + + # Build the message path's source_id set (phone wa_id -> user_id -> parent_user_id) plus + # contact attributes, so the resolver lands a call on the same ContactInbox a message would. + # BSUIDs ride in from_user_id/from_parent_user_id (or the contact's user_id/parent_user_id), + # never in `from` (the phone wa_id). + def perform(payload) + contact = caller_contact(payload) + phone = contact[:wa_id].presence || payload[:from].presence + source_ids = [ + phone_source_id(phone), + payload[:from_user_id].presence || contact[:user_id].presence, + payload[:from_parent_user_id].presence || contact[:parent_user_id].presence + ].compact_blank.uniq + { source_ids: source_ids, contact_attributes: contact_attributes(contact, phone, source_ids.first) } + end + + private + + # Normalize the wa_id the same way messaging does so a call matches its stored source_id. + def phone_source_id(phone) + return unless phone.to_s.match?(/\A\d{1,15}\z/) + + Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(phone.to_s, :cloud) + end + + def contact_attributes(contact, phone, source_identifier) + name = contact.dig(:profile, :name).presence || source_identifier + return { name: name } unless phone.to_s.match?(/\A\d{1,15}\z/) + + formatted = "+#{phone}" + { name: name == phone ? formatted : name, phone_number: formatted } + end + + # Match the contacts entry to THIS caller so batched payloads don't borrow another's identity. + def caller_contact(payload) + Array(params[:contacts]).map(&:with_indifferent_access).find do |c| + identifier_match?(c[:wa_id], payload[:from]) || + identifier_match?(c[:user_id], payload[:from_user_id]) || + identifier_match?(c[:parent_user_id], payload[:from_parent_user_id]) + end || {}.with_indifferent_access + end + + def identifier_match?(left, right) + left.present? && right.present? && left.to_s == right.to_s + end +end diff --git a/enterprise/app/services/whatsapp/incoming_call_service.rb b/enterprise/app/services/whatsapp/incoming_call_service.rb index 11b35d95a..ea08c8415 100644 --- a/enterprise/app/services/whatsapp/incoming_call_service.rb +++ b/enterprise/app/services/whatsapp/incoming_call_service.rb @@ -95,28 +95,21 @@ class Whatsapp::IncomingCallService # commit) already terminal, never `ringing` — agents aren't rung for a dead call. def build_inbound_call(payload, sdp_offer) ActiveRecord::Base.transaction do - call = Voice::InboundCallBuilder.perform!(inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id], - provider: :whatsapp, extra_meta: inbound_extra_meta(payload, sdp_offer)) + identity = Whatsapp::InboundCallIdentityBuilder.new(inbox: inbox, params: params).perform(payload) + extra_meta = { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers } + call = Voice::InboundCallBuilder.perform!(inbox: inbox, call_sid: payload[:id], + provider: :whatsapp, extra_meta: extra_meta, caller: identity) + sync_caller_identifiers(call, identity) tombstone = consume_terminate_tombstone(payload[:id]) finalize_terminate(call, tombstone['duration'], tombstone['terminate_reason']) if tombstone call end end - def inbound_extra_meta(payload, sdp_offer) - extra_meta = { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers } - name = caller_profile_name(payload) - extra_meta['contact_name'] = name if name.present? - extra_meta - end - - # Match strictly on wa_id (== calls[].from): in a batched payload missing this - # call's contact entry, borrowing another caller's name would corrupt this - # contact, so fall back to the phone number (nil here) instead of contacts.first. - def caller_profile_name(payload) - contacts = Array(params[:contacts]).map(&:with_indifferent_access) - match = contacts.find { |c| c[:wa_id].to_s == payload[:from].to_s } - match&.dig(:profile, :name).presence + # Backfill every caller alias (the builder only stores the first) so a later event keyed on any one lands on this thread. + def sync_caller_identifiers(call, identity) + Whatsapp::IdentifierSyncService.new(contact_inbox: call.conversation.contact_inbox, contact: call.contact) + .perform(source_ids: identity[:source_ids], phone_number: identity.dig(:contact_attributes, :phone_number)) end # `connect` is the WebRTC tunnel-ready signal, not the pickup signal. Apply diff --git a/spec/enterprise/controllers/twilio/voice_controller_spec.rb b/spec/enterprise/controllers/twilio/voice_controller_spec.rb index 79fdd67a8..0f537af20 100644 --- a/spec/enterprise/controllers/twilio/voice_controller_spec.rb +++ b/spec/enterprise/controllers/twilio/voice_controller_spec.rb @@ -33,8 +33,8 @@ RSpec.describe 'Twilio::VoiceController', type: :request do expect(Voice::InboundCallBuilder).to receive(:perform!).with( inbox: inbox, - from_number: from_number, - call_sid: call_sid + call_sid: call_sid, + caller: { source_ids: [from_number], contact_attributes: { name: from_number, phone_number: from_number } } ).and_return(call) post "/twilio/voice/call/#{digits}", params: { diff --git a/spec/enterprise/services/voice/inbound_call_builder_spec.rb b/spec/enterprise/services/voice/inbound_call_builder_spec.rb index e36c9a1b7..c8c6b978e 100644 --- a/spec/enterprise/services/voice/inbound_call_builder_spec.rb +++ b/spec/enterprise/services/voice/inbound_call_builder_spec.rb @@ -17,8 +17,8 @@ RSpec.describe Voice::InboundCallBuilder do def perform_builder described_class.perform!( inbox: inbox, - from_number: from_number, - call_sid: call_sid + call_sid: call_sid, + caller: { source_ids: [from_number], contact_attributes: { name: from_number, phone_number: from_number } } ) end @@ -100,26 +100,29 @@ RSpec.describe Voice::InboundCallBuilder do end end - context 'when the WhatsApp wa_id needs Brazil normalization to match an existing ContactInbox' do + context 'when a WhatsApp call shares a BSUID with an existing ContactInbox' do let(:whatsapp_channel) do create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', provider_config: { 'phone_number_id' => '123', 'source' => 'embedded_signup', 'calling_enabled' => true }, validate_provider_config: false, sync_templates: false) end let(:whatsapp_inbox) { whatsapp_channel.inbox } - let!(:stored_contact) { create(:contact, account: account, phone_number: '+5541988887777') } + let!(:stored_contact) { create(:contact, account: account) } let!(:stored_contact_inbox) do - create(:contact_inbox, contact: stored_contact, inbox: whatsapp_inbox, source_id: '5541988887777') + create(:contact_inbox, contact: stored_contact, inbox: whatsapp_inbox, source_id: 'IN.2081978709342942') end before { account.enable_features!('channel_voice') } - it 'reuses the contact via normalized wa_id rather than forking a new ContactInbox' do + # Closes the gap: the contact was keyed by BSUID, but the call also carries a phone. + # Matching across every source_id reuses the contact instead of forking on the phone. + it 'reuses the contact by matching any source_id, not just the first' do call = described_class.perform!( inbox: whatsapp_inbox, - from_number: '+554188887777', - call_sid: 'wacall_br_1', - provider: :whatsapp + call_sid: 'wacall_bsuid_1', + provider: :whatsapp, + caller: { source_ids: ['5541988887777', 'IN.2081978709342942'], + contact_attributes: { name: 'Ada Lovelace', phone_number: '+5541988887777' } } ) expect(call.contact).to eq(stored_contact) diff --git a/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb b/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb index a3c5246d2..cf031f1ca 100644 --- a/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb +++ b/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb @@ -74,6 +74,120 @@ describe Whatsapp::IncomingCallService do end end + describe 'inbound connect from a username (BSUID) caller' do + let(:sdp_offer) { "v=0\r\n...sdp..." } + let(:bsuid) { 'IN.2081978709342942' } + let!(:agent) { create(:user, account: account) } + + before { create(:inbox_member, inbox: inbox, user: agent) } + + it 'keys a phone caller by the phone (matching messaging) even when a BSUID is also present' do + allow(ActionCable.server).to receive(:broadcast) + + params = { + calls: [{ id: provider_call_id, from: from_number, from_user_id: bsuid, event: 'connect', + session: { sdp: sdp_offer, sdp_type: 'offer' } }], + contacts: [{ wa_id: from_number, user_id: bsuid, profile: { name: 'Ada Lovelace' } }] + } + expect { described_class.new(inbox: inbox, params: params).perform } + .to change(Call, :count).by(1).and change(Conversation, :count).by(1) + + contact_inbox = Call.last.conversation.contact_inbox + expect(contact_inbox.source_id).to eq(from_number) + expect(contact_inbox.contact.name).to eq('Ada Lovelace') + end + + it 'keys a username-only caller by the BSUID when no phone `from` is present' do + allow(ActionCable.server).to receive(:broadcast) + + params = { + calls: [{ id: provider_call_id, from_user_id: bsuid, event: 'connect', + session: { sdp: sdp_offer, sdp_type: 'offer' } }], + contacts: [{ user_id: bsuid, profile: { name: 'Ada Lovelace' } }] + } + expect { described_class.new(inbox: inbox, params: params).perform } + .to change(Call, :count).by(1) + + contact_inbox = Call.last.conversation.contact_inbox + expect(contact_inbox.source_id).to eq(bsuid) + expect(contact_inbox.contact.name).to eq('Ada Lovelace') + end + + it 'reuses the phone-keyed ContactInbox messaging created for a phone caller and backfills the BSUID alias' do + allow(ActionCable.server).to receive(:broadcast) + contact = create(:contact, account: account) + existing = create(:contact_inbox, inbox: inbox, contact: contact, source_id: from_number) + + params = { + calls: [{ id: provider_call_id, from: from_number, from_user_id: bsuid, event: 'connect', + session: { sdp: sdp_offer, sdp_type: 'offer' } }], + contacts: [{ wa_id: from_number, user_id: bsuid }] + } + # The conversation reuses the existing phone thread; the BSUID alias is backfilled onto the same contact. + expect { described_class.new(inbox: inbox, params: params).perform } + .to change(Call, :count).by(1).and change(ContactInbox, :count).by(1) + + expect(Call.last.contact).to eq(contact) + expect(Call.last.conversation.contact_inbox).to eq(existing) + expect(inbox.contact_inboxes.find_by(source_id: bsuid).contact).to eq(contact) + end + + it 'reuses a phone ContactInbox via the same wa_id normalization messaging uses' do + allow(ActionCable.server).to receive(:broadcast) + contact = create(:contact, account: account, phone_number: '+5541988887777') + existing = create(:contact_inbox, inbox: inbox, contact: contact, source_id: '5541988887777') + + params = { + calls: [{ id: provider_call_id, from: '554188887777', event: 'connect', + session: { sdp: sdp_offer, sdp_type: 'offer' } }], + contacts: [{ wa_id: '554188887777' }] + } + expect { described_class.new(inbox: inbox, params: params).perform } + .to change(Call, :count).by(1).and not_change(ContactInbox, :count) + + expect(Call.last.conversation.contact_inbox).to eq(existing) + end + + it 'reuses the BSUID-keyed ContactInbox messaging created for a username-only caller' do + allow(ActionCable.server).to receive(:broadcast) + contact = create(:contact, account: account) + existing = create(:contact_inbox, inbox: inbox, contact: contact, source_id: bsuid) + + params = { + calls: [{ id: provider_call_id, from_user_id: bsuid, event: 'connect', + session: { sdp: sdp_offer, sdp_type: 'offer' } }], + contacts: [{ user_id: bsuid }] + } + expect { described_class.new(inbox: inbox, params: params).perform } + .to change(Call, :count).by(1).and not_change(ContactInbox, :count) + + expect(Call.last.contact).to eq(contact) + expect(Call.last.conversation.contact_inbox).to eq(existing) + end + + # The gap: messaging created the contact username-only (BSUID-keyed), and the call now + # also exposes a phone. Matching across every source_id reuses the BSUID thread instead + # of forking a new phone-keyed contact. + it 'reuses a BSUID-keyed ContactInbox even when the call also carries a phone and backfills the phone alias' do + allow(ActionCable.server).to receive(:broadcast) + contact = create(:contact, account: account) + existing = create(:contact_inbox, inbox: inbox, contact: contact, source_id: bsuid) + + params = { + calls: [{ id: provider_call_id, from: from_number, from_user_id: bsuid, event: 'connect', + session: { sdp: sdp_offer, sdp_type: 'offer' } }], + contacts: [{ wa_id: from_number, user_id: bsuid }] + } + # The conversation reuses the existing BSUID thread; the phone alias is backfilled onto the same contact. + expect { described_class.new(inbox: inbox, params: params).perform } + .to change(Call, :count).by(1).and change(ContactInbox, :count).by(1) + + expect(Call.last.contact).to eq(contact) + expect(Call.last.conversation.contact_inbox).to eq(existing) + expect(inbox.contact_inboxes.find_by(source_id: from_number).contact).to eq(contact) + end + end + describe 'outbound connect (existing call)' do let!(:call) do conversation = create(:conversation, account: account, inbox: inbox) From 2144de92f26a6cab7614ae6c7b9ab4a66d935e97 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:21:40 +0530 Subject: [PATCH 09/11] fix(whatsapp): reopen conversation across a contact's coexistence identities (#15098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp contacts using coexistence are identified by more than one source ID (a phone `wa_id` and a `BR.`/BSUID identity), so a single contact ends up owning multiple `contact_inbox` records. The "reopen the same conversation" feature scoped conversation reuse to a single `contact_inbox`, so messages arriving under a different identity of the same contact started a brand-new conversation — even with reopen enabled — producing duplicate conversations. This scopes reuse to the contact across all of its `contact_inbox` records in the inbox instead of a single `contact_inbox`. ## Closes - [CW-7651 ](https://linear.app/chatwoot/issue/CW-7651/duplicate-conversations) ## How to reproduce 1. On a WhatsApp Cloud inbox with "reopen the same conversation" (lock to single conversation) enabled. 2. Have a coexistence contact whose webhooks alternate between carrying the phone `wa_id` and only the BSUID identity. 3. Before: each identity opens its own conversation → duplicates. After: incoming messages reopen the contact's existing conversation regardless of which identity the webhook carried. ## What changed - `Whatsapp::IncomingMessageBaseService#set_conversation` now looks up reusable conversations via `@contact.conversations.where(inbox_id: @inbox.id)` instead of `@contact_inbox.conversations`. - Updated existing specs to wire the conversation's `contact` to the contact_inbox's contact, mirroring production data. --- .../whatsapp/incoming_message_base_service.rb | 8 ++++--- .../whatsapp/incoming_message_service_spec.rb | 22 +++++++++---------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/app/services/whatsapp/incoming_message_base_service.rb b/app/services/whatsapp/incoming_message_base_service.rb index 00936ff15..09632c5b1 100644 --- a/app/services/whatsapp/incoming_message_base_service.rb +++ b/app/services/whatsapp/incoming_message_base_service.rb @@ -113,12 +113,14 @@ class Whatsapp::IncomingMessageBaseService end def set_conversation + # Scope reuse to the contact across all its contact_inboxes in this inbox: WhatsApp coexistence + # gives one contact multiple source_ids (phone + BSUID), so reopen must not be limited to a single contact_inbox. + conversations = @contact.conversations.where(inbox_id: @inbox.id) # if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved @conversation = if @inbox.lock_to_single_conversation - @contact_inbox.conversations.last + conversations.last else - @contact_inbox.conversations - .where.not(status: :resolved).last + conversations.where.not(status: :resolved).last end return if @conversation diff --git a/spec/services/whatsapp/incoming_message_service_spec.rb b/spec/services/whatsapp/incoming_message_service_spec.rb index 430aa1561..01b569cb9 100644 --- a/spec/services/whatsapp/incoming_message_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_service_spec.rb @@ -34,8 +34,8 @@ describe Whatsapp::IncomingMessageService do it 'appends to last conversation when if conversation already exists' do contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from]) - 2.times.each { create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) } - last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) + 2.times.each { create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact) } + last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact) described_class.new(inbox: whatsapp_channel.inbox, params: params).perform # no new conversation should be created expect(whatsapp_channel.inbox.conversations.count).to eq(3) @@ -46,7 +46,7 @@ describe Whatsapp::IncomingMessageService do it 'reopen last conversation if last conversation is resolved and lock to single conversation is enabled' do whatsapp_channel.inbox.update(lock_to_single_conversation: true) contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from]) - last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) + last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact) last_conversation.update(status: 'resolved') described_class.new(inbox: whatsapp_channel.inbox, params: params).perform # no new conversation should be created @@ -59,7 +59,7 @@ describe Whatsapp::IncomingMessageService do it 'creates a new conversation if last conversation is resolved and lock to single conversation is disabled' do whatsapp_channel.inbox.update(lock_to_single_conversation: false) contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from]) - last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) + last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact) last_conversation.update(status: 'resolved') described_class.new(inbox: whatsapp_channel.inbox, params: params).perform # new conversation should be created @@ -70,7 +70,7 @@ describe Whatsapp::IncomingMessageService do it 'will not create a new conversation if last conversation is not resolved and lock to single conversation is disabled' do whatsapp_channel.inbox.update(lock_to_single_conversation: false) contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from]) - last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) + last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact) last_conversation.update(status: Conversation.statuses.except('resolved').keys.sample) described_class.new(inbox: whatsapp_channel.inbox, params: params).perform # new conversation should be created @@ -238,7 +238,7 @@ describe Whatsapp::IncomingMessageService do end before do - create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) + create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact) described_class.new(inbox: whatsapp_channel.inbox, params: params).perform end @@ -453,7 +453,7 @@ describe Whatsapp::IncomingMessageService do it 'appends to existing contact if contact inbox exists' do contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id) - last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) + last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact) described_class.new(inbox: whatsapp_channel.inbox, params: params).perform # no new conversation should be created expect(whatsapp_channel.inbox.conversations.count).to eq(1) @@ -468,7 +468,7 @@ describe Whatsapp::IncomingMessageService do context 'when a contact inbox exists in the old format without 9 included' do it 'appends to existing contact' do contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id) - last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) + last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact) described_class.new(inbox: whatsapp_channel.inbox, params: params).perform # no new conversation should be created expect(whatsapp_channel.inbox.conversations.count).to eq(1) @@ -480,7 +480,7 @@ describe Whatsapp::IncomingMessageService do context 'when a contact inbox exists in the new format with 9 included' do it 'appends to existing contact' do contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: '5541988887777') - last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) + last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact) described_class.new(inbox: whatsapp_channel.inbox, params: params).perform # no new conversation should be created expect(whatsapp_channel.inbox.conversations.count).to eq(1) @@ -515,7 +515,7 @@ describe Whatsapp::IncomingMessageService do # Normalized format removes the 9 after country code normalized_wa_id = '541123456789' contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: normalized_wa_id) - last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) + last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact) described_class.new(inbox: whatsapp_channel.inbox, params: params).perform # no new conversation should be created expect(whatsapp_channel.inbox.conversations.count).to eq(1) @@ -532,7 +532,7 @@ describe Whatsapp::IncomingMessageService do context 'when a contact inbox exists with the same format' do it 'appends to existing contact' do contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id) - last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) + last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact) described_class.new(inbox: whatsapp_channel.inbox, params: params).perform # no new conversation should be created expect(whatsapp_channel.inbox.conversations.count).to eq(1) From 8c013415b85c1df6b72fa3b81c51d755c00c9bce Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 21 Jul 2026 16:29:08 +0530 Subject: [PATCH 10/11] fix: localize the captain overview summary greeting (#15108) --- .../openai/openai_prompts/captain_overview_summary.liquid | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/integrations/openai/openai_prompts/captain_overview_summary.liquid b/lib/integrations/openai/openai_prompts/captain_overview_summary.liquid index 96734b4d9..c5637da0f 100644 --- a/lib/integrations/openai/openai_prompts/captain_overview_summary.liquid +++ b/lib/integrations/openai/openai_prompts/captain_overview_summary.liquid @@ -1,8 +1,8 @@ You are writing a short, warm summary of how an AI support assistant named "{{ assistant_name }}" performed over a reporting period, for {{ first_name }}, the person who manages it. Voice and format: -- Write the entire summary in {{ language }}, including the opening greeting. -- Address {{ first_name }} directly and open with "Hey {{ first_name }},". Be conversational, never robotic. +- Write the entire summary in {{ language }}. +- Address {{ first_name }} directly and open with a short casual greeting to {{ first_name }} in {{ language }}, the natural equivalent of "Hey {{ first_name }},". Never leave the greeting in English when {{ language }} is not English. Be conversational, never robotic. - Always call the assistant by its name, {{ assistant_name }}. Never call it "Captain", "the assistant", or "your assistant". - This is a static, read-only poster on an analytics dashboard, not a chat. The reader cannot reply or ask you for anything. Never ask a question, invite a reply, offer further help, or say things like "let me know" or "I can dive in". - Write 2 to 4 sentences in one short paragraph. Add a second short paragraph only for a genuinely useful heads-up. From 7d2f01e40242160d34bacaacef63154552532a51 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 21 Jul 2026 15:05:11 +0400 Subject: [PATCH 11/11] feat(whatsapp): unify embedded signup feature gating (#15106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp embedded signup now uses `whatsapp_embedded_signup_inbox_creation` as the single Chatwoot Cloud rollout gate for inbox creation, proactive reconfiguration, and disconnected inbox reauthorization. The authorization endpoint enforces the same gate, so the UI and backend remain consistent. Self-hosted installations keep their existing behavior. ## Things to know - This reuses the existing feature flag; there is no migration or schema change. - The feature is shown as “WhatsApp Embedded Signup Flow” in feature management. - `whatsapp_reconfigure` remains visible and honored for self-hosted proactive reconfiguration to preserve existing accounts. It can be deprecated after the self-hosted dependency is removed or migrated. ## How to test 1. On Chatwoot Cloud, enable `whatsapp_embedded_signup_inbox_creation` for an account. 2. Confirm that new WhatsApp inbox creation, proactive reconfiguration, and disconnected inbox reauthorization are available. 3. Disable the flag and confirm those entry points are hidden and authorization requests are rejected. 4. On self-hosted, confirm proactive reconfiguration remains controlled by the existing `whatsapp_reconfigure` account setting. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> --- .../v1/accounts/whatsapp/authorizations_controller.rb | 11 +++++++++-- app/javascript/dashboard/featureFlags.js | 3 +-- .../onboarding/inbox-setup/useChannelConfig.js | 4 +--- .../routes/dashboard/settings/inbox/Settings.vue | 5 +++++ .../dashboard/settings/inbox/channels/Whatsapp.vue | 4 +--- .../settings/inbox/settingsPage/ConfigurationPage.vue | 5 ++++- config/features.yml | 2 +- 7 files changed, 22 insertions(+), 12 deletions(-) diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb index 580ae77c6..46d89a1ba 100644 --- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb @@ -1,4 +1,5 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController + before_action :ensure_embedded_signup_enabled # Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins. before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? } before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? } @@ -18,6 +19,13 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: private + def ensure_embedded_signup_enabled + return unless ChatwootApp.chatwoot_cloud? + return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation') + + raise Pundit::NotAuthorizedError + end + def process_embedded_signup service = Whatsapp::EmbeddedSignupService.new( account: Current.account, @@ -44,8 +52,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: def can_reconfigure_channel? channel = @inbox.channel return false unless channel.provider == 'whatsapp_cloud' - - # Reconfiguring a live embedded-signup channel requires the feature flag. + return true if ChatwootApp.chatwoot_cloud? return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup' true diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index e707284cb..04f46890e 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -7,8 +7,7 @@ export const FEATURE_FLAGS = { AUTOMATIONS: 'automations', CAMPAIGNS: 'campaigns', WHATSAPP_CAMPAIGNS: 'whatsapp_campaign', - WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION: - 'whatsapp_embedded_signup_inbox_creation', + WHATSAPP_EMBEDDED_SIGNUP_FLOW: 'whatsapp_embedded_signup_inbox_creation', WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer', WHATSAPP_RECONFIGURE: 'whatsapp_reconfigure', CANNED_RESPONSES: 'canned_responses', diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js index 36a78dcbe..6dedbe894 100644 --- a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js @@ -18,9 +18,7 @@ export function useChannelConfig() { // app id (not the 'none' sentinel) and the signup configuration id. whatsapp: () => (!isOnChatwootCloud.value || - isCloudFeatureEnabled( - FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION - )) && + isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW)) && Boolean(installationConfig.whatsappAppId) && installationConfig.whatsappAppId !== 'none' && Boolean(installationConfig.whatsappConfigurationId), diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 222d238a8..5e7f320f3 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -390,6 +390,11 @@ export default { return ( this.isAWhatsAppCloudChannel && this.isEmbeddedSignupWhatsApp && + (!this.isOnChatwootCloud || + this.isFeatureEnabledonAccount( + this.accountId, + FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW + )) && this.inbox.reauthorization_required ); }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue index b4e0c58af..6a4a361b0 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue @@ -42,9 +42,7 @@ const shouldShowWhatsappEmbeddedSignup = computed(() => { selectedProvider.value === PROVIDER_TYPES.WHATSAPP && hasWhatsappAppId.value && (!isOnChatwootCloud.value || - isCloudFeatureEnabled( - FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION - )) + isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW)) ); }); diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue index 61038cec1..1e9e2f704 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue @@ -56,6 +56,7 @@ export default { ...mapGetters({ accountId: 'getCurrentAccountId', isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', + isOnChatwootCloud: 'globalConfig/isOnChatwootCloud', }), isEmbeddedSignupWhatsApp() { return this.inbox.provider_config?.source === 'embedded_signup'; @@ -65,7 +66,9 @@ export default { this.isEmbeddedSignupWhatsApp && this.isFeatureEnabledonAccount( this.accountId, - FEATURE_FLAGS.WHATSAPP_RECONFIGURE + this.isOnChatwootCloud + ? FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW + : FEATURE_FLAGS.WHATSAPP_RECONFIGURE ) ); }, diff --git a/config/features.yml b/config/features.yml index 590f28814..950d6e7c5 100644 --- a/config/features.yml +++ b/config/features.yml @@ -265,6 +265,6 @@ enabled: false column: feature_flags_ext_1 - name: whatsapp_embedded_signup_inbox_creation - display_name: WhatsApp Embedded Signup Inbox Creation + display_name: WhatsApp Embedded Signup Flow enabled: false column: feature_flags_ext_1