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
+1
-1
@@ -40,7 +40,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
||||
describe '#initiate_call' do
|
||||
it 'returns the parsed body on success' do
|
||||
stub_request(:post, calls_url)
|
||||
.with(body: { messaging_product: 'whatsapp', to: '15551234567', type: 'audio',
|
||||
.with(body: { messaging_product: 'whatsapp', to: '15551234567', action: 'connect',
|
||||
session: { sdp: 'sdp_offer', sdp_type: 'offer' } }.to_json)
|
||||
.to_return(status: 200, body: { messages: [{ id: 'wacall_1' }] }.to_json, headers: headers)
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ describe Whatsapp::FacebookApiClient do
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: { override_callback_uri: callback_url, verify_token: verify_token,
|
||||
subscribed_fields: %w[messages smb_message_echoes] }.to_json
|
||||
subscribed_fields: %w[messages smb_message_echoes calls] }.to_json
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
@@ -224,7 +224,7 @@ describe Whatsapp::FacebookApiClient do
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: { override_callback_uri: callback_url, verify_token: verify_token,
|
||||
subscribed_fields: %w[messages smb_message_echoes] }.to_json
|
||||
subscribed_fields: %w[messages smb_message_echoes calls] }.to_json
|
||||
)
|
||||
.to_return(status: 400, body: { error: 'Webhook callback override failed' }.to_json)
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user