From bff08d615ae088eafd2aa2a4d430e0297aeb5cd6 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Wed, 20 May 2026 15:57:32 +0530 Subject: [PATCH] =?UTF-8?q?feat(voice):=20WhatsApp=20WABA=20calling=20back?= =?UTF-8?q?end=20=E2=80=94=20status=20sync,=20webhook,=20incoming=20pipeli?= =?UTF-8?q?ne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend for WhatsApp WABA calling: query/enable/disable calling status via Meta, webhook re-subscribe, inbox policy, and incoming call service wiring. Builds on the WhatsApp calling UI branch. --- app/models/channel/whatsapp.rb | 35 +++++++++++++++++-- app/policies/inbox_policy.rb | 8 +++++ app/services/whatsapp/facebook_api_client.rb | 10 +++--- .../whatsapp/webhook_setup_service.rb | 14 +++++--- config/routes.rb | 2 ++ .../api/v1/accounts/conference_controller.rb | 15 ++++++++ .../api/v1/accounts/inboxes_controller.rb | 20 +++++++++++ enterprise/app/models/call.rb | 1 + .../providers/whatsapp_cloud_service.rb | 16 +++++++++ .../app/services/voice/call_status/manager.rb | 3 ++ .../app/services/whatsapp/call_service.rb | 3 +- .../whatsapp/incoming_call_service.rb | 9 +++-- 12 files changed, 121 insertions(+), 15 deletions(-) diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index e1d4b226a..7b44ea7e6 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -58,6 +58,34 @@ class Channel::Whatsapp < ApplicationRecord end end + # Enables voice: turns calling on at Meta (idempotent), subscribes the `calls` + # webhook field, and sets calling_enabled. Raises on Meta failure. + # The flag is persisted with validate: false so the remote credential check in + # validate_provider_config can't transiently fail and leave the local flag out + # of sync with the changes already made at Meta. + def enable_voice_calling! + raise 'Voice calling is only supported on whatsapp_cloud channels' unless provider == 'whatsapp_cloud' + + provider_service.update_calling_status('ENABLED') + webhook_setup_service.register_callback + self.provider_config = provider_config.merge('calling_enabled' => true) + save!(validate: false) + end + + # Disables voice: unsets calling_enabled (gates the call subsystem) and drops + # `calls` from the webhook subscription (best-effort, so a Meta outage can't + # trap admins). Leaves Meta's WABA calling.status untouched. + def disable_voice_calling! + raise 'Voice calling is only supported on whatsapp_cloud channels' unless provider == 'whatsapp_cloud' + + update!(provider_config: provider_config.merge('calling_enabled' => false)) + begin + webhook_setup_service.register_callback(subscribed_fields: %w[messages smb_message_echoes]) + rescue StandardError => e + Rails.logger.warn "[WHATSAPP CALL] disable webhook re-subscribe failed: #{e.message}" + end + end + def mark_message_templates_updated # rubocop:disable Rails/SkipsModelValidations update_column(:message_templates_last_updated, Time.zone.now) @@ -88,10 +116,11 @@ class Channel::Whatsapp < ApplicationRecord end def perform_webhook_setup - business_account_id = provider_config['business_account_id'] - api_key = provider_config['api_key'] + webhook_setup_service.perform + end - Whatsapp::WebhookSetupService.new(self, business_account_id, api_key).perform + def webhook_setup_service + Whatsapp::WebhookSetupService.new(self, provider_config['business_account_id'], provider_config['api_key']) end def teardown_webhooks diff --git a/app/policies/inbox_policy.rb b/app/policies/inbox_policy.rb index d77b183ee..e516a498e 100644 --- a/app/policies/inbox_policy.rb +++ b/app/policies/inbox_policy.rb @@ -69,4 +69,12 @@ class InboxPolicy < ApplicationPolicy def reset_secret? @account_user.administrator? end + + def enable_whatsapp_calling? + @account_user.administrator? + end + + def disable_whatsapp_calling? + @account_user.administrator? + end end diff --git a/app/services/whatsapp/facebook_api_client.rb b/app/services/whatsapp/facebook_api_client.rb index 94e46dabd..eef84b022 100644 --- a/app/services/whatsapp/facebook_api_client.rb +++ b/app/services/whatsapp/facebook_api_client.rb @@ -60,14 +60,16 @@ class Whatsapp::FacebookApiClient data['code_verification_status'] == 'VERIFIED' end - def subscribe_waba_webhook(waba_id, callback_url, verify_token) + WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes calls].freeze + + def subscribe_waba_webhook(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS) # Step 1: Subscribe app to WABA first (required before override) # Meta requires the app to be subscribed before using override_callback_uri # See: https://github.com/chatwoot/chatwoot/issues/13097 subscribe_app_to_waba(waba_id) # Step 2: Override callback URL for this specific WABA - override_waba_callback(waba_id, callback_url, verify_token) + override_waba_callback(waba_id, callback_url, verify_token, subscribed_fields: subscribed_fields) end def subscribe_app_to_waba(waba_id) @@ -79,14 +81,14 @@ class Whatsapp::FacebookApiClient handle_response(response, 'App subscription to WABA failed') end - def override_waba_callback(waba_id, callback_url, verify_token) + def override_waba_callback(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS) response = HTTParty.post( "#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps", headers: request_headers, body: { override_callback_uri: callback_url, verify_token: verify_token, - subscribed_fields: %w[messages smb_message_echoes calls] + subscribed_fields: subscribed_fields }.to_json ) diff --git a/app/services/whatsapp/webhook_setup_service.rb b/app/services/whatsapp/webhook_setup_service.rb index 97a53eb9a..a287b4977 100644 --- a/app/services/whatsapp/webhook_setup_service.rb +++ b/app/services/whatsapp/webhook_setup_service.rb @@ -17,9 +17,9 @@ class Whatsapp::WebhookSetupService setup_webhook end - def register_callback + def register_callback(subscribed_fields: nil) validate_parameters! - setup_webhook + setup_webhook(subscribed_fields: subscribed_fields) end private @@ -55,12 +55,16 @@ class Whatsapp::WebhookSetupService @channel.save! end - def setup_webhook + def setup_webhook(subscribed_fields: nil) callback_url = build_callback_url verify_token = @channel.provider_config['webhook_verify_token'] - @api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token) - + args = [@waba_id, callback_url, verify_token] + if subscribed_fields + @api_client.subscribe_waba_webhook(*args, subscribed_fields: subscribed_fields) + else + @api_client.subscribe_waba_webhook(*args) + end rescue StandardError => e Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}") raise "Webhook setup failed: #{e.message}" diff --git a/config/routes.rb b/config/routes.rb index 355491d5b..2b901d109 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -260,6 +260,8 @@ Rails.application.routes.draw do resource :conference, only: %i[create destroy], controller: 'conference' do get :token, on: :member end + post :enable_whatsapp_calling, on: :member + post :disable_whatsapp_calling, on: :member end resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' do diff --git a/enterprise/app/controllers/api/v1/accounts/conference_controller.rb b/enterprise/app/controllers/api/v1/accounts/conference_controller.rb index 1123699d8..6361a060d 100644 --- a/enterprise/app/controllers/api/v1/accounts/conference_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/conference_controller.rb @@ -27,6 +27,7 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle def destroy call = resolve_call! + finalize_as_agent_reject!(call) if agent_rejecting_before_pickup?(call) Voice::Provider::Twilio::ConferenceService.new(call: call).end_conference render json: { status: 'success', id: call.conversation.display_id } end @@ -59,4 +60,18 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle def render_call_already_accepted(error) render json: { error: error.message }, status: :conflict end + + # A hangup before pickup is treated as an agent rejection, matching WhatsApp. + def agent_rejecting_before_pickup?(call) + call.ringing? && call.accepted_by_agent_id.nil? + end + + def finalize_as_agent_reject!(call) + call.update!( + status: 'failed', + end_reason: 'agent_rejected', + accepted_by_agent_id: Current.user.id + ) + Voice::CallMessageBuilder.new(call).update_status!(status: 'failed', agent: Current.user) + end end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb index f9d828806..30806e326 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb @@ -3,6 +3,26 @@ module Enterprise::Api::V1::Accounts::InboxesController super + ee_inbox_attributes end + def enable_whatsapp_calling + channel = @inbox.channel + return render_could_not_create_error('Not a WhatsApp Cloud inbox') unless channel.is_a?(Channel::Whatsapp) && channel.provider == 'whatsapp_cloud' + + channel.enable_voice_calling! + head :ok + rescue StandardError => e + render_could_not_create_error(e.message) + end + + def disable_whatsapp_calling + channel = @inbox.channel + return render_could_not_create_error('Not a WhatsApp Cloud inbox') unless channel.is_a?(Channel::Whatsapp) && channel.provider == 'whatsapp_cloud' + + channel.disable_voice_calling! + head :ok + rescue StandardError => e + render_could_not_create_error(e.message) + end + def ee_inbox_attributes [auto_assignment_config: [:max_assignment_limit]] end diff --git a/enterprise/app/models/call.rb b/enterprise/app/models/call.rb index f8c0580f8..e111cdd48 100644 --- a/enterprise/app/models/call.rb +++ b/enterprise/app/models/call.rb @@ -116,6 +116,7 @@ class Call < ApplicationRecord direction: direction, status: display_status, duration_seconds: duration_seconds, + end_reason: end_reason, conference_sid: conference_sid, accepted_by_agent_id: accepted_by_agent_id, accepted_by_agent_name: accepted_by_agent&.available_name, diff --git a/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb b/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb index ec29a5a38..2fcf4b5e7 100644 --- a/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb +++ b/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb @@ -40,6 +40,22 @@ module Enterprise::Whatsapp::Providers::WhatsappCloudService process_initiate_call_response(response) end + # Sets WABA calling status ('ENABLED'/'DISABLED'). Returns true, or raises with + # Meta's user-facing message on failure so the caller can surface it. + def update_calling_status(status) + response = HTTParty.post( + "#{calls_phone_id_path}/settings", + headers: api_headers, + body: { calling: { status: status } }.to_json + ) + return true if response.success? + + parsed = response.parsed_response.is_a?(Hash) ? response.parsed_response : {} + message = parsed.dig('error', 'error_user_msg') || parsed.dig('error', 'message') || 'Failed to update calling status' + Rails.logger.error "[WHATSAPP CALL] update_calling_status failed: status=#{response.code} body=#{response.body}" + raise message + end + private def calls_phone_id_path diff --git a/enterprise/app/services/voice/call_status/manager.rb b/enterprise/app/services/voice/call_status/manager.rb index 73ace3a78..942ee0cc0 100644 --- a/enterprise/app/services/voice/call_status/manager.rb +++ b/enterprise/app/services/voice/call_status/manager.rb @@ -4,6 +4,9 @@ class Voice::CallStatus::Manager def process_status_update(status, duration: nil, timestamp: nil) return unless Call::STATUSES.include?(status) return if call.status == status + # Don't overwrite a terminal status — Twilio's late `completed` events would + # otherwise clobber an agent-rejection reason. + return if Call::TERMINAL_STATUSES.include?(call.status) apply_call_updates!(status, duration: duration, timestamp: timestamp) call.conversation.update!(last_activity_at: Time.zone.now) diff --git a/enterprise/app/services/whatsapp/call_service.rb b/enterprise/app/services/whatsapp/call_service.rb index 8a52ea6bc..93eba957c 100644 --- a/enterprise/app/services/whatsapp/call_service.rb +++ b/enterprise/app/services/whatsapp/call_service.rb @@ -20,7 +20,8 @@ class Whatsapp::CallService next if call.terminal? || call.in_progress? invoke_provider!(:reject_call) - finalize_call('failed') + call.update!(accepted_by_agent_id: agent.id) if call.accepted_by_agent_id.nil? + finalize_call('failed', end_reason: 'agent_rejected') end call end diff --git a/enterprise/app/services/whatsapp/incoming_call_service.rb b/enterprise/app/services/whatsapp/incoming_call_service.rb index d290e96c2..99f6350ff 100644 --- a/enterprise/app/services/whatsapp/incoming_call_service.rb +++ b/enterprise/app/services/whatsapp/incoming_call_service.rb @@ -159,17 +159,22 @@ class Whatsapp::IncomingCallService ) end - # Ring the assignee if assigned; otherwise account-wide so any agent can pick up. + # Ring the assignee if any, else online inbox agents, else the whole account. def broadcast_incoming(call, sdp_offer) contact = call.contact token = call.conversation.assignee&.pubsub_token + streams = token ? [token] : (online_agent_streams.presence || account_streams) broadcast(call, 'voice_call.incoming', - streams: token ? [token] : account_streams, + streams: streams, direction: call.direction_label, inbox_id: call.inbox_id, sdp_offer: sdp_offer, ice_servers: Call.default_ice_servers, caller: { name: contact.name, phone: contact.phone_number, avatar: contact.avatar_url }) end + def online_agent_streams + inbox.available_agents.pluck('users.pubsub_token').compact + end + def broadcast(call, event, streams: account_streams, **extra) payload = { event: event, data: base_payload(call).merge(extra) } streams.each { |s| ActionCable.server.broadcast(s, payload) }