feat(voice): bridge WhatsApp Cloud Calling to Chatwoot voice pipeline

Wires Meta's WhatsApp Cloud Calling APIs into the voice subsystem so
agents can answer / place calls on WhatsApp Cloud inboxes from the
existing Voice flow. Browser ↔ Meta WebRTC is direct (no media-server
hop); Chatwoot owns the signalling, recording upload, and lifecycle
state on the Call/Message records.

Backend
- API surface: new Api::V1::Accounts::WhatsappCallsController with
  show / accept / reject / terminate / initiate / upload_recording.
  Enforces conversation-level Pundit visibility, blocks initiate when
  the channel isn't embedded-signup voice-enabled, and surfaces the
  Meta 138006 (no-call-permission) flow as a throttled opt-in template
  send under a conversation lock so concurrent retries can't double-send.
- Whatsapp::CallService — accept/reject/terminate state machine with
  call-level locking; wraps Meta API failures (transport or business)
  as Voice::CallErrors::CallFailed so the controller renders 422.
- Whatsapp::IncomingCallService — handles Meta connect/terminate
  webhooks; pins setup:active on outbound answers, materialises a
  missed-call record when terminate arrives before connect, and
  broadcasts voice_call.* events to assignee or account streams.
- Whatsapp::Providers::WhatsappCloudService — adds pre_accept/accept/
  reject/terminate/initiate/permission-request endpoints. Uses
  configurable WHATSAPP_API_VERSION (default v22) since the Calls API
  needs v17+ and OSS phone_id_path is locked at v13.
- Audio recordings: Attachment after_create_commit enqueues
  transcription and rebroadcasts the message so the FE bubble updates
  immediately. Active Storage initializer allows audio MIME types to
  serve inline. Audio transcription drops Whisper temperature to 0 to
  suppress hallucinations on silence.
- Channel::Voice stub backs legacy inbox rows whose channel_type
  survived the DropChannelVoice migration so jbuilder .try chains
  short-circuit instead of raising.

Routes / config
- /api/v1/accounts/:id/whatsapp_calls/* (enterprise-only)
- en.yml error strings under errors.whatsapp.calls.*

The FE consumer of this API ships separately on feat/whatsapp-call-ui.
This commit is contained in:
Tanmay Deep Sharma
2026-05-04 15:38:28 +07:00
parent 555894793e
commit d693d468d8
23 changed files with 431 additions and 9 deletions
+12
View File
@@ -104,11 +104,23 @@ class Attachment < ApplicationRecord
audio_file_data = base_data.merge(file_metadata)
audio_file_data.merge(
{
# ActiveStorage's redirect endpoint defaults to Content-Disposition: attachment,
# which makes <audio> elements download instead of play. Force inline so the
# call-recording chip (and any other audio bubble) can stream directly.
data_url: inline_audio_url,
transcribed_text: meta&.[]('transcribed_text') || ''
}
)
end
def inline_audio_url
return '' unless file.attached?
# Proxy endpoint streams through Rails and honours `disposition: 'inline'`,
# unlike the redirect endpoint which always sends Content-Disposition: attachment.
Rails.application.routes.url_helpers.rails_storage_proxy_url(file, disposition: 'inline')
end
def file_metadata
metadata = {
extension: extension,
+5 -1
View File
@@ -41,8 +41,12 @@ class Channel::Whatsapp < ApplicationRecord
end
# Mirrors Channel::TwilioSms#voice_enabled? so the call subsystem can duck-type across providers.
# Meta's Calling API is only available via the embedded-signup whatsapp_cloud flow —
# 360dialog (default provider) and manual whatsapp_cloud setups can't reach the call APIs.
def voice_enabled?
provider_config['calling_enabled'].present?
provider == 'whatsapp_cloud' &&
provider_config['source'] == 'embedded_signup' &&
provider_config['calling_enabled'].present?
end
def provider_service
+2 -1
View File
@@ -46,7 +46,8 @@ class Base::SendOnChannelService
def invalid_message?
# private notes aren't send to the channels
# we should also avoid the case of message loops, when outgoing messages are created from channel
message.private? || outgoing_message_originated_from_channel?
# voice_call bubbles are call status indicators, not deliverable messages
message.private? || outgoing_message_originated_from_channel? || message.content_type == 'voice_call'
end
def validate_target_channel
@@ -142,3 +142,6 @@ if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
end
end
## Voice attribute for WhatsApp Cloud (only embedded-signup channels surface true)
json.voice_enabled resource.channel.voice_enabled? if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
+13
View File
@@ -0,0 +1,13 @@
# Allow audio attachments (call recordings, voice notes) to serve inline so the
# in-app <audio> player can stream them. Without this, ActiveStorage's blob model
# forces Content-Disposition: attachment for any MIME outside the default allowlist
# (images + PDF), which makes the browser download instead of play.
Rails.application.config.active_storage.content_types_allowed_inline += %w[
audio/webm
audio/ogg
audio/mpeg
audio/mp4
audio/x-m4a
audio/wav
audio/x-wav
]
+7
View File
@@ -114,6 +114,13 @@ en:
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
calls:
not_enabled: 'Calling is not enabled for this inbox'
no_recording: 'No recording file provided'
no_message: 'Call has no associated message'
sdp_offer_required: 'sdp_offer is required'
contact_phone_required: 'Contact phone number is required'
permission_request_failed: 'Failed to send call permission request'
inboxes:
imap:
socket_error: Please check the network connection, IMAP address and try again.
+15
View File
@@ -215,6 +215,21 @@ Rails.application.routes.draw do
end
end
resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
if ChatwootApp.enterprise?
resources :whatsapp_calls, only: [:show] do
member do
post :accept
post :reject
post :terminate
post :upload_recording
end
collection do
post :initiate
end
end
end
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
@@ -0,0 +1,162 @@
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 :ensure_calling_enabled, only: :initiate
before_action :ensure_sdp_offer, only: :initiate
before_action :ensure_contact_phone, only: :initiate
before_action :ensure_recording_present, only: :upload_recording
before_action :ensure_call_message, only: :upload_recording
rescue_from Voice::CallErrors::NotRinging,
Voice::CallErrors::AlreadyAccepted,
Voice::CallErrors::CallFailed,
with: :render_call_error
rescue_from Voice::CallErrors::NoCallPermission, with: :render_permission_request
def show; end
def accept
call_service.accept
end
def reject
call_service.reject
end
def terminate
call_service.terminate
end
def upload_recording
@upload_status = @call.message.with_lock { attach_recording_idempotently }
end
def initiate
@call = create_outbound_call
@message = Voice::CallMessageBuilder.new(@call).perform!
@call.update!(message_id: @message.id)
end
private
def call_service
@call_service ||= Whatsapp::CallService.new(call: @call, agent: Current.user, sdp_answer: params[:sdp_answer])
end
def provider_service
@provider_service ||= @conversation.inbox.channel.provider_service
end
def set_call
@call = Current.account.calls.whatsapp.find(params[:id])
authorize @call.conversation, :show?
end
def set_conversation
@conversation = Current.account.conversations.find_by!(display_id: params[:conversation_id])
authorize @conversation, :show?
end
def ensure_calling_enabled
channel = @conversation.inbox.channel
return if channel.is_a?(Channel::Whatsapp) && channel.voice_enabled?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.not_enabled'))
end
def ensure_sdp_offer
return if params[:sdp_offer].present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.sdp_offer_required'))
end
def ensure_contact_phone
return if @conversation.contact&.phone_number.present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.contact_phone_required'))
end
def ensure_recording_present
return if params[:recording].present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_recording'))
end
def ensure_call_message
return if @call.message.present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_message'))
end
def attach_recording_idempotently
return 'already_uploaded' if @call.message.attachments.exists?(file_type: :audio)
@call.message.attachments.create!(account_id: @call.account_id, file_type: :audio, file: params[:recording])
'uploaded'
end
def create_outbound_call
contact_phone = @conversation.contact.phone_number.delete('+')
result = provider_service.initiate_call(contact_phone, params[:sdp_offer])
provider_call_id = result.dig('calls', 0, 'id') || result['call_id']
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 }
)
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)
status = 'permission_requested'
else
status = 'failed'
end
end
return render_could_not_create_error(I18n.t('errors.whatsapp.calls.permission_request_failed')) if status == 'failed'
render json: { status: status }
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('+'))
rescue StandardError => e
Rails.logger.warn "[WHATSAPP CALL] permission_request failed: #{e.class} #{e.message}"
nil
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)
end
def render_call_error(error)
render_could_not_create_error(error.message)
end
end
-2
View File
@@ -29,9 +29,7 @@
# index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE
#
class Call < ApplicationRecord
# All valid call statuses
STATUSES = %w[ringing in_progress completed no_answer failed].freeze
# Statuses where the call is finished and won't change again
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
store_accessor :meta, :conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
+8
View File
@@ -0,0 +1,8 @@
# Stub for legacy inboxes whose channel_type is still 'Channel::Voice' after
# the upstream DropChannelVoice migration moved voice fields onto
# Channel::TwilioSms. Pointing at the new table lets Rails resolve
# `belongs_to :channel, polymorphic: true` lookups to nil (no matching row),
# so jbuilder `.try` chains short-circuit instead of raising.
class Channel::Voice < ApplicationRecord
self.table_name = 'channel_twilio_sms'
end
@@ -3,13 +3,30 @@ module Enterprise::Concerns::Attachment
included do
after_create_commit :enqueue_audio_transcription
# Broadcast the message update so the FE bubble picks up the new audio
# attachment immediately. Without this, the FE has to wait until Whisper
# finishes (or fall back to a page refresh) — and if Whisper returns blank,
# the bubble never gets the audio at all.
after_create_commit :broadcast_message_update_for_audio
end
private
def enqueue_audio_transcription
return unless file_type.to_sym == :audio
return unless file.attached?
Messages::AudioTranscriptionJob.perform_later(id)
end
def broadcast_message_update_for_audio
return unless file_type.to_sym == :audio
return unless message
# Without an attached file, the message serializer's audio_metadata path
# dereferences `file.metadata[:width]` on nil and raises. The pre-attach
# broadcast wouldn't carry useful audio info anyway — skip until upload completes.
return unless file.attached?
message.reload.send_update_event
end
end
@@ -13,6 +13,12 @@ module Enterprise::Conversation
super + %w[sla_policy_id]
end
# Surface call lifecycle changes to the FE: writes to additional_attributes
# call_status/call_direction should rebroadcast conversation_updated.
def allowed_keys?
super || call_attributes_changed?
end
def with_captain_activity_context(reason:, reason_type:)
previous_reason = captain_activity_reason
previous_reason_type = captain_activity_reason_type
@@ -30,4 +36,13 @@ module Enterprise::Conversation
def dispatch_captain_inference_event(event_name)
dispatcher_dispatch(event_name)
end
def call_attributes_changed?
return false if previous_changes['additional_attributes'].blank?
# Compare before/after values for call keys — checking key presence alone
# rebroadcasts on any unrelated additional_attributes write once the keys exist.
before, after = previous_changes['additional_attributes']
%w[call_status call_direction].any? { |key| (before || {})[key] != (after || {})[key] }
end
end
@@ -1,4 +1,9 @@
module Enterprise::Whatsapp::Providers::WhatsappCloudService
# Calls API + the call_permission_request interactive message both require Graph
# API v17+; OSS phone_id_path is locked at v13.0 for legacy /messages compatibility.
# Use the configured global version (defaulting to v22.0) for call-flow endpoints.
WHATSAPP_CALLING_API_VERSION_FALLBACK = 'v22.0'.freeze
def pre_accept_call(call_id, sdp_answer)
call_api('pre_accept_call', call_action_body(call_id, 'pre_accept', sdp_answer))
end
@@ -17,7 +22,7 @@ module Enterprise::Whatsapp::Providers::WhatsappCloudService
def send_call_permission_request(to_phone_number, body_text = I18n.t('conversations.messages.whatsapp.call_permission_request_body'))
response = HTTParty.post(
"#{phone_id_path}/messages", headers: api_headers, body: permission_request_body(to_phone_number, body_text)
"#{calls_phone_id_path}/messages", headers: api_headers, body: permission_request_body(to_phone_number, body_text)
)
unless response.success?
@@ -30,13 +35,19 @@ module Enterprise::Whatsapp::Providers::WhatsappCloudService
def initiate_call(to_phone_number, sdp_offer)
response = HTTParty.post(
"#{phone_id_path}/calls", headers: api_headers, body: initiate_call_body(to_phone_number, sdp_offer)
"#{calls_phone_id_path}/calls", headers: api_headers, body: initiate_call_body(to_phone_number, sdp_offer)
)
process_initiate_call_response(response)
end
private
def calls_phone_id_path
base = ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
version = GlobalConfigService.load('WHATSAPP_API_VERSION', WHATSAPP_CALLING_API_VERSION_FALLBACK)
"#{base}/#{version}/#{whatsapp_channel.provider_config['phone_number_id']}"
end
def call_action_body(call_id, action, sdp_answer = nil)
body = { messaging_product: 'whatsapp', call_id: call_id, action: action }
body[:session] = { sdp: sdp_answer, sdp_type: 'answer' } if sdp_answer
@@ -44,7 +55,7 @@ module Enterprise::Whatsapp::Providers::WhatsappCloudService
end
def call_api(action_name, body)
url = "#{phone_id_path}/calls"
url = "#{calls_phone_id_path}/calls"
Rails.logger.info "[WHATSAPP CALL] #{action_name} POST #{url} body=#{body.except(:session).to_json}"
response = HTTParty.post(url, headers: api_headers, body: body.to_json)
Rails.logger.error "[WHATSAPP CALL] #{action_name} failed: status=#{response.code} body=#{response.body}" unless response.success?
@@ -67,7 +67,9 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
parameters: {
model: WHISPER_MODEL,
file: file,
temperature: 0.4
# 0.0 minimises Whisper's hallucinations on silence / sub-vocal segments —
# higher temperatures produce phantom phrases that look like real transcripts.
temperature: 0.0
}
)
transcribed_text = response['text']
@@ -0,0 +1,102 @@
class Whatsapp::CallService
pattr_initialize [:call!, :agent!, :sdp_answer]
def accept
raise Voice::CallErrors::CallFailed, 'sdp_answer is required' if sdp_answer.blank?
# All side effects under the lock so a concurrent terminate cannot finalize
# the call between status update and the message/conversation/broadcast writes.
call.with_lock do
transition_to_in_progress!
update_message_status('in_progress')
update_conversation_call_status(call.display_status)
broadcast(:accepted, accepted_by_agent_id: agent.id)
end
call
end
def reject
call.with_lock do
next if call.terminal? || call.in_progress?
invoke_provider!(:reject_call)
finalize_call('failed')
end
call
end
def terminate
call.with_lock do
next if call.terminal?
invoke_provider!(:terminate_call)
# Agent hangs up before contact picks up → no_answer; mirrors the webhook terminate path.
finalize_call(call.in_progress? ? 'completed' : 'no_answer')
end
call
end
private
def transition_to_in_progress!
# Order matters: in_progress and terminal both make ringing? false, so we have to
# branch on in_progress? first to surface the distinct AlreadyAccepted state.
raise Voice::CallErrors::AlreadyAccepted, 'Call already accepted by another agent' if call.in_progress?
raise Voice::CallErrors::NotRinging, 'Call is not in ringing state' unless call.ringing?
forward_answer_to_meta!
call.update!(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: Time.current,
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
claim_conversation_for_agent
end
def forward_answer_to_meta!
invoke_provider!(:pre_accept_call, sdp_answer)
invoke_provider!(:accept_call, sdp_answer)
end
# Take ownership of the conversation if no one holds it; leave assignee alone otherwise (transfer via UI).
def claim_conversation_for_agent
call.conversation.update!(assignee: agent) if call.conversation.assignee_id.blank?
end
# Raise on Meta failure (bool false or transport error) so callers bail before
# finalizing local state — otherwise we'd mark a still-active call as ended
# and broadcast voice_call.ended while Meta thinks it's live.
def invoke_provider!(method, *)
success = call.inbox.channel.provider_service.public_send(method, call.provider_call_id, *)
raise Voice::CallErrors::CallFailed, "Meta #{method} failed" unless success
rescue Voice::CallErrors::CallFailed
raise
rescue StandardError => e
Rails.logger.error "[WHATSAPP CALL] #{method} failed: #{e.class} #{e.message}"
raise Voice::CallErrors::CallFailed, "Meta #{method} failed"
end
def finalize_call(status)
meta = (call.meta || {}).merge('ended_at' => Time.zone.now.to_i)
call.update!(status: status, meta: meta)
update_message_status(status)
update_conversation_call_status(call.display_status)
broadcast(:ended, status: call.display_status)
end
def update_message_status(status)
Voice::CallMessageBuilder.new(call).update_status!(status: status, agent: agent)
end
def update_conversation_call_status(status)
call.conversation.update!(
additional_attributes: (call.conversation.additional_attributes || {}).merge('call_status' => status)
)
end
def broadcast(event, **extra)
payload = {
event: "voice_call.#{event}",
data: { id: call.id, call_id: call.provider_call_id, provider: call.provider,
conversation_id: call.conversation_id, account_id: call.account_id }.merge(extra)
}
ActionCable.server.broadcast("account_#{call.account_id}", payload)
end
end
@@ -50,7 +50,10 @@ class Whatsapp::IncomingCallService
end
def handle_terminate(payload)
call = Call.whatsapp.find_by(provider_call_id: payload[:id])
# Webhooks can arrive out of order (terminate before connect under tunnel/network
# delays). Materialise a missed-call record so the contact's "called and hung up"
# still surfaces in the dashboard instead of being silently dropped.
call = Call.whatsapp.find_by(provider_call_id: payload[:id]) || build_missed_inbound_call(payload)
return unless call
duration = payload[:duration]&.to_i
@@ -60,6 +63,18 @@ class Whatsapp::IncomingCallService
broadcast(call, 'voice_call.ended', status: call.display_status, duration_seconds: call.duration_seconds)
end
# No connect was ever processed (webhook reordering or never delivered) — build a
# bare Call+Message for the contact so the UI shows the missed-call bubble.
def build_missed_inbound_call(payload)
Voice::InboundCallBuilder.perform!(
inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id],
provider: :whatsapp,
extra_meta: { 'ice_servers' => Call.default_ice_servers }
)
rescue ActiveRecord::RecordNotUnique
Call.whatsapp.find_by(provider_call_id: payload[:id])
end
# accepted_by_agent_id is the initiating agent on outbound calls, so it only signals "answered" for inbound.
def answered?(call, duration)
call.in_progress? || duration.to_i.positive? || (call.incoming? && call.accepted_by_agent_id.present?)
@@ -0,0 +1 @@
json.partial! 'api/v1/models/whatsapp_call', call: @call
@@ -0,0 +1,5 @@
json.status 'calling'
json.call_id @call.provider_call_id
json.id @call.id
json.message_id @message.id
json.provider 'whatsapp'
@@ -0,0 +1,2 @@
json.id @call.id
json.status @call.display_status
@@ -0,0 +1 @@
json.partial! 'api/v1/models/whatsapp_call', call: @call
@@ -0,0 +1,2 @@
json.id @call.id
json.status @call.display_status
@@ -0,0 +1,2 @@
json.id @call.id
json.status @upload_status
@@ -0,0 +1,24 @@
contact = call.conversation&.contact
json.id call.id
json.call_id call.provider_call_id
json.provider call.provider
json.status call.display_status
json.direction call.direction_label
json.conversation_id call.conversation_id
json.inbox_id call.inbox_id
json.message_id call.message_id
json.accepted_by_agent_id call.accepted_by_agent_id
json.elapsed_seconds(call.started_at ? (Time.current - call.started_at).to_i : 0)
json.sdp_offer call.meta&.dig('sdp_offer')
json.ice_servers(call.meta&.dig('ice_servers') || Call.default_ice_servers)
if contact
json.caller do
json.name contact.name
json.phone contact.phone_number
json.avatar contact.avatar_url
end
else
json.caller({})
end