From d1fa8d8c2f844e5868a8f327b73249d203624f3f Mon Sep 17 00:00:00 2001 From: Devi R Date: Tue, 21 Jul 2026 11:38:23 +0530 Subject: [PATCH 01/22] 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 02/22] 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 03/22] 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 06/22] 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 07/22] 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 08/22] 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 09/22] 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 From ed30ff9c2291084d0558f543be8da58003fe3025 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:08:32 +0530 Subject: [PATCH 10/22] fix(whatsapp): allow calling a contact with no existing conversation (#15014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Agents can now place a WhatsApp call to a contact straight from the contacts screen, even if that contact has never messaged in. Previously the call only worked once a conversation already existed, so a freshly added contact would fail with "Unable to start the call. Please try again." — the only workaround was to get the contact to message the channel first. ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - Manually 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 --- .../api/channel/whatsapp/whatsappCallsAPI.js | 5 +- .../Contacts/VoiceCallButton.vue | 36 +----- .../message/bubbles/VoiceCall.vue | 6 +- .../conversation/ConversationCallButton.vue | 6 +- .../composables/useWhatsappCallSession.js | 10 +- .../v1/accounts/whatsapp_calls_controller.rb | 122 +++++++----------- .../whatsapp/call_conversation_builder.rb | 32 +++++ .../call_permission_request_service.rb | 63 +++++++++ .../whatsapp_calls/initiate.json.jbuilder | 1 + 9 files changed, 166 insertions(+), 115 deletions(-) create mode 100644 enterprise/app/services/whatsapp/call_conversation_builder.rb create mode 100644 enterprise/app/services/whatsapp/call_permission_request_service.rb diff --git a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js index ec24aae34..d458c1e5d 100644 --- a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js +++ b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js @@ -10,10 +10,13 @@ class WhatsappCallsAPI extends ApiClient { return axios.get(`${this.url}/${callId}`).then(r => r.data); } - initiate(conversationId, sdpOffer) { + // Either conversationId, or contactId + inboxId to let the BE resolve the conversation. + initiate({ conversationId, contactId, inboxId }, sdpOffer) { return axios .post(`${this.url}/initiate`, { conversation_id: conversationId, + contact_id: contactId, + inbox_id: inboxId, sdp_offer: sdpOffer, }) .then(r => r.data); diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index 7e2b6f0c4..5c7ec691d 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -16,7 +16,6 @@ import { useAlert } from 'dashboard/composables'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import { useCallsStore } from 'dashboard/stores/calls'; import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession'; -import ContactAPI from 'dashboard/api/contacts'; import Button from 'dashboard/components-next/button/Button.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; @@ -83,39 +82,18 @@ const navigateToConversation = conversationId => { const whatsappCallSession = useWhatsappCallSession(); -// Find the most recent open conversation for this contact in the picked inbox. -// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path). -// Pass inboxId so the BE applies the filter before the 20-row cap — without it, -// contacts whose latest WhatsApp conversation falls outside the 20 most recent -// across all inboxes would be treated as having no conversation. -const findWhatsappConversationId = async inboxId => { - const { data } = await ContactAPI.getConversations(props.contactId, { - inboxId, - }); - const conversations = data?.payload || []; - const match = [...conversations].sort( - (a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0) - )[0]; - return match?.id || null; -}; - const startWhatsappCall = async (inboxId, conversationIdHint) => { - // WhatsApp /initiate is conversation-scoped, so we must hand it a - // conversation. Use the caller's hint when given (in-conversation flow); - // otherwise pick the most recent one in the inbox. - const conversationId = - conversationIdHint || (await findWhatsappConversationId(inboxId)); - if (!conversationId) { - useAlert(t('CONTACT_PANEL.CALL_FAILED')); - return; - } - - const response = - await whatsappCallSession.initiateOutboundCall(conversationId); + const response = await whatsappCallSession.initiateOutboundCall( + conversationIdHint + ? { conversationId: conversationIdHint } + : { contactId: props.contactId, inboxId } + ); // The composable returns { status: 'locked' } when an init is already in // flight or a call is already active; treat that as a soft no-op rather than // claiming success. if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return; + + const conversationId = response?.conversation_id || conversationIdHint; if (!response?.id) { // Permission template path returns no call id. Mirror the header button and // surface whether the request was just sent or is already pending instead of diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index 34eb1eaff..1a127d9e6 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -263,9 +263,9 @@ const handleCallBack = async () => { if (!canCallBack.value || isInitiatingCall.value) return; try { if (isWhatsapp.value) { - const response = await whatsappCallSession.initiateOutboundCall( - conversationId.value - ); + const response = await whatsappCallSession.initiateOutboundCall({ + conversationId: conversationId.value, + }); if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return; // Permission template path returns no call id — show banner, no widget yet. if (!response?.id) { diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCallButton.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCallButton.vue index d0d2a2778..f59df929c 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCallButton.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCallButton.vue @@ -69,9 +69,9 @@ const callButtonTooltip = computed(() => const startWhatsappCall = async () => { if (whatsappCallSession.isInitiating.value) return; try { - const response = await whatsappCallSession.initiateOutboundCall( - props.chat.id - ); + const response = await whatsappCallSession.initiateOutboundCall({ + conversationId: props.chat.id, + }); // Composable returns LOCKED when init is already in flight or a call is // active; soft no-op so a parallel click doesn't trigger a banner. diff --git a/app/javascript/dashboard/composables/useWhatsappCallSession.js b/app/javascript/dashboard/composables/useWhatsappCallSession.js index b934c0997..9d17c6b06 100644 --- a/app/javascript/dashboard/composables/useWhatsappCallSession.js +++ b/app/javascript/dashboard/composables/useWhatsappCallSession.js @@ -308,7 +308,8 @@ export function useWhatsappCallSession() { } }; - const initiateOutboundCall = async conversationId => { + // target: { conversationId } or { contactId, inboxId } + const initiateOutboundCall = async target => { // Module-scoped lock + active-session guard so a second click — from the // same composable instance OR a different one (header vs contact panel) // OR while a call is already live — can't tear down the in-flight setup @@ -320,10 +321,7 @@ export function useWhatsappCallSession() { isInitiatingOutbound.value = true; try { const sdpOffer = await prepareOutboundOffer(); - const response = await WhatsappCallsAPI.initiate( - conversationId, - sdpOffer - ); + const response = await WhatsappCallsAPI.initiate(target, sdpOffer); if (response?.id) { activeCallId = response.id; // A connect webhook that raced ahead of this response was buffered; @@ -354,7 +352,7 @@ export function useWhatsappCallSession() { data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_REQUESTED || data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING ) { - return { status: data.status }; + return { status: data.status, conversation_id: data.conversation_id }; } throw e; } finally { diff --git a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb index a0627ce49..2f86e0de8 100644 --- a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb @@ -1,8 +1,6 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseController - PERMISSION_REQUEST_THROTTLE = 5.minutes - before_action :set_call, only: %i[show accept reject terminate upload_recording] - before_action :set_conversation, only: :initiate + before_action :set_call_context, only: :initiate before_action :ensure_calling_enabled, only: :initiate before_action :ensure_sdp_offer, only: :initiate before_action :ensure_contact_phone, only: :initiate @@ -53,7 +51,7 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro end def provider_service - @provider_service ||= @conversation.inbox.channel.provider_service + @provider_service ||= @inbox.channel.provider_service end def set_call @@ -61,13 +59,38 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro authorize @call.conversation, :show? end - def set_conversation + def set_call_context + params[:conversation_id].present? ? set_context_from_conversation : set_context_from_contact + end + + def set_context_from_conversation @conversation = Current.account.conversations.find_by!(display_id: params[:conversation_id]) authorize @conversation, :show? + @inbox = @conversation.inbox + @contact = @conversation.contact + end + + def set_context_from_contact + @inbox = Current.account.inboxes.find(params[:inbox_id]) + authorize @inbox, :show? + @contact = Current.account.contacts.find(params[:contact_id]) + @conversation = conversation_builder.existing_conversation + # Authorize the thread the call will land in — after the dial is too late to refuse a ringing call. + authorize(@conversation || conversation_builder.new_conversation, :show?) + end + + def conversation_builder + @conversation_builder ||= Whatsapp::CallConversationBuilder.new(inbox: @inbox, contact: @contact, user: Current.user) + end + + # Created only after the dial succeeds, so a failed call leaves no empty thread and there is nothing to + # roll back. Re-authorized because a concurrent caller may have created the thread we get back. + def open_conversation! + (@conversation || conversation_builder.perform!).tap { |conversation| authorize conversation, :show? } end def ensure_calling_enabled - channel = @conversation.inbox.channel + channel = @inbox.channel return if channel.is_a?(Channel::Whatsapp) && channel.voice_enabled? render_could_not_create_error(I18n.t('errors.whatsapp.calls.not_enabled')) @@ -80,7 +103,7 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro end def ensure_contact_phone - return if @conversation.contact&.phone_number.present? + return if @contact.phone_number.present? render_could_not_create_error(I18n.t('errors.whatsapp.calls.contact_phone_required')) end @@ -105,92 +128,45 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro end def create_outbound_call - contact_phone = @conversation.contact.phone_number.delete('+') - # Claim for the caller only if unassigned at trigger time (before the round-trip); wins over auto-assignment. - claim_for_caller = @conversation.assignee_id.nil? + # A reused thread unassigned at click time is claimed for the caller (wins over auto-assignment); a + # fresh thread (@conversation nil until the dial succeeds) is created already assigned to the caller. + claim_for_caller = @conversation.present? && @conversation.assignee_id.nil? - result = provider_service.initiate_call(contact_phone, params[:sdp_offer]) + result = provider_service.initiate_call(@contact.phone_number.delete('+'), params[:sdp_offer]) provider_call_id = result.dig('calls', 0, 'id') || result['call_id'] + @conversation = open_conversation! @conversation.with_lock { @conversation.update!(assignee: Current.user) } if claim_for_caller + create_call_record(provider_call_id) + end + + def create_call_record(provider_call_id) + existing = Current.account.calls.whatsapp.find_by(provider_call_id: provider_call_id) + return existing if existing + Current.account.calls.create!( provider: :whatsapp, inbox: @conversation.inbox, conversation: @conversation, contact: @conversation.contact, provider_call_id: provider_call_id, direction: :outgoing, status: 'ringing', accepted_by_agent_id: Current.user.id, meta: { 'sdp_offer' => params[:sdp_offer], 'ice_servers' => Call.default_ice_servers } ) + rescue ActiveRecord::RecordNotUnique + # A webhook inserted the row between the find_by above and this create; reconcile to it. + Current.account.calls.whatsapp.find_by!(provider_call_id: provider_call_id) end - # Meta error 138006 means the contact hasn't opted in yet; send the opt-in - # template (throttled, behind a conversation lock to prevent double-send). def render_permission_request - status = nil - @conversation.with_lock do - if permission_request_throttled? - status = 'permission_pending' - next - end - - sent = send_permission_request_safely - if sent - record_permission_request_wamid(sent) - emit_permission_requested_activity - status = 'permission_requested' - else - status = 'failed' - end - end + # Raised mid-dial, so a fresh contact has no thread yet — open one for the opt-in template to land in. + @conversation = open_conversation! + status = Whatsapp::CallPermissionRequestService.new(conversation: @conversation).perform return render_could_not_create_error(I18n.t('errors.whatsapp.calls.permission_request_failed')) if status == 'failed' # 422 (not 200) so any client treating 2xx as "call placed" can't mistake # the permission-template path for a successful dial. The FE composable # detects this status and surfaces the banner instead of throwing. - render json: { status: status }, status: :unprocessable_entity - end - - def permission_request_throttled? - last_requested = @conversation.additional_attributes&.dig('call_permission_requested_at') - last_requested.present? && Time.zone.parse(last_requested) > PERMISSION_REQUEST_THROTTLE.ago - end - - # Treat transport errors as a falsy return so we render 422 rather than 500. - def send_permission_request_safely - provider_service.send_call_permission_request( - @conversation.contact.phone_number.delete('+'), - *permission_request_body_args - ) - rescue StandardError => e - Rails.logger.warn "[WHATSAPP CALL] permission_request failed: #{e.class} #{e.message}" - nil - end - - # Pass the inbox-level override only when present so the provider falls back - # to the i18n default for inboxes that haven't customized the prompt. - def permission_request_body_args - custom_body = @conversation.inbox.channel.provider_config&.dig('call_permission_request_body').presence - custom_body ? [custom_body] : [] - end - - def emit_permission_requested_activity - content = I18n.t( - 'conversations.activity.whatsapp_call.permission_requested', - contact_name: @conversation.contact.name - ) - ::Conversations::ActivityMessageJob.perform_later( - @conversation, - { account_id: @conversation.account_id, inbox_id: @conversation.inbox_id, message_type: :activity, content: content } - ) - end - - # Stash the outbound wamid so the reply webhook can match context.id back here. - def record_permission_request_wamid(sent) - attrs = (@conversation.additional_attributes || {}).merge( - 'call_permission_requested_at' => Time.current.iso8601, - 'call_permission_request_message_id' => sent.dig('messages', 0, 'id') - ) - @conversation.update!(additional_attributes: attrs) + render json: { status: status, conversation_id: @conversation.display_id }, status: :unprocessable_entity end def render_call_error(error) diff --git a/enterprise/app/services/whatsapp/call_conversation_builder.rb b/enterprise/app/services/whatsapp/call_conversation_builder.rb new file mode 100644 index 000000000..665f2fb20 --- /dev/null +++ b/enterprise/app/services/whatsapp/call_conversation_builder.rb @@ -0,0 +1,32 @@ +class Whatsapp::CallConversationBuilder + pattr_initialize [:inbox!, :contact!, :user!] + + # Mirrors the continuity rule in Whatsapp::IncomingMessageBaseService#set_conversation. + # Locked inboxes hold a contact to one thread, so the caller is refused rather than given a second one. + def existing_conversation + return contact_conversations.first if inbox.lock_to_single_conversation + + # Only threads the caller can open, else a newest-but-hidden thread would block the call. + Conversations::PermissionFilterService.new( + contact_conversations.where.not(status: :resolved), user, inbox.account + ).perform.first + end + + def contact_conversations + inbox.conversations.where(contact_id: contact.id).order(last_activity_at: :desc) + end + + # Unsaved, so callers can authorize the thread a call would open before dialing. + def new_conversation + inbox.account.conversations.new(inbox: inbox, contact: contact, assignee_id: user.id, status: :open) + end + + # Locked so two agents calling the same fresh contact can't open two threads. + def perform! + contact_inbox = ContactInboxBuilder.new(contact: contact, inbox: inbox).perform + + contact_inbox.with_lock do + existing_conversation || new_conversation.tap { |conversation| conversation.update!(contact_inbox: contact_inbox) } + end + end +end diff --git a/enterprise/app/services/whatsapp/call_permission_request_service.rb b/enterprise/app/services/whatsapp/call_permission_request_service.rb new file mode 100644 index 000000000..65c4f1506 --- /dev/null +++ b/enterprise/app/services/whatsapp/call_permission_request_service.rb @@ -0,0 +1,63 @@ +# Meta error 138006 means the contact hasn't opted in to calls yet; send the opt-in template. +class Whatsapp::CallPermissionRequestService + THROTTLE = 5.minutes + + pattr_initialize [:conversation!] + + # Locked so two agents calling the same contact can't both send the template. + def perform + conversation.with_lock do + next 'permission_pending' if throttled? + + sent = send_request_safely + next 'failed' if sent.blank? + + record_wamid(sent) + emit_activity + 'permission_requested' + end + end + + private + + def throttled? + last_requested = conversation.additional_attributes&.dig('call_permission_requested_at') + last_requested.present? && Time.zone.parse(last_requested) > THROTTLE.ago + end + + # Treat transport errors as a falsy return so the caller renders 422 rather than 500. + def send_request_safely + provider_service.send_call_permission_request(conversation.contact.phone_number.delete('+'), *body_args) + rescue StandardError => e + Rails.logger.warn "[WHATSAPP CALL] permission_request failed: #{e.class} #{e.message}" + nil + end + + # Pass the inbox-level override only when present so the provider falls back + # to the i18n default for inboxes that haven't customized the prompt. + def body_args + custom_body = conversation.inbox.channel.provider_config&.dig('call_permission_request_body').presence + custom_body ? [custom_body] : [] + end + + def emit_activity + content = I18n.t('conversations.activity.whatsapp_call.permission_requested', contact_name: conversation.contact.name) + ::Conversations::ActivityMessageJob.perform_later( + conversation, + { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity, content: content } + ) + end + + # Stash the outbound wamid so the reply webhook can match context.id back here. + def record_wamid(sent) + attrs = (conversation.additional_attributes || {}).merge( + 'call_permission_requested_at' => Time.current.iso8601, + 'call_permission_request_message_id' => sent.dig('messages', 0, 'id') + ) + conversation.update!(additional_attributes: attrs) + end + + def provider_service + @provider_service ||= conversation.inbox.channel.provider_service + end +end diff --git a/enterprise/app/views/api/v1/accounts/whatsapp_calls/initiate.json.jbuilder b/enterprise/app/views/api/v1/accounts/whatsapp_calls/initiate.json.jbuilder index bdb1fa204..920c5bb7d 100644 --- a/enterprise/app/views/api/v1/accounts/whatsapp_calls/initiate.json.jbuilder +++ b/enterprise/app/views/api/v1/accounts/whatsapp_calls/initiate.json.jbuilder @@ -2,4 +2,5 @@ json.status 'calling' json.call_id @call.provider_call_id json.id @call.id json.message_id @message.id +json.conversation_id @conversation.display_id json.provider 'whatsapp' From 89b83c65c843e87018fa2e6d190cf3fbc65c880e Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 21 Jul 2026 19:34:57 +0530 Subject: [PATCH 11/22] fix: close message generation popover when its trigger scrolls away (#15114) --- .../NewConversation/ComposeConversation.vue | 1 + .../components-next/popover/Popover.vue | 30 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue index 02a00c703..2446e0e2b 100644 --- a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue +++ b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue @@ -234,6 +234,7 @@ onMounted(() => resetContacts()); ref="popoverRef" :align="align" :show-content-border="false" + :close-on-scroll="false" @show="onPopoverShow" @hide="onPopoverHide" > diff --git a/app/javascript/dashboard/components-next/popover/Popover.vue b/app/javascript/dashboard/components-next/popover/Popover.vue index 5b67e572f..9d369133f 100644 --- a/app/javascript/dashboard/components-next/popover/Popover.vue +++ b/app/javascript/dashboard/components-next/popover/Popover.vue @@ -1,7 +1,11 @@