feat(voice): WhatsApp WABA calling backend — status sync, webhook, incoming pipeline

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.
This commit is contained in:
Tanmay Deep Sharma
2026-05-20 16:22:38 +05:30
parent a5ce1edf20
commit bff08d615a
12 changed files with 121 additions and 15 deletions
+32 -3
View File
@@ -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
+8
View File
@@ -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
+6 -4
View File
@@ -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
)
@@ -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}"
+2
View File
@@ -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
@@ -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
@@ -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
+1
View File
@@ -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,
@@ -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
@@ -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)
@@ -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
@@ -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) }