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