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 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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 @@