From c5236ec67e369ac3ecf9bc1d17e9f7de2aa8f0ce Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Thu, 7 May 2026 16:09:36 +0700 Subject: [PATCH] feat(voice): bridge WhatsApp Cloud Calling to Chatwoot voice pipeline [4] (#14356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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) --- .../messages/messenger/message_builder.rb | 4 + app/models/attachment.rb | 12 ++ app/models/channel/whatsapp.rb | 7 +- app/services/base/send_on_channel_service.rb | 3 +- app/services/whatsapp/facebook_api_client.rb | 2 +- app/views/api/v1/models/_inbox.json.jbuilder | 3 + config/initializers/active_storage.rb | 13 ++ config/locales/en.yml | 10 + config/routes.rb | 15 ++ .../v1/accounts/contacts/calls_controller.rb | 15 +- .../v1/accounts/whatsapp_calls_controller.rb | 187 ++++++++++++++++++ .../webhooks/whatsapp_events_job.rb | 30 ++- enterprise/app/models/call.rb | 2 - .../models/enterprise/concerns/attachment.rb | 19 ++ .../app/models/enterprise/conversation.rb | 15 ++ .../providers/whatsapp_cloud_service.rb | 19 +- .../services/voice/outbound_call_builder.rb | 9 +- .../whatsapp/call_permission_reply_service.rb | 12 ++ .../app/services/whatsapp/call_service.rb | 102 ++++++++++ .../whatsapp/incoming_call_service.rb | 51 ++++- .../whatsapp_calls/accept.json.jbuilder | 1 + .../whatsapp_calls/initiate.json.jbuilder | 5 + .../whatsapp_calls/reject.json.jbuilder | 2 + .../whatsapp_calls/show.json.jbuilder | 1 + .../whatsapp_calls/terminate.json.jbuilder | 2 + .../upload_recording.json.jbuilder | 2 + .../v1/models/_whatsapp_call.json.jbuilder | 24 +++ .../providers/whatsapp_cloud_service_spec.rb | 2 +- .../whatsapp/facebook_api_client_spec.rb | 4 +- 29 files changed, 546 insertions(+), 27 deletions(-) create mode 100644 config/initializers/active_storage.rb create mode 100644 enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb create mode 100644 enterprise/app/services/whatsapp/call_service.rb create mode 100644 enterprise/app/views/api/v1/accounts/whatsapp_calls/accept.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/whatsapp_calls/initiate.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/whatsapp_calls/reject.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/whatsapp_calls/show.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/whatsapp_calls/terminate.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/whatsapp_calls/upload_recording.json.jbuilder create mode 100644 enterprise/app/views/api/v1/models/_whatsapp_call.json.jbuilder diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index be1f98b43..ecd6f06ea 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -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) diff --git a/app/models/attachment.rb b/app/models/attachment.rb index 769e134be..8c87ff433 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -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