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 1/2] 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 2/2] 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 @@