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