fix(voice): bail on Meta failures and restrict initiate to WhatsApp inboxes

This commit is contained in:
Tanmay Deep Sharma
2026-04-30 17:26:01 +07:00
parent 41e3ea0d87
commit a7fdc45977
4 changed files with 40 additions and 8 deletions
@@ -14,10 +14,14 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
def reject
@call = Whatsapp::CallService.new(call: @call, agent: Current.user).reject
rescue Voice::CallErrors::CallFailed => e
render_could_not_create_error(e.message)
end
def terminate
@call = Whatsapp::CallService.new(call: @call, agent: Current.user).terminate
rescue Voice::CallErrors::CallFailed => e
render_could_not_create_error(e.message)
end
# Browser-supplied recording captured via MediaRecorder. Idempotent: the
@@ -62,9 +66,11 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
authorize @conversation, :show?
end
# WhatsApp Cloud Calling specifically — Twilio voice channels also expose
# voice_enabled? but use a different (Voice::OutboundCallBuilder) initiation path.
def calling_enabled?(conversation)
channel = conversation.inbox.channel
channel.respond_to?(:voice_enabled?) && channel.voice_enabled?
channel.is_a?(Channel::Whatsapp) && channel.voice_enabled?
end
# Browser-built SDP offer is forwarded to Meta; the connect webhook later delivers Meta's answer.
@@ -15,7 +15,7 @@ class Whatsapp::CallService
call.with_lock do
next if call.terminal? || call.in_progress?
invoke_provider(:reject_call)
invoke_provider!(:reject_call)
finalize_call('failed')
end
call
@@ -25,9 +25,8 @@ class Whatsapp::CallService
call.with_lock do
next if call.terminal?
invoke_provider(:terminate_call)
# An agent who hangs up before the contact picks up should mark the call no_answer,
# mirroring the webhook terminate path in Whatsapp::IncomingCallService.
invoke_provider!(:terminate_call)
# Agent hangs up before contact picks up → no_answer; mirrors the webhook terminate path.
finalize_call(call.in_progress? ? 'completed' : 'no_answer')
end
call
@@ -56,11 +55,17 @@ class Whatsapp::CallService
call.conversation.update!(assignee: agent) if call.conversation.assignee_id.blank?
end
def invoke_provider(method)
# 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)
Rails.logger.error "[WHATSAPP CALL] #{method} returned false for #{call.provider_call_id}" unless success
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.message}"
Rails.logger.error "[WHATSAPP CALL] #{method} failed: #{e.class} #{e.message}"
raise Voice::CallErrors::CallFailed, "Meta #{method} failed"
end
def finalize_call(status)
@@ -136,6 +136,19 @@ RSpec.describe 'WhatsApp Calls API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Meta error')
end
it 'returns 422 when the conversation belongs to a non-WhatsApp inbox' do
twilio_channel = create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239998')
create(:inbox_member, user: agent, inbox: twilio_channel.inbox)
twilio_conversation = create(:conversation, account: account, inbox: twilio_channel.inbox)
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
params: { conversation_id: twilio_conversation.display_id, sdp_offer: 'sdp_offer' },
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(I18n.t('errors.whatsapp.calls.not_enabled'))
end
end
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/upload_recording' do
@@ -83,6 +83,14 @@ describe Whatsapp::CallService do
expect(provider_service).not_to have_received(:reject_call)
end
it 'raises CallFailed and leaves the call ringing when Meta rejects the request' do
allow(provider_service).to receive(:reject_call).and_return(false)
expect { described_class.new(call: call, agent: agent).reject }
.to raise_error(Voice::CallErrors::CallFailed)
expect(call.reload.status).to eq('ringing')
end
end
describe '#terminate' do