feat(voice): add WhatsApp inbound call webhook pipeline [3] (#14315)
Adds the server-side flow that turns Meta WhatsApp Cloud Calling webhooks into Chatwoot Calls, conversations, voice_call message bubbles, and ActionCable broadcasts. Stacked on top of #14312 (PR-2 — provider methods); intentionally does not include the HTTP controller, routes, or frontend (those land in PR-4 and PR-9). ## Closes - Part of the WhatsApp Cloud Calling rollout. Linear: TBD ## What changed **Webhook routing** - `app/jobs/webhooks/whatsapp_events_job.rb` — append `prepend_mod_with('Webhooks::WhatsappEventsJob')` so EE can extend it without forking. - `enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb` (new) — overlay that prepends `handle_message_events` to intercept `field: 'calls'` payloads (route to `Whatsapp::IncomingCallService`) and `interactive.call_permission_reply` messages (route to `Whatsapp::CallPermissionReplyService`); falls through with `super` for regular messages. **Services** - `enterprise/app/services/whatsapp/incoming_call_service.rb` (new) — gated on `provider_config['calling_enabled']`; processes `connect` (creates inbound call via `Voice::InboundCallBuilder` or transitions an existing outbound call to `in_progress`) and `terminate` events; updates conversation `additional_attributes` and broadcasts `voice_call.incoming`/`voice_call.outbound_connected`/`voice_call.ended`. - `enterprise/app/services/whatsapp/call_permission_reply_service.rb` (new) — handles WhatsApp interactive `call_permission_reply` replies; clears the conversation's `call_permission_requested_at` flag and broadcasts `voice_call.permission_granted` so the agent UI can re-enable the call button. **Builder/model adjustments** - `enterprise/app/services/voice/inbound_call_builder.rb` — provider-agnostic; accepts `provider:` and `extra_meta:` kwargs, drops `account:` (now derived from `inbox.account` to keep the param count under rubocop's ceiling without disabling cops), uses digits-only `source_id` for WhatsApp ContactInbox (validation requires `^\d{1,15}\z`), skips Twilio-only `conference_sid` for non-Twilio providers. - `enterprise/app/services/voice/call_message_builder.rb` — adds `create!`/`update_status!` API and `CALL_TO_VOICE_STATUS` map; uses direct `Message.create!` (bypasses `Messages::MessageBuilder`'s incoming-on-non-Api-inbox guard, which would otherwise reject the system bubble); content is `'WhatsApp Call'` for WhatsApp and `'Voice Call'` for Twilio. Backwards-compatible `perform!` retained for the existing Twilio call sites. - `enterprise/app/models/call.rb` — adds `default_ice_servers` (driven by `VOICE_CALL_STUN_URLS` env), `direction_label` alias for the `inbound`/`outbound` strings the FE expects, and `ringing?`/`in_progress?`/`terminal?` predicates used throughout the pipeline. **Outgoing-channel guard** - `app/services/base/send_on_channel_service.rb` — extends `invalid_message?` to skip messages with `content_type == 'voice_call'`. Without this, agent-initiated outbound calls (PR-4) would deliver \"WhatsApp Call\" as a text message to the contact every time. **Twilio call-site update** - `enterprise/app/controllers/twilio/voice_controller.rb` — drops the now-redundant `account: current_account` kwarg from the `Voice::InboundCallBuilder.perform!` call. **Tests** - New: `spec/enterprise/services/whatsapp/incoming_call_service_spec.rb` (5 examples — calling-disabled, inbound connect, outbound connect, terminate completed, terminate no-answer, unknown event). - New: `spec/enterprise/services/whatsapp/call_permission_reply_service_spec.rb` (3 examples — accept, reject, calling-disabled). - Updated: `spec/enterprise/services/voice/inbound_call_builder_spec.rb` and `spec/enterprise/controllers/twilio/voice_controller_spec.rb` to drop the `account:` kwarg from call expectations. ## How to test In `rails console` against an account with a WhatsApp inbox where `provider_config['calling_enabled']` is true: ```ruby inbox = Inbox.find(<id>) params = { calls: [{ id: 'wacid_test', from: '15550001111', event: 'connect', session: { sdp: 'v=0...', sdp_type: 'offer' } }] } Whatsapp::IncomingCallService.new(inbox: inbox, params: params).perform # => Conversation + Call (status: 'ringing', provider: 'whatsapp') + voice_call message bubble # => ActionCable broadcasts `voice_call.incoming` to the assignee or account-wide # Then terminate it: Whatsapp::IncomingCallService.new(inbox: inbox, params: { calls: [{ id: 'wacid_test', event: 'terminate', duration: 0, terminate_reason: 'no_answer' }] } ).perform # => Call status flips to 'no_answer', message bubble updates, `voice_call.ended` broadcast fires ``` End-to-end browser flow (Meta → cable → UI) requires the controller from PR-4 and the frontend from PR-9. --------- 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
79a7423f9f
commit
de696a55cb
@@ -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)
|
||||
|
||||
@@ -128,3 +128,5 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
|
||||
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||
end
|
||||
end
|
||||
|
||||
Webhooks::WhatsappEventsJob.prepend_mod_with('Webhooks::WhatsappEventsJob')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -40,6 +40,16 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
'Whatsapp'
|
||||
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 == 'whatsapp_cloud' &&
|
||||
provider_config['source'] == 'embedded_signup' &&
|
||||
provider_config['calling_enabled'].present? &&
|
||||
account.feature_enabled?('channel_voice')
|
||||
end
|
||||
|
||||
def provider_service
|
||||
if provider == 'whatsapp_cloud'
|
||||
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -143,3 +143,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?)
|
||||
|
||||
@@ -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
|
||||
]
|
||||
@@ -116,6 +116,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.
|
||||
@@ -246,6 +253,9 @@ en:
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
call_permission_request_body: 'We would like to call you regarding your conversation.'
|
||||
voice_call:
|
||||
twilio: 'Voice Call'
|
||||
whatsapp: 'WhatsApp Call'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -290,6 +300,9 @@ en:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
whatsapp_call:
|
||||
permission_requested: 'Sent a call permission request to %{contact_name}.'
|
||||
permission_granted: '%{contact_name} accepted the call permission request.'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
auto_resolve:
|
||||
|
||||
@@ -228,6 +228,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
|
||||
|
||||
@@ -2,13 +2,12 @@ module Enterprise::Messages::MessageBuilder
|
||||
private
|
||||
|
||||
def message_type
|
||||
return @message_type if @message_type == 'incoming' && twilio_voice_inbox? && @params[:content_type] == 'voice_call'
|
||||
return @message_type if @message_type == 'incoming' && voice_call_inbox? && @params[:content_type] == 'voice_call'
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def twilio_voice_inbox?
|
||||
inbox = @conversation.inbox
|
||||
inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled?
|
||||
def voice_call_inbox?
|
||||
@conversation.inbox.channel.try(:voice_enabled?)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,7 +10,8 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
|
||||
account: Current.account,
|
||||
inbox: voice_inbox,
|
||||
user: Current.user,
|
||||
contact: contact
|
||||
contact: contact,
|
||||
conversation: existing_conversation
|
||||
)
|
||||
|
||||
render json: {
|
||||
@@ -38,4 +39,20 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
|
||||
inbox
|
||||
end
|
||||
end
|
||||
|
||||
# Reuse the open conversation when the caller is already inside it; ignore
|
||||
# the hint if it doesn't belong to the picked voice inbox or dialed contact.
|
||||
# Only reuse open conversations — Message#reopen_conversation skips outgoing
|
||||
# messages, so dropping a voice_call bubble into a resolved/snoozed/pending
|
||||
# thread leaves it stuck in that state.
|
||||
def existing_conversation
|
||||
return nil if params[:conversation_id].blank?
|
||||
|
||||
conversation = Current.account.conversations.find_by(display_id: params[:conversation_id])
|
||||
return nil unless conversation
|
||||
return nil unless conversation.inbox_id == voice_inbox.id && conversation.contact_id == contact.id
|
||||
return nil unless conversation.open?
|
||||
|
||||
conversation
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
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)
|
||||
emit_permission_requested_activity
|
||||
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'
|
||||
|
||||
# 422 (not 200) so any client treating 2xx as "call placed" can't mistake
|
||||
# the permission-template path for a successful dial. The FE composable
|
||||
# detects this status and surfaces the banner instead of throwing.
|
||||
render json: { status: status }, status: :unprocessable_entity
|
||||
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('+'),
|
||||
*permission_request_body_args
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[WHATSAPP CALL] permission_request failed: #{e.class} #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
# Pass the inbox-level override only when present so the provider falls back
|
||||
# to the i18n default for inboxes that haven't customized the prompt.
|
||||
def permission_request_body_args
|
||||
custom_body = @conversation.inbox.channel.provider_config&.dig('call_permission_request_body').presence
|
||||
custom_body ? [custom_body] : []
|
||||
end
|
||||
|
||||
def emit_permission_requested_activity
|
||||
content = I18n.t(
|
||||
'conversations.activity.whatsapp_call.permission_requested',
|
||||
contact_name: @conversation.contact.name
|
||||
)
|
||||
::Conversations::ActivityMessageJob.perform_later(
|
||||
@conversation,
|
||||
{ account_id: @conversation.account_id, inbox_id: @conversation.inbox_id, message_type: :activity, content: content }
|
||||
)
|
||||
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
|
||||
@@ -94,7 +94,6 @@ class Twilio::VoiceController < ApplicationController
|
||||
case twilio_direction
|
||||
when 'inbound'
|
||||
Voice::InboundCallBuilder.perform!(
|
||||
account: current_account,
|
||||
inbox: inbox,
|
||||
from_number: twilio_from,
|
||||
call_sid: twilio_call_sid
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
module Enterprise::Webhooks::WhatsappEventsJob
|
||||
def handle_message_events(channel, params)
|
||||
return handle_call_events(channel, params) if call_event?(params)
|
||||
return handle_call_permission_reply(channel, params) if call_permission_reply?(params)
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Lock per-call_id inside handle_call_events instead of the parent's per-sender mutex.
|
||||
def contact_sender_id(params)
|
||||
return nil if call_event?(params)
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def call_event?(params)
|
||||
params.dig(:entry, 0, :changes, 0, :field) == 'calls'
|
||||
end
|
||||
|
||||
def call_permission_reply?(params)
|
||||
params.dig(:entry, 0, :changes, 0, :value, :messages, 0, :interactive, :type) == 'call_permission_reply'
|
||||
end
|
||||
|
||||
# Per-call_id mutex so connect/status/terminate for the same call serialize
|
||||
# across batches. Meta delivers two payload shapes under field=calls:
|
||||
# - value.calls[] → event-based (connect, terminate)
|
||||
# - value.statuses[] → status-based (RINGING, ACCEPTED) — the real pickup
|
||||
# signal for outbound; without this, only `connect` (tunnel-up) is seen
|
||||
# and timer/recorder kick off before the contact actually answers.
|
||||
def handle_call_events(channel, params)
|
||||
value = params.dig(:entry, 0, :changes, 0, :value) || {}
|
||||
|
||||
Array(value[:calls]).each do |call_payload|
|
||||
with_call_lock(channel, call_payload[:id]) do
|
||||
Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload] }).perform
|
||||
end
|
||||
end
|
||||
|
||||
Array(value[:statuses]).each do |status_payload|
|
||||
next unless status_payload[:type] == 'call'
|
||||
|
||||
with_call_lock(channel, status_payload[:id]) do
|
||||
Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { statuses: [status_payload] }).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def with_call_lock(channel, call_id, &)
|
||||
lock_key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX,
|
||||
inbox_id: channel.inbox.id, sender_id: "call:#{call_id}")
|
||||
with_lock(lock_key, 30.seconds, &)
|
||||
end
|
||||
|
||||
def handle_call_permission_reply(channel, params)
|
||||
Whatsapp::CallPermissionReplyService.new(inbox: channel.inbox, params: params).perform
|
||||
end
|
||||
end
|
||||
@@ -29,13 +29,16 @@
|
||||
# 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, :twilio_conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
|
||||
|
||||
# Frontend voice bubbles/stores expect inbound/outbound string values
|
||||
DISPLAY_DIRECTION = { 'incoming' => 'inbound', 'outgoing' => 'outbound' }.freeze
|
||||
|
||||
DEFAULT_STUN_URL = 'stun:stun.l.google.com:19302'.freeze
|
||||
|
||||
enum :provider, { twilio: 0, whatsapp: 1 }
|
||||
enum :direction, { incoming: 0, outgoing: 1 }
|
||||
|
||||
@@ -65,6 +68,28 @@ class Call < ApplicationRecord
|
||||
"conf_account_#{account_id}_call_#{id}"
|
||||
end
|
||||
|
||||
# Browser ↔ Meta WebRTC needs at least one STUN server to discover its public srflx candidate.
|
||||
def self.default_ice_servers
|
||||
urls = ENV.fetch('VOICE_CALL_STUN_URLS', DEFAULT_STUN_URL).split(',').filter_map { |u| u.strip.presence }
|
||||
[{ urls: urls }]
|
||||
end
|
||||
|
||||
def direction_label
|
||||
DISPLAY_DIRECTION[direction]
|
||||
end
|
||||
|
||||
def ringing?
|
||||
status == 'ringing'
|
||||
end
|
||||
|
||||
def in_progress?
|
||||
status == 'in_progress'
|
||||
end
|
||||
|
||||
def terminal?
|
||||
TERMINAL_STATUSES.include?(status)
|
||||
end
|
||||
|
||||
def display_status
|
||||
status.to_s.tr('_', '-')
|
||||
end
|
||||
|
||||
@@ -3,6 +3,11 @@ 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
|
||||
@@ -10,6 +15,20 @@ module Enterprise::Concerns::Attachment
|
||||
def enqueue_audio_transcription
|
||||
return unless file_type.to_sym == :audio
|
||||
|
||||
# No file.attached? guard: the social-media ingest path saves the
|
||||
# Attachment before attaching the blob. AudioTranscriptionJob retries
|
||||
# on ActiveStorage::FileNotFoundError to ride out that race.
|
||||
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?
|
||||
@@ -65,7 +76,7 @@ module Enterprise::Whatsapp::Providers::WhatsappCloudService
|
||||
|
||||
def initiate_call_body(to_phone_number, sdp_offer)
|
||||
{
|
||||
messaging_product: 'whatsapp', to: to_phone_number, type: 'audio',
|
||||
messaging_product: 'whatsapp', to: to_phone_number, action: 'connect',
|
||||
session: { sdp: sdp_offer, sdp_type: 'offer' }
|
||||
}.to_json
|
||||
end
|
||||
|
||||
@@ -7,15 +7,30 @@ class Voice::CallMessageBuilder
|
||||
call.message || create_message!
|
||||
end
|
||||
|
||||
def update_status!(status:, agent: nil, duration_seconds: nil)
|
||||
message = call.message
|
||||
return unless message
|
||||
|
||||
patch = {
|
||||
'status' => status&.to_s&.tr('_', '-'),
|
||||
'accepted_by' => agent && { 'id' => agent.id, 'name' => agent.name },
|
||||
'duration_seconds' => duration_seconds
|
||||
}.compact
|
||||
|
||||
message.update!(content_attributes: (message.content_attributes || {}).deep_merge('data' => patch))
|
||||
message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :call
|
||||
|
||||
def create_message!
|
||||
params = {
|
||||
content: 'Voice Call',
|
||||
content: I18n.t("conversations.messages.voice_call.#{call.provider}"),
|
||||
message_type: call.outgoing? ? 'outgoing' : 'incoming',
|
||||
content_type: 'voice_call'
|
||||
content_type: 'voice_call',
|
||||
content_attributes: { 'data' => build_data_payload }
|
||||
}
|
||||
Messages::MessageBuilder.new(sender, call.conversation, params).perform
|
||||
end
|
||||
@@ -23,4 +38,15 @@ class Voice::CallMessageBuilder
|
||||
def sender
|
||||
call.outgoing? ? call.accepted_by_agent : call.contact
|
||||
end
|
||||
|
||||
# call_source lets the FE disambiguate WhatsApp vs Twilio without re-fetching the Call.
|
||||
def build_data_payload
|
||||
{
|
||||
'call_id' => call.id,
|
||||
'call_sid' => call.provider_call_id,
|
||||
'call_source' => call.provider,
|
||||
'call_direction' => call.direction_label,
|
||||
'status' => call.display_status
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
class Voice::InboundCallBuilder
|
||||
attr_reader :account, :inbox, :from_number, :call_sid
|
||||
attr_reader :inbox, :from_number, :call_sid, :provider, :extra_meta
|
||||
|
||||
def self.perform!(account:, inbox:, from_number:, call_sid:)
|
||||
new(account: account, inbox: inbox, from_number: from_number, call_sid: call_sid).perform!
|
||||
def self.perform!(inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {})
|
||||
new(inbox: inbox, from_number: from_number, call_sid: call_sid,
|
||||
provider: provider, extra_meta: extra_meta).perform!
|
||||
end
|
||||
|
||||
def initialize(account:, inbox:, from_number:, call_sid:)
|
||||
@account = account
|
||||
def initialize(inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {})
|
||||
@inbox = inbox
|
||||
@from_number = from_number
|
||||
@call_sid = call_sid
|
||||
@provider = provider.to_sym
|
||||
@extra_meta = extra_meta || {}
|
||||
end
|
||||
|
||||
def perform!
|
||||
@@ -17,8 +19,8 @@ class Voice::InboundCallBuilder
|
||||
return existing if existing
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
contact = ensure_contact!
|
||||
contact_inbox = ensure_contact_inbox!(contact)
|
||||
contact_inbox = ensure_contact_inbox!
|
||||
contact = contact_inbox.contact
|
||||
conversation = resolve_conversation!(contact, contact_inbox)
|
||||
call = create_call!(contact, conversation)
|
||||
message = Voice::CallMessageBuilder.new(call).perform!
|
||||
@@ -26,15 +28,34 @@ class Voice::InboundCallBuilder
|
||||
call
|
||||
end
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
# A concurrent Twilio retry won the create race; return what now exists.
|
||||
# A concurrent provider retry won the create race; return what now exists.
|
||||
find_existing_call || raise
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account
|
||||
inbox.account
|
||||
end
|
||||
|
||||
def find_existing_call
|
||||
Call.where(account_id: account.id, inbox_id: inbox.id)
|
||||
.find_by(provider: :twilio, provider_call_id: call_sid)
|
||||
.find_by(provider: provider, provider_call_id: call_sid)
|
||||
end
|
||||
|
||||
# Always look up by (inbox, source_id) first — that pair has a UNIQUE index, so
|
||||
# creating with a colliding source_id under a different contact would raise
|
||||
# RecordNotUnique. Reuse the existing ContactInbox (and its contact) when found.
|
||||
# A concurrent message webhook for the same wa_id can win the (inbox_id, source_id)
|
||||
# race; rescue and re-find so the call path doesn't drop the connect.
|
||||
def ensure_contact_inbox!
|
||||
sid = source_id_for_provider
|
||||
existing = inbox.contact_inboxes.find_by(source_id: sid)
|
||||
return existing if existing
|
||||
|
||||
ContactInbox.create!(contact: ensure_contact!, inbox: inbox, source_id: sid)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
inbox.contact_inboxes.find_by!(source_id: sid)
|
||||
end
|
||||
|
||||
def ensure_contact!
|
||||
@@ -43,13 +64,14 @@ class Voice::InboundCallBuilder
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_contact_inbox!(contact)
|
||||
ContactInbox.find_or_create_by!(
|
||||
contact_id: contact.id,
|
||||
inbox_id: inbox.id
|
||||
) do |record|
|
||||
record.source_id = from_number
|
||||
end
|
||||
# WhatsApp ContactInbox.source_id must be digits-only (the wa_id); Twilio accepts the +.
|
||||
# Run BR/AR-style wa_id normalization (same path messaging uses) so an inbound call
|
||||
# finds the existing ContactInbox instead of forking a new contact/conversation.
|
||||
def source_id_for_provider
|
||||
return from_number unless provider == :whatsapp
|
||||
|
||||
digits = from_number.to_s.delete_prefix('+')
|
||||
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(digits, :cloud)
|
||||
end
|
||||
|
||||
def resolve_conversation!(contact, contact_inbox)
|
||||
@@ -76,13 +98,14 @@ class Voice::InboundCallBuilder
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
contact: contact,
|
||||
provider: :twilio,
|
||||
provider: provider,
|
||||
direction: :incoming,
|
||||
status: 'ringing',
|
||||
provider_call_id: call_sid,
|
||||
meta: { 'initiated_at' => Time.zone.now.to_i }
|
||||
meta: { 'initiated_at' => Time.zone.now.to_i }.merge(extra_meta.stringify_keys)
|
||||
)
|
||||
call.update!(conference_sid: call.default_conference_sid)
|
||||
# `conference_sid` is a Twilio bridging concept; WhatsApp goes browser↔Meta.
|
||||
call.update!(conference_sid: call.default_conference_sid) if call.twilio?
|
||||
call
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
class Voice::OutboundCallBuilder
|
||||
attr_reader :account, :inbox, :user, :contact
|
||||
|
||||
def self.perform!(account:, inbox:, user:, contact:)
|
||||
new(account: account, inbox: inbox, user: user, contact: contact).perform!
|
||||
def self.perform!(account:, inbox:, user:, contact:, conversation: nil)
|
||||
new(account: account, inbox: inbox, user: user, contact: contact, conversation: conversation).perform!
|
||||
end
|
||||
|
||||
def initialize(account:, inbox:, user:, contact:)
|
||||
def initialize(account:, inbox:, user:, contact:, conversation: nil)
|
||||
@account = account
|
||||
@inbox = inbox
|
||||
@user = user
|
||||
@contact = contact
|
||||
@existing_conversation = conversation
|
||||
end
|
||||
|
||||
def perform!
|
||||
@@ -18,7 +19,7 @@ class Voice::OutboundCallBuilder
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
contact_inbox = ensure_contact_inbox!
|
||||
conversation = create_conversation!(contact_inbox)
|
||||
conversation = @existing_conversation || create_conversation!(contact_inbox)
|
||||
call_sid = initiate_call!
|
||||
call = create_call!(conversation, call_sid)
|
||||
message = Voice::CallMessageBuilder.new(call).perform!
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
class Whatsapp::CallPermissionReplyService
|
||||
pattr_initialize [:inbox!, :params!]
|
||||
|
||||
def perform
|
||||
return unless inbox.channel.voice_enabled?
|
||||
|
||||
reply_data = extract_reply_data
|
||||
return unless reply_data&.dig(:accepted)
|
||||
|
||||
conversation = find_requesting_conversation(reply_data[:context_id])
|
||||
return unless conversation
|
||||
|
||||
clear_permission_flag(conversation)
|
||||
emit_permission_granted_activity(conversation)
|
||||
broadcast_permission_granted(conversation.contact, conversation)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def emit_permission_granted_activity(conversation)
|
||||
content = I18n.t(
|
||||
'conversations.activity.whatsapp_call.permission_granted',
|
||||
contact_name: conversation.contact.name
|
||||
)
|
||||
::Conversations::ActivityMessageJob.perform_later(
|
||||
conversation,
|
||||
{ account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity, content: content }
|
||||
)
|
||||
end
|
||||
|
||||
def extract_reply_data
|
||||
message = params.dig(:entry, 0, :changes, 0, :value, :messages, 0)
|
||||
reply = message&.dig(:interactive, :call_permission_reply)
|
||||
return unless reply
|
||||
|
||||
accepted = reply[:response] == 'accept'
|
||||
Rails.logger.info "[WHATSAPP CALL] call_permission_reply from=#{message[:from]} accepted=#{accepted} permanent=#{reply[:is_permanent]}"
|
||||
{ from_number: message[:from], accepted: accepted, context_id: message.dig(:context, :id) }
|
||||
end
|
||||
|
||||
# Match the reply to the conversation whose request message it actually points
|
||||
# at (interactive replies carry context.id = our outbound wamid). Recency-based
|
||||
# lookup would broadcast to the wrong thread when a contact has multiple
|
||||
# parallel pending requests.
|
||||
def find_requesting_conversation(context_id)
|
||||
return if context_id.blank?
|
||||
|
||||
inbox.conversations
|
||||
.where.not(status: :resolved)
|
||||
.where("additional_attributes ->> 'call_permission_request_message_id' = ?", context_id)
|
||||
.first
|
||||
end
|
||||
|
||||
def clear_permission_flag(conversation)
|
||||
attrs = (conversation.additional_attributes || {}).except(
|
||||
'call_permission_requested_at', 'call_permission_request_message_id'
|
||||
)
|
||||
conversation.update!(additional_attributes: attrs)
|
||||
end
|
||||
|
||||
def broadcast_permission_granted(contact, conversation)
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{inbox.account_id}",
|
||||
{
|
||||
event: 'voice_call.permission_granted',
|
||||
data: {
|
||||
account_id: inbox.account_id, conversation_id: conversation.id,
|
||||
contact_name: contact.name, contact_phone: contact.phone_number
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,110 @@
|
||||
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)
|
||||
# Compute duration from started_at locally — the webhook arrives after the
|
||||
# call is already terminal and the idempotency guard there bails before it
|
||||
# can fill these fields, so we have to record them here.
|
||||
if call.in_progress?
|
||||
duration = call.started_at ? (Time.current - call.started_at).to_i : nil
|
||||
finalize_call('completed', duration_seconds: duration, end_reason: 'agent_hangup')
|
||||
else
|
||||
# Agent hangs up before contact picks up → no_answer; mirrors the webhook terminate path.
|
||||
finalize_call('no_answer', end_reason: 'agent_hangup')
|
||||
end
|
||||
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, **attrs)
|
||||
meta = (call.meta || {}).merge('ended_at' => Time.zone.now.to_i)
|
||||
call.update!(status: status, meta: meta, **attrs)
|
||||
update_message_status(status, duration_seconds: attrs[:duration_seconds])
|
||||
update_conversation_call_status(call.display_status)
|
||||
broadcast(:ended, status: call.display_status)
|
||||
end
|
||||
|
||||
def update_message_status(status, duration_seconds: nil)
|
||||
Voice::CallMessageBuilder.new(call).update_status!(status: status, agent: agent, duration_seconds: duration_seconds)
|
||||
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
|
||||
@@ -0,0 +1,186 @@
|
||||
class Whatsapp::IncomingCallService
|
||||
pattr_initialize [:inbox!, :params!]
|
||||
|
||||
def perform
|
||||
return unless inbox.channel.voice_enabled?
|
||||
|
||||
Array(params[:calls]).each { |c| handle_event(c.with_indifferent_access) }
|
||||
Array(params[:statuses]).each { |s| handle_status(s.with_indifferent_access) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_event(payload)
|
||||
case payload[:event]
|
||||
when 'connect' then handle_connect(payload)
|
||||
when 'terminate' then handle_terminate(payload)
|
||||
else Rails.logger.warn "[WHATSAPP CALL] Unknown call event: #{payload[:event]}"
|
||||
end
|
||||
end
|
||||
|
||||
# Meta's `connect` event for outbound calls fires when the WebRTC tunnel is
|
||||
# up — empirically ~20s before the contact actually answers. The real pickup
|
||||
# is reported as a separate webhook with status=ACCEPTED, and is what
|
||||
# `terminate.start_time` aligns to. Treat ACCEPTED as the pickup transition.
|
||||
def handle_status(payload)
|
||||
return unless payload[:type] == 'call'
|
||||
|
||||
call = Call.whatsapp.find_by(provider_call_id: payload[:id])
|
||||
return unless call
|
||||
|
||||
case payload[:status]
|
||||
when 'ACCEPTED' then mark_outbound_accepted(call, payload)
|
||||
when 'RINGING' then nil # informational
|
||||
else Rails.logger.info "[WHATSAPP CALL] Unhandled call status: #{payload[:status]} for #{payload[:id]}"
|
||||
end
|
||||
end
|
||||
|
||||
# with_lock + reload here serializes against Whatsapp::CallService (agent
|
||||
# actions hold call.with_lock across the Meta API call), so a webhook racing
|
||||
# with a terminate can't overwrite a freshly-finalized terminal status.
|
||||
def mark_outbound_accepted(call, payload)
|
||||
call.with_lock do
|
||||
next unless call.outgoing?
|
||||
next if call.in_progress? || call.terminal?
|
||||
|
||||
started_at = Time.zone.at(payload[:timestamp].to_i) if payload[:timestamp].present?
|
||||
update_call!(call, 'in_progress', started_at: started_at || Time.current)
|
||||
broadcast(call, 'voice_call.outbound_accepted')
|
||||
end
|
||||
end
|
||||
|
||||
def handle_connect(payload)
|
||||
call = Call.whatsapp.find_by(provider_call_id: payload[:id])
|
||||
if call.nil?
|
||||
# Only an `offer` payload is a real inbound caller. An `answer` with no
|
||||
# local row means Meta beat our outbound `Call.create!` (tiny window
|
||||
# between initiate API response and DB insert) — do not mint an inbound
|
||||
# row for it; the next status webhook (or a retry) will find it.
|
||||
return create_inbound_call(payload) if inbound_offer?(payload)
|
||||
|
||||
Rails.logger.warn "[WHATSAPP CALL] Outbound connect for unknown call #{payload[:id]}; skipping"
|
||||
return
|
||||
end
|
||||
|
||||
return accept_outbound_call(call, payload) if call.outgoing?
|
||||
|
||||
Rails.logger.info "[WHATSAPP CALL] Duplicate inbound connect for #{payload[:id]}; ignoring"
|
||||
end
|
||||
|
||||
def inbound_offer?(payload)
|
||||
payload.dig(:session, :sdp_type).to_s.downcase == 'offer'
|
||||
end
|
||||
|
||||
def create_inbound_call(payload)
|
||||
sdp_offer = payload.dig(:session, :sdp)
|
||||
call = Voice::InboundCallBuilder.perform!(
|
||||
inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id],
|
||||
provider: :whatsapp,
|
||||
extra_meta: { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
|
||||
)
|
||||
update_conversation(call)
|
||||
broadcast_incoming(call, sdp_offer)
|
||||
end
|
||||
|
||||
# `connect` is the WebRTC tunnel-ready signal, not the pickup signal. Apply
|
||||
# Meta's SDP answer so the handshake completes during ringing; the call
|
||||
# stays in `ringing` until status=ACCEPTED arrives. Don't gate on
|
||||
# in_progress: an out-of-order ACCEPTED can flip status before connect is
|
||||
# processed, and dropping the SDP answer would leave the browser without
|
||||
# the data it needs to complete the handshake. Use the stored answer as
|
||||
# the idempotency key instead.
|
||||
def accept_outbound_call(call, payload)
|
||||
call.with_lock do
|
||||
next if call.terminal?
|
||||
next if call.meta&.dig('sdp_answer').present?
|
||||
|
||||
# Pin setup:active so browsers don't renegotiate when Meta echoes actpass.
|
||||
sdp_answer = payload.dig(:session, :sdp)&.gsub('a=setup:actpass', 'a=setup:active')
|
||||
call.update!(meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
|
||||
broadcast(call, 'voice_call.outbound_connected', sdp_answer: sdp_answer)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_terminate(payload)
|
||||
call = Call.whatsapp.find_by(provider_call_id: payload[:id])
|
||||
if call.nil?
|
||||
# No row yet means either an out-of-order terminate (rare in practice — Meta
|
||||
# delivery is FIFO) or, more dangerously, an outbound terminate landing in
|
||||
# the window between the controller's Meta API call and Call.create!.
|
||||
# Materialising as inbound here would collide with the unique
|
||||
# (provider, provider_call_id) index. Skip; controller commits seal it.
|
||||
Rails.logger.warn "[WHATSAPP CALL] Terminate for unknown call #{payload[:id]}; skipping"
|
||||
return
|
||||
end
|
||||
|
||||
call.with_lock do
|
||||
# Webhook retries can re-deliver terminate after we've already finalized the
|
||||
# call; don't recompute status or a duration=0 retry can flip a completed
|
||||
# short call back to no_answer.
|
||||
next if call.terminal?
|
||||
|
||||
duration = payload[:duration]&.to_i
|
||||
reason = payload[:terminate_reason].to_s
|
||||
status = derive_terminate_status(call, duration, reason)
|
||||
meta = (call.meta || {}).merge('ended_at' => Time.zone.now.to_i)
|
||||
update_call!(call, status, duration_seconds: duration, end_reason: reason, meta: meta)
|
||||
broadcast(call, 'voice_call.ended', status: call.display_status, duration_seconds: call.duration_seconds)
|
||||
end
|
||||
end
|
||||
|
||||
# Provider-reported failures trump the answered/no_answer heuristic. An
|
||||
# in_progress call that Meta later terminates with a failure reason would
|
||||
# otherwise be recorded as 'completed' purely because it had been accepted.
|
||||
FAILURE_REASONS = %w[failed error rejected busy invalid_offer cancelled].freeze
|
||||
|
||||
def derive_terminate_status(call, duration, reason)
|
||||
return 'failed' if FAILURE_REASONS.any? { |r| reason.include?(r) }
|
||||
|
||||
answered?(call, duration) ? 'completed' : 'no_answer'
|
||||
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?)
|
||||
end
|
||||
|
||||
def update_call!(call, status, **attrs)
|
||||
call.update!(status: status, **attrs)
|
||||
Voice::CallMessageBuilder.new(call).update_status!(status: status, agent: call.accepted_by_agent,
|
||||
duration_seconds: attrs[:duration_seconds])
|
||||
update_conversation(call)
|
||||
end
|
||||
|
||||
def update_conversation(call)
|
||||
call.conversation.update!(
|
||||
additional_attributes: (call.conversation.additional_attributes || {}).merge(
|
||||
'call_status' => call.display_status, 'call_direction' => call.direction_label
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
# Ring the assignee if assigned; otherwise account-wide so any agent can pick up.
|
||||
def broadcast_incoming(call, sdp_offer)
|
||||
contact = call.contact
|
||||
token = call.conversation.assignee&.pubsub_token
|
||||
broadcast(call, 'voice_call.incoming',
|
||||
streams: token ? [token] : account_streams,
|
||||
direction: call.direction_label, inbox_id: call.inbox_id,
|
||||
sdp_offer: sdp_offer, ice_servers: Call.default_ice_servers,
|
||||
caller: { name: contact.name, phone: contact.phone_number, avatar: contact.avatar_url })
|
||||
end
|
||||
|
||||
def broadcast(call, event, streams: account_streams, **extra)
|
||||
payload = { event: event, data: base_payload(call).merge(extra) }
|
||||
streams.each { |s| ActionCable.server.broadcast(s, payload) }
|
||||
end
|
||||
|
||||
def account_streams
|
||||
["account_#{inbox.account_id}"]
|
||||
end
|
||||
|
||||
def base_payload(call)
|
||||
{ account_id: inbox.account_id, id: call.id, call_id: call.provider_call_id,
|
||||
provider: 'whatsapp', conversation_id: call.conversation_id }
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -32,7 +32,6 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
|
||||
call.update!(conference_sid: call.default_conference_sid)
|
||||
|
||||
expect(Voice::InboundCallBuilder).to receive(:perform!).with(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
from_number: from_number,
|
||||
call_sid: call_sid
|
||||
|
||||
+5
-3
@@ -4,8 +4,10 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
||||
subject(:service) { described_class.new(whatsapp_channel: whatsapp_channel) }
|
||||
|
||||
let(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) }
|
||||
let(:calls_url) { 'https://graph.facebook.com/v13.0/123456789/calls' }
|
||||
let(:messages_url) { 'https://graph.facebook.com/v13.0/123456789/messages' }
|
||||
# Call-flow endpoints use the configured WHATSAPP_API_VERSION (fallback v22.0),
|
||||
# not the OSS v13.0 path locked for legacy /messages compatibility.
|
||||
let(:calls_url) { 'https://graph.facebook.com/v22.0/123456789/calls' }
|
||||
let(:messages_url) { 'https://graph.facebook.com/v22.0/123456789/messages' }
|
||||
let(:headers) { { 'Content-Type' => 'application/json' } }
|
||||
|
||||
before { stub_request(:get, /message_templates/) }
|
||||
@@ -40,7 +42,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)
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ RSpec.describe Voice::InboundCallBuilder do
|
||||
|
||||
def perform_builder
|
||||
described_class.perform!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
from_number: from_number,
|
||||
call_sid: call_sid
|
||||
@@ -87,6 +86,45 @@ RSpec.describe Voice::InboundCallBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a ContactInbox already exists for the source_id (different contact)' do
|
||||
let!(:original_contact) { create(:contact, account: account, phone_number: '+15550009999') }
|
||||
let!(:original_contact_inbox) do
|
||||
create(:contact_inbox, contact: original_contact, inbox: inbox, source_id: from_number)
|
||||
end
|
||||
|
||||
it 'reuses the existing ContactInbox instead of raising RecordNotUnique' do
|
||||
call = perform_builder
|
||||
|
||||
expect(call.contact).to eq(original_contact)
|
||||
expect(call.conversation.contact_inbox).to eq(original_contact_inbox)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the WhatsApp wa_id needs Brazil normalization to match an existing ContactInbox' do
|
||||
let(:whatsapp_channel) do
|
||||
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
|
||||
provider_config: { 'phone_number_id' => '123', 'calling_enabled' => true },
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let(:whatsapp_inbox) { whatsapp_channel.inbox }
|
||||
let!(:stored_contact) { create(:contact, account: account, phone_number: '+5541988887777') }
|
||||
let!(:stored_contact_inbox) do
|
||||
create(:contact_inbox, contact: stored_contact, inbox: whatsapp_inbox, source_id: '5541988887777')
|
||||
end
|
||||
|
||||
it 'reuses the contact via normalized wa_id rather than forking a new ContactInbox' do
|
||||
call = described_class.perform!(
|
||||
inbox: whatsapp_inbox,
|
||||
from_number: '+554188887777',
|
||||
call_sid: 'wacall_br_1',
|
||||
provider: :whatsapp
|
||||
)
|
||||
|
||||
expect(call.contact).to eq(stored_contact)
|
||||
expect(call.conversation.contact_inbox).to eq(stored_contact_inbox)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the inbox has lock_to_single_conversation enabled' do
|
||||
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
|
||||
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::CallPermissionReplyService do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) do
|
||||
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:contact) { create(:contact, account: account, phone_number: '+15550001111') }
|
||||
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '15550001111') }
|
||||
let(:request_wamid) { 'wamid.permission_request_abc' }
|
||||
let!(:conversation) do
|
||||
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :open,
|
||||
additional_attributes: {
|
||||
'call_permission_requested_at' => Time.current.iso8601,
|
||||
'call_permission_request_message_id' => request_wamid
|
||||
})
|
||||
end
|
||||
|
||||
before do
|
||||
account.enable_features!('channel_voice')
|
||||
channel.provider_config = channel.provider_config.merge('source' => 'embedded_signup', 'calling_enabled' => true)
|
||||
channel.save!
|
||||
end
|
||||
|
||||
def reply_params(response:, context_id: request_wamid)
|
||||
interactive = { type: 'call_permission_reply',
|
||||
call_permission_reply: { response: response, is_permanent: false } }
|
||||
message = { from: '15550001111', type: 'interactive', interactive: interactive }
|
||||
message[:context] = { id: context_id } if context_id
|
||||
{ entry: [{ changes: [{ value: { messages: [message] } }] }] }
|
||||
end
|
||||
|
||||
it 'clears both permission flags and broadcasts voice_call.permission_granted on accept' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
described_class.new(inbox: inbox, params: reply_params(response: 'accept')).perform
|
||||
|
||||
attrs = conversation.reload.additional_attributes
|
||||
expect(attrs).not_to include('call_permission_requested_at')
|
||||
expect(attrs).not_to include('call_permission_request_message_id')
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}",
|
||||
hash_including(event: 'voice_call.permission_granted',
|
||||
data: hash_including(conversation_id: conversation.id))
|
||||
)
|
||||
end
|
||||
|
||||
it 'is a no-op when the contact rejected the request' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
described_class.new(inbox: inbox, params: reply_params(response: 'reject')).perform
|
||||
|
||||
expect(conversation.reload.additional_attributes).to include('call_permission_requested_at')
|
||||
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||
end
|
||||
|
||||
it 'is a no-op when calling is disabled on the channel' do
|
||||
channel.provider_config = channel.provider_config.merge('calling_enabled' => false)
|
||||
channel.save!
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
described_class.new(inbox: inbox, params: reply_params(response: 'accept')).perform
|
||||
|
||||
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||
end
|
||||
|
||||
it 'matches the originating conversation by context.id when the contact has multiple pending requests' do
|
||||
other_request_wamid = 'wamid.permission_request_xyz'
|
||||
other_open = create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox,
|
||||
status: :open,
|
||||
additional_attributes: {
|
||||
'call_permission_requested_at' => Time.current.iso8601,
|
||||
'call_permission_request_message_id' => other_request_wamid
|
||||
})
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
described_class.new(inbox: inbox, params: reply_params(response: 'accept', context_id: other_request_wamid)).perform
|
||||
|
||||
# The reply pointed at other_open's request — it should be the cleared one, not `conversation`
|
||||
expect(other_open.reload.additional_attributes).not_to include('call_permission_request_message_id')
|
||||
expect(conversation.reload.additional_attributes).to include('call_permission_request_message_id')
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}",
|
||||
hash_including(data: hash_including(conversation_id: other_open.id))
|
||||
)
|
||||
end
|
||||
|
||||
it 'is a no-op when the reply has no context.id' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
described_class.new(inbox: inbox, params: reply_params(response: 'accept', context_id: nil)).perform
|
||||
|
||||
expect(conversation.reload.additional_attributes).to include('call_permission_request_message_id')
|
||||
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,242 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::IncomingCallService do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) do
|
||||
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:from_number) { '15550001111' }
|
||||
let(:provider_call_id) { 'wacid_abc' }
|
||||
|
||||
before do
|
||||
account.enable_features!('channel_voice')
|
||||
channel.provider_config = channel.provider_config.merge('source' => 'embedded_signup', 'calling_enabled' => true)
|
||||
channel.save!
|
||||
end
|
||||
|
||||
def call_payload(event:, **extra)
|
||||
{ calls: [{ id: provider_call_id, from: from_number, event: event, **extra }] }
|
||||
end
|
||||
|
||||
context 'when calling is disabled on the channel' do
|
||||
it 'is a no-op' do
|
||||
channel.provider_config = channel.provider_config.merge('calling_enabled' => false)
|
||||
channel.save!
|
||||
|
||||
expect { described_class.new(inbox: inbox, params: call_payload(event: 'connect')).perform }
|
||||
.not_to change(Call, :count)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'inbound connect' do
|
||||
let(:sdp_offer) { "v=0\r\n...sdp..." }
|
||||
|
||||
it 'creates the Call + Conversation + voice_call message and broadcasts voice_call.incoming' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
params = call_payload(event: 'connect', session: { sdp: sdp_offer, sdp_type: 'offer' })
|
||||
expect { described_class.new(inbox: inbox, params: params).perform }
|
||||
.to change(Call, :count).by(1).and change(Conversation, :count).by(1)
|
||||
|
||||
call = Call.last
|
||||
expect(call).to have_attributes(provider: 'whatsapp', direction: 'incoming', status: 'ringing',
|
||||
provider_call_id: provider_call_id)
|
||||
expect(call.meta['sdp_offer']).to eq(sdp_offer)
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}",
|
||||
hash_including(event: 'voice_call.incoming', data: hash_including(sdp_offer: sdp_offer))
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'outbound connect (existing call)' do
|
||||
let!(:call) do
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||
provider: :whatsapp, direction: :outgoing, status: 'ringing', provider_call_id: provider_call_id)
|
||||
end
|
||||
|
||||
it 'stores the SDP answer and broadcasts voice_call.outbound_connected without flipping to in_progress' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
sdp_answer = "v=0\r\na=setup:actpass\r\n"
|
||||
|
||||
params = call_payload(event: 'connect', session: { sdp: sdp_answer, sdp_type: 'answer' })
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
# connect only completes the SDP handshake; pickup is reported separately as status=ACCEPTED.
|
||||
expect(call.reload).to have_attributes(status: 'ringing', started_at: nil)
|
||||
expect(call.meta['sdp_answer']).to include('a=setup:active')
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}",
|
||||
hash_including(event: 'voice_call.outbound_connected')
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'terminate' do
|
||||
let!(:call) do
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||
provider: :whatsapp, direction: :incoming, status: 'in_progress', provider_call_id: provider_call_id)
|
||||
end
|
||||
|
||||
it 'marks the call completed when the call had been answered' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
params = call_payload(event: 'terminate', duration: 42, terminate_reason: 'completed_normally')
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(call.reload).to have_attributes(status: 'completed', duration_seconds: 42, end_reason: 'completed_normally')
|
||||
expect(call.ended_at).to be_present
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}",
|
||||
hash_including(event: 'voice_call.ended', data: hash_including(status: 'completed'))
|
||||
)
|
||||
end
|
||||
|
||||
it 'marks unanswered ringing calls as no_answer' do
|
||||
call.update!(status: 'ringing')
|
||||
params = call_payload(event: 'terminate', duration: 0, terminate_reason: 'no_answer')
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
expect(call.reload.status).to eq('no_answer')
|
||||
end
|
||||
|
||||
it 'records the call as failed when Meta reports a failure reason for an in_progress call' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
params = call_payload(event: 'terminate', duration: 12, terminate_reason: 'failed')
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(call.reload).to have_attributes(status: 'failed', duration_seconds: 12, end_reason: 'failed')
|
||||
end
|
||||
|
||||
it 'is a no-op when the call is already terminal so retries cannot flip a completed call to no_answer' do
|
||||
call.update!(status: 'completed', duration_seconds: 5, direction: :outgoing, accepted_by_agent: nil)
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
params = call_payload(event: 'terminate', duration: 0, terminate_reason: 'completed_normally')
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(call.reload).to have_attributes(status: 'completed', duration_seconds: 5)
|
||||
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'terminate with no local row yet' do
|
||||
it 'logs and skips instead of materialising an inbound missed-call row' do
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
params = call_payload(event: 'terminate', duration: 0, terminate_reason: 'no_answer')
|
||||
|
||||
expect { described_class.new(inbox: inbox, params: params).perform }
|
||||
.not_to change(Call, :count)
|
||||
expect(Rails.logger).to have_received(:warn).with(/Terminate for unknown call/)
|
||||
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'outbound connect with no local row yet' do
|
||||
it 'does not mint an inbound call when sdp_type is answer' do
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
params = call_payload(event: 'connect', session: { sdp: 'sdp_answer', sdp_type: 'answer' })
|
||||
|
||||
expect { described_class.new(inbox: inbox, params: params).perform }
|
||||
.not_to change(Call, :count)
|
||||
expect(Rails.logger).to have_received(:warn).with(/Outbound connect for unknown call/)
|
||||
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'duplicate inbound connect' do
|
||||
let!(:call) do
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||
provider: :whatsapp, direction: :incoming, status: 'ringing', provider_call_id: provider_call_id)
|
||||
end
|
||||
|
||||
it 'logs and ignores rather than treating it as outbound' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
params = call_payload(event: 'connect', session: { sdp: 'sdp_x', sdp_type: 'offer' })
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(call.reload).to have_attributes(status: 'ringing')
|
||||
expect(Rails.logger).to have_received(:info).with(/Duplicate inbound connect/)
|
||||
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'connect arriving after terminal status' do
|
||||
let!(:call) do
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||
provider: :whatsapp, direction: :outgoing, status: 'completed', provider_call_id: provider_call_id)
|
||||
end
|
||||
|
||||
it 'does not reopen a completed outbound call' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
params = call_payload(event: 'connect', session: { sdp: 'late_sdp', sdp_type: 'answer' })
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(call.reload.status).to eq('completed')
|
||||
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'unanswered outbound call terminate' do
|
||||
let!(:agent) { create(:user, account: account) }
|
||||
let!(:call) do
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||
provider: :whatsapp, direction: :outgoing, status: 'ringing',
|
||||
accepted_by_agent: agent, provider_call_id: provider_call_id)
|
||||
end
|
||||
|
||||
it 'marks the call as no_answer even though accepted_by_agent_id is set' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
params = call_payload(event: 'terminate', duration: 0, terminate_reason: 'no_answer')
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(call.reload.status).to eq('no_answer')
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}",
|
||||
hash_including(event: 'voice_call.ended', data: hash_including(status: 'no-answer'))
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'unknown event' do
|
||||
it 'logs a warning and does not raise' do
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
params = call_payload(event: 'mystery')
|
||||
|
||||
expect { described_class.new(inbox: inbox, params: params).perform }.not_to raise_error
|
||||
expect(Rails.logger).to have_received(:warn).with(/Unknown call event: mystery/)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'multiple calls in one webhook payload' do
|
||||
it 'processes every call in the array' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
params = {
|
||||
calls: [
|
||||
{ id: 'wacid_a', from: from_number, event: 'connect', session: { sdp: 'sdp_a', sdp_type: 'offer' } },
|
||||
{ id: 'wacid_b', from: '15550002222', event: 'connect', session: { sdp: 'sdp_b', sdp_type: 'offer' } }
|
||||
]
|
||||
}
|
||||
|
||||
expect { described_class.new(inbox: inbox, params: params).perform }.to change(Call, :count).by(2)
|
||||
expect(Call.where(provider_call_id: %w[wacid_a wacid_b]).pluck(:provider_call_id)).to contain_exactly('wacid_a', 'wacid_b')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -41,7 +41,9 @@ RSpec.describe Telegram::SendAttachmentsService do
|
||||
end
|
||||
|
||||
context 'when this is business chat' do
|
||||
before { allow(channel).to receive(:business_connection_id).and_return('eooW3KF5WB5HxTD7T826') }
|
||||
before do
|
||||
message.conversation.update!(additional_attributes: { 'business_connection_id' => 'eooW3KF5WB5HxTD7T826' })
|
||||
end
|
||||
|
||||
it 'sends all types of attachments in seperate groups and returns the last successful message ID from the batch' do
|
||||
attach_files(message)
|
||||
|
||||
@@ -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