From 66d9ddb9c4cd4a7c9b46252e4f2c54de43874af8 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Tue, 5 May 2026 14:13:10 +0700 Subject: [PATCH 1/2] feat(voice): add activity messages + customizable body for call permission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit a conversation activity message when the call permission template is sent (in the controller's render_permission_request) and another when the contact accepts the prompt (in CallPermissionReplyService). Read an inbox provider_config['call_permission_request_body'] override before falling back to the i18n default — surfaced in the inbox UI in a separate change. --- config/locales/en.yml | 3 +++ .../v1/accounts/whatsapp_calls_controller.rb | 24 ++++++++++++++++++- .../whatsapp/call_permission_reply_service.rb | 12 ++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index ca77e45a7..c7ea07ea4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -298,6 +298,9 @@ en: issue_created: 'Linear issue %{issue_id} was created by %{user_name}' issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}' issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}' + whatsapp_call: + permission_requested: 'Sent a call permission request to %{contact_name}.' + permission_granted: '%{contact_name} accepted the call permission request.' csat: not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions' auto_resolve: 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 c1dbacecd..04ba67dd1 100644 --- a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb @@ -123,6 +123,7 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro sent = send_permission_request_safely if sent record_permission_request_wamid(sent) + emit_permission_requested_activity status = 'permission_requested' else status = 'failed' @@ -141,12 +142,33 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro # 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('+')) + 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( diff --git a/enterprise/app/services/whatsapp/call_permission_reply_service.rb b/enterprise/app/services/whatsapp/call_permission_reply_service.rb index 42f4621f5..f05a6cd2f 100644 --- a/enterprise/app/services/whatsapp/call_permission_reply_service.rb +++ b/enterprise/app/services/whatsapp/call_permission_reply_service.rb @@ -11,11 +11,23 @@ class Whatsapp::CallPermissionReplyService return unless conversation clear_permission_flag(conversation) + emit_permission_granted_activity(conversation) broadcast_permission_granted(conversation.contact, conversation) end private + def emit_permission_granted_activity(conversation) + content = I18n.t( + 'conversations.activity.whatsapp_call.permission_granted', + 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 + def extract_reply_data message = params.dig(:entry, 0, :changes, 0, :value, :messages, 0) reply = message&.dig(:interactive, :call_permission_reply) From 5086edefb32cd39df8c9f0f39b00641cdd04284d Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Tue, 5 May 2026 14:13:26 +0700 Subject: [PATCH 2/2] fix(voice): use status=ACCEPTED as the real pickup signal for outbound calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Meta delivers two payload shapes under field=calls: value.calls[] (event: connect/terminate) and value.statuses[] (status: RINGING/ACCEPTED). The dispatcher only forwarded the calls[] array, so status webhooks were silently dropped. Empirically `connect` for BUSINESS_INITIATED outbound fires when the WebRTC tunnel is up, not on pickup — sometimes ~20s before the contact actually answers. The terminate webhook's start_time aligns with the ACCEPTED status timestamp, confirming ACCEPTED is the true pickup. - whatsapp_events_job: route value.statuses[] (type=call) to IncomingCallService under the same per-call_id mutex. - incoming_call_service: handle_status routes ACCEPTED to mark_outbound_accepted (flip in_progress, set started_at, broadcast voice_call.outbound_accepted). Strip the in_progress flip from the connect handler — connect now only stores the SDP answer + broadcasts voice_call.outbound_connected so the browser can complete the DTLS handshake during ringing. --- .../webhooks/whatsapp_events_job.rb | 30 ++++++++++++---- .../whatsapp/incoming_call_service.rb | 34 +++++++++++++++++-- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb b/enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb index 0c24720f5..b1e2bc053 100644 --- a/enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb +++ b/enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb @@ -23,16 +23,34 @@ module Enterprise::Webhooks::WhatsappEventsJob params.dig(:entry, 0, :changes, 0, :value, :messages, 0, :interactive, :type) == 'call_permission_reply' end - # Per-call_id mutex so connect/terminate for the same call serialize across batches. + # Per-call_id mutex so connect/status/terminate for the same call serialize + # across batches. Meta delivers two payload shapes under field=calls: + # - value.calls[] → event-based (connect, terminate) + # - value.statuses[] → status-based (RINGING, ACCEPTED) — the real pickup + # signal for outbound; without this, only `connect` (tunnel-up) is seen + # and timer/recorder kick off before the contact actually answers. def handle_call_events(channel, params) - calls = params.dig(:entry, 0, :changes, 0, :value, :calls) || [] - calls.each do |call_payload| - lock_key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, - inbox_id: channel.inbox.id, sender_id: "call:#{call_payload[:id]}") - with_lock(lock_key, 30.seconds) do + value = params.dig(:entry, 0, :changes, 0, :value) || {} + + Array(value[:calls]).each do |call_payload| + with_call_lock(channel, call_payload[:id]) do Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload] }).perform end end + + Array(value[:statuses]).each do |status_payload| + next unless status_payload[:type] == 'call' + + with_call_lock(channel, status_payload[:id]) do + Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { statuses: [status_payload] }).perform + end + end + end + + def with_call_lock(channel, call_id, &) + lock_key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, + inbox_id: channel.inbox.id, sender_id: "call:#{call_id}") + with_lock(lock_key, 30.seconds, &) end def handle_call_permission_reply(channel, params) diff --git a/enterprise/app/services/whatsapp/incoming_call_service.rb b/enterprise/app/services/whatsapp/incoming_call_service.rb index 709862230..17a0b4446 100644 --- a/enterprise/app/services/whatsapp/incoming_call_service.rb +++ b/enterprise/app/services/whatsapp/incoming_call_service.rb @@ -5,6 +5,7 @@ class Whatsapp::IncomingCallService return unless inbox.channel.voice_enabled? Array(params[:calls]).each { |c| handle_event(c.with_indifferent_access) } + Array(params[:statuses]).each { |s| handle_status(s.with_indifferent_access) } end private @@ -17,6 +18,32 @@ class Whatsapp::IncomingCallService end end + # Meta's `connect` event for outbound calls fires when the WebRTC tunnel is + # up — empirically ~20s before the contact actually answers. The real pickup + # is reported as a separate webhook with status=ACCEPTED, and is what + # `terminate.start_time` aligns to. Treat ACCEPTED as the pickup transition. + def handle_status(payload) + return unless payload[:type] == 'call' + + call = Call.whatsapp.find_by(provider_call_id: payload[:id]) + return unless call + + case payload[:status] + when 'ACCEPTED' then mark_outbound_accepted(call, payload) + when 'RINGING' then nil # informational + else Rails.logger.info "[WHATSAPP CALL] Unhandled call status: #{payload[:status]} for #{payload[:id]}" + end + end + + def mark_outbound_accepted(call, payload) + return unless call.outgoing? + return if call.in_progress? || call.terminal? + + started_at = Time.zone.at(payload[:timestamp].to_i) if payload[:timestamp].present? + update_call!(call, 'in_progress', started_at: started_at || Time.current) + broadcast(call, 'voice_call.outbound_accepted') + end + def handle_connect(payload) call = Call.whatsapp.find_by(provider_call_id: payload[:id]) return create_inbound_call(payload) if call.nil? @@ -38,14 +65,15 @@ class Whatsapp::IncomingCallService broadcast_incoming(call, sdp_offer) end + # `connect` is the WebRTC tunnel-ready signal, not the pickup signal. Apply + # Meta's SDP answer so the handshake completes during ringing; the call + # stays in `ringing` until status=ACCEPTED arrives. def accept_outbound_call(call, payload) return if call.in_progress? || call.terminal? # Pin setup:active so browsers don't renegotiate when Meta echoes actpass. sdp_answer = payload.dig(:session, :sdp)&.gsub('a=setup:actpass', 'a=setup:active') - update_call!(call, 'in_progress', - started_at: Time.current, - meta: (call.meta || {}).merge('sdp_answer' => sdp_answer)) + call.update!(meta: (call.meta || {}).merge('sdp_answer' => sdp_answer)) broadcast(call, 'voice_call.outbound_connected', sdp_answer: sdp_answer) end