fix(voice): lock call state transitions, branch terminate on connection state, atomic recording upload, rescue CallFailed in initiate
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user