fix(voice): use status=ACCEPTED as the real pickup signal for outbound calls

Meta delivers two payload shapes under field=calls: value.calls[] (event:
connect/terminate) and value.statuses[] (status: RINGING/ACCEPTED). The
dispatcher only forwarded the calls[] array, so status webhooks were
silently dropped.

Empirically `connect` for BUSINESS_INITIATED outbound fires when the
WebRTC tunnel is up, not on pickup — sometimes ~20s before the contact
actually answers. The terminate webhook's start_time aligns with the
ACCEPTED status timestamp, confirming ACCEPTED is the true pickup.

- whatsapp_events_job: route value.statuses[] (type=call) to
  IncomingCallService under the same per-call_id mutex.
- incoming_call_service: handle_status routes ACCEPTED to
  mark_outbound_accepted (flip in_progress, set started_at, broadcast
  voice_call.outbound_accepted). Strip the in_progress flip from the
  connect handler — connect now only stores the SDP answer + broadcasts
  voice_call.outbound_connected so the browser can complete the DTLS
  handshake during ringing.
This commit is contained in:
Tanmay Deep Sharma
2026-05-05 14:13:26 +07:00
parent 66d9ddb9c4
commit 5086edefb3
2 changed files with 55 additions and 9 deletions
@@ -23,16 +23,34 @@ module Enterprise::Webhooks::WhatsappEventsJob
params.dig(:entry, 0, :changes, 0, :value, :messages, 0, :interactive, :type) == 'call_permission_reply'
end
# Per-call_id mutex so connect/terminate for the same call serialize across batches.
# 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)
calls = params.dig(:entry, 0, :changes, 0, :value, :calls) || []
calls.each do |call_payload|
lock_key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX,
inbox_id: channel.inbox.id, sender_id: "call:#{call_payload[:id]}")
with_lock(lock_key, 30.seconds) do
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)
@@ -5,6 +5,7 @@ class Whatsapp::IncomingCallService
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
@@ -17,6 +18,32 @@ class Whatsapp::IncomingCallService
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
def mark_outbound_accepted(call, payload)
return unless call.outgoing?
return 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
def handle_connect(payload)
call = Call.whatsapp.find_by(provider_call_id: payload[:id])
return create_inbound_call(payload) if call.nil?
@@ -38,14 +65,15 @@ class Whatsapp::IncomingCallService
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.
def accept_outbound_call(call, payload)
return if call.in_progress? || call.terminal?
# 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')
update_call!(call, 'in_progress',
started_at: Time.current,
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
call.update!(meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
broadcast(call, 'voice_call.outbound_connected', sdp_answer: sdp_answer)
end