fix(voice): lock call state transitions, branch terminate on connection state, atomic recording upload, rescue CallFailed in initiate

This commit is contained in:
Tanmay Deep Sharma
2026-04-30 16:54:47 +07:00
parent c37d1b1d6b
commit 7c87d3d150
5 changed files with 54 additions and 21 deletions
+6
View File
@@ -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.
@@ -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)
@@ -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
@@ -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
@@ -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