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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user