feat(voice): bridge WhatsApp Cloud Calling to Chatwoot voice pipeline [4] (#14356)
# Pull Request Template ## Description Bridges WhatsApp Cloud Calling onto Chatwoot's voice pipeline so agents can answer and place calls on WhatsApp Cloud inboxes with the same UX as Twilio voice. Browser ↔ Meta WebRTC is direct (no media-server hop); Chatwoot owns signalling, recording upload, and `Call`/`Message` lifecycle state. Companion PRs: - FE: #14346 (`feat/whatsapp-call-ui`) — UI consumer for these endpoints. - Specs: #14357 (`feat/whatsapp-call-meta-bridge-specs`) — backend test coverage. ## Type of change - [x] New feature (non-breaking change which adds functionality) ## What changed - **API** — new `Api::V1::Accounts::WhatsappCallsController` with `show / accept / reject / terminate / initiate / upload_recording`. Conversation-level Pundit auth. `initiate` is gated on the channel being embedded-signup voice-enabled. Permission-blocked initiations (Meta error 138006) return **422** with `{ status: 'permission_requested' | 'permission_pending' }` and trigger a throttled opt-in template send under a conversation lock. - **State machine** — `Whatsapp::CallService` handles accept / reject / terminate with call-level locking. Meta failures are wrapped as `Voice::CallErrors::CallFailed` so the controller renders 422 instead of leaking 500s. - **Webhooks** — `Whatsapp::IncomingCallService` processes Meta connect/terminate events. Pins `setup:active` on outbound answers, materialises a missed-call record when terminate arrives before connect (out-of-order delivery), and broadcasts `voice_call.*` events to the assignee's pubsub stream when assigned, otherwise account-wide. - **Provider** — `Whatsapp::Providers::WhatsappCloudService` adds `pre_accept_call / accept_call / reject_call / terminate_call / initiate_call / send_call_permission_request`. Uses configurable `WHATSAPP_API_VERSION` (default v22) since Calls API needs v17+ and the OSS `phone_id_path` is locked at v13 for legacy `/messages` compatibility. - **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. ## How to test 1. Set up a WhatsApp Cloud Embedded Signup inbox; flip `provider_config.calling_enabled = true` (Calls tab in inbox settings — ships with FE PR #14346). 2. Place a real call from a phone number that has previously messaged the business → expect `voice_call.incoming` cable + `Call` row + `voice_call` Message bubble. 3. Click Accept (FE PR) → `/whatsapp_calls/:id/accept` 200, audio flows browser ↔ Meta, recording uploads on hangup, Whisper transcript appears within ~10s. 4. From Chatwoot, click outbound → `/whatsapp_calls/initiate` 200 if the contact is opted-in, otherwise **422** with `status: 'permission_requested'` and a template send (FE shows a banner instead of an error toast). 5. Refresh during a ringing call → call survives on Meta, FE seeds it back from the conversation cache, agent can still accept. 6. Refresh during an active call → `pagehide` beacon terminates the call on Meta with `status: 'completed'` (no orphan). ## Checklist - [x] Code follows project style (RuboCop / ESLint clean) - [x] Self-review done - [x] Hard-to-understand areas commented - [ ] Documentation update (architecture doc lives in companion branch) - [x] No new warnings - [x] Tests live in companion PR #14357 - [x] Existing tests pass locally - [x] Dependent changes merged downstream (FE #14346 consumes these endpoints) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
29fd83c1ea
commit
c5236ec67e
@@ -28,6 +28,10 @@ class Messages::Messenger::MessageBuilder
|
||||
filename: attachment_file.original_filename,
|
||||
content_type: attachment_file.content_type
|
||||
)
|
||||
# The Attachment row is saved before the blob is attached, so the
|
||||
# after_create_commit broadcast bails on `file.attached?`. Re-fire here
|
||||
# for audio so the bubble updates without waiting on transcription.
|
||||
attachment.message&.reload&.send_update_event if attachment.file_type.to_sym == :audio
|
||||
end
|
||||
|
||||
def attachment_params(attachment)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,8 +41,13 @@ 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? &&
|
||||
account.feature_enabled?('channel_voice')
|
||||
end
|
||||
|
||||
def provider_service
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -86,7 +86,7 @@ class Whatsapp::FacebookApiClient
|
||||
body: {
|
||||
override_callback_uri: callback_url,
|
||||
verify_token: verify_token,
|
||||
subscribed_fields: %w[messages smb_message_echoes]
|
||||
subscribed_fields: %w[messages smb_message_echoes calls]
|
||||
}.to_json
|
||||
)
|
||||
|
||||
|
||||
@@ -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?)
|
||||
|
||||
Reference in New Issue
Block a user