fix(voice): don't mint inbound calls for outbound connect webhooks

If Meta delivered a connect for an outbound call before the controller
committed the Call row (race between Meta's API response and our INSERT),
the handler treated the unknown call_id as inbound and either tripped the
unique (provider, provider_call_id) index or broadcast voice_call.incoming
for an outbound. Use session.sdp_type to gate inbound creation on 'offer'
only; 'answer' with no row is logged and skipped — the next status webhook
finds the now-committed row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tanmay Deep Sharma
2026-05-07 15:39:29 +07:00
co-authored by Claude Opus 4.7
parent 74f9cd764b
commit 29fd83c1ea
2 changed files with 30 additions and 1 deletions
@@ -19,12 +19,26 @@ class Whatsapp::IncomingCallService
def handle_connect(payload)
call = Call.whatsapp.find_by(provider_call_id: payload[:id])
return create_inbound_call(payload) if call.nil?
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!(
@@ -114,6 +114,21 @@ describe Whatsapp::IncomingCallService do
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(inbox.channel).to receive(:voice_enabled?).and_return(true)
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)