diff --git a/config/locales/en.yml b/config/locales/en.yml index c225392a4..c8876754a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -114,6 +114,12 @@ en: reauthorization: generic: 'Failed to reauthorize WhatsApp. Please try again.' not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.' + calls: + not_enabled: 'Calling is not enabled for this inbox' + no_recording: 'No recording file provided' + no_message: 'Call has no associated message' + sdp_offer_required: 'sdp_offer is required' + permission_request_failed: 'Failed to send call permission request' inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. 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 801e2d392..7ab777be5 100644 --- a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb @@ -20,29 +20,34 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro @call = Whatsapp::CallService.new(call: @call, agent: Current.user).terminate end - # Browser-supplied recording captured via MediaRecorder during the call. - # Idempotent: subsequent uploads no-op once the first audio attachment exists. + # Browser-supplied recording captured via MediaRecorder. Idempotent: the + # check-and-create runs under the message row lock so the hangup-vs-pagehide + # race can't double-attach. def upload_recording - return render_could_not_create_error('No recording file provided') if params[:recording].blank? - return render_could_not_create_error('Call has no associated message') if @call.message.blank? + return render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_recording')) if params[:recording].blank? + return render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_message')) if @call.message.blank? - @upload_status = if @call.message.attachments.exists?(file_type: :audio) - 'already_uploaded' - else - @call.message.attachments.create!(account_id: @call.account_id, file_type: :audio, file: params[:recording]) - 'uploaded' - end + @call.message.with_lock do + @upload_status = if @call.message.attachments.exists?(file_type: :audio) + 'already_uploaded' + else + @call.message.attachments.create!(account_id: @call.account_id, file_type: :audio, file: params[:recording]) + 'uploaded' + end + end end def initiate - return render_could_not_create_error('Calling is not enabled for this inbox') unless calling_enabled?(@conversation) - return render_could_not_create_error('sdp_offer is required') if params[:sdp_offer].blank? + return render_could_not_create_error(I18n.t('errors.whatsapp.calls.not_enabled')) unless calling_enabled?(@conversation) + return render_could_not_create_error(I18n.t('errors.whatsapp.calls.sdp_offer_required')) if params[:sdp_offer].blank? @call = create_outbound_call(@conversation, params[:sdp_offer]) @message = Voice::CallMessageBuilder.new(@call).perform! @call.update!(message_id: @message.id) rescue Voice::CallErrors::NoCallPermission handle_no_call_permission(@conversation) + rescue Voice::CallErrors::CallFailed => e + render_could_not_create_error(e.message) end private @@ -87,7 +92,7 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro contact_phone = conversation.contact.phone_number.delete('+') sent = conversation.inbox.channel.provider_service.send_call_permission_request(contact_phone) - return render_could_not_create_error('Failed to send call permission request') unless sent + return render_could_not_create_error(I18n.t('errors.whatsapp.calls.permission_request_failed')) unless sent attrs = (conversation.additional_attributes || {}).merge('call_permission_requested_at' => Time.current.iso8601) conversation.update!(additional_attributes: attrs) diff --git a/enterprise/app/services/whatsapp/call_service.rb b/enterprise/app/services/whatsapp/call_service.rb index 5802b379e..f734f332e 100644 --- a/enterprise/app/services/whatsapp/call_service.rb +++ b/enterprise/app/services/whatsapp/call_service.rb @@ -12,19 +12,24 @@ class Whatsapp::CallService end def reject - call.reload - return call if call.terminal? || call.in_progress? + call.with_lock do + next if call.terminal? || call.in_progress? - invoke_provider(:reject_call) - finalize_call('failed') + invoke_provider(:reject_call) + finalize_call('failed') + end call end def terminate - return call if call.terminal? + call.with_lock do + next if call.terminal? - invoke_provider(:terminate_call) - finalize_call('completed') + 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. + finalize_call(call.in_progress? ? 'completed' : 'no_answer') + end call end 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 857c678bf..40ce454dc 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 @@ -123,6 +123,17 @@ RSpec.describe 'WhatsApp Calls API', type: :request do expect(response).to have_http_status(:unprocessable_entity) end + + it 'returns 422 when Meta raises CallFailed for non-permission errors' do + allow(provider_service).to receive(:initiate_call).and_raise(Voice::CallErrors::CallFailed, 'Meta error') + + post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate", + params: { conversation_id: initiate_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('Meta error') + 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 615f2cf03..76752789e 100644 --- a/spec/enterprise/services/whatsapp/call_service_spec.rb +++ b/spec/enterprise/services/whatsapp/call_service_spec.rb @@ -88,7 +88,7 @@ describe Whatsapp::CallService do describe '#terminate' do before { allow(provider_service).to receive(:terminate_call).and_return(true) } - it 'tells Meta to terminate and finalizes the call as completed' do + it 'finalizes an in-progress call as completed' do call.update!(status: 'in_progress') described_class.new(call: call, agent: agent).terminate @@ -100,5 +100,11 @@ describe Whatsapp::CallService do "account_#{account.id}", hash_including(event: 'voice_call.ended') ) end + + it 'finalizes a still-ringing call as no_answer when the agent hangs up before the contact picks up' do + described_class.new(call: call, agent: agent).terminate + + expect(call.reload.status).to eq('no_answer') + end end end