diff --git a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb index 96c98db7a..bf1fa9ca9 100644 --- a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb @@ -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. diff --git a/enterprise/app/services/whatsapp/call_service.rb b/enterprise/app/services/whatsapp/call_service.rb index f734f332e..e7e1d9b7c 100644 --- a/enterprise/app/services/whatsapp/call_service.rb +++ b/enterprise/app/services/whatsapp/call_service.rb @@ -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) diff --git a/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb index 4408e15f3..31aac16fe 100644 --- a/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb @@ -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 diff --git a/spec/enterprise/services/whatsapp/call_service_spec.rb b/spec/enterprise/services/whatsapp/call_service_spec.rb index 76752789e..eb2fdbee8 100644 --- a/spec/enterprise/services/whatsapp/call_service_spec.rb +++ b/spec/enterprise/services/whatsapp/call_service_spec.rb @@ -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