feat(voice): add WhatsApp Cloud Calling agent service, controller and routes

This commit is contained in:
Tanmay Deep Sharma
2026-04-30 16:24:39 +07:00
parent f32a7b3f77
commit 83cd568ca2
6 changed files with 512 additions and 0 deletions
+15
View File
@@ -215,6 +215,21 @@ Rails.application.routes.draw do
end
end
resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
if ChatwootApp.enterprise?
resources :whatsapp_calls, only: [:show] do
member do
post :accept
post :reject
post :terminate
post :upload_recording
end
collection do
post :initiate
end
end
end
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
@@ -0,0 +1,136 @@
class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseController
before_action :set_call, only: %i[show accept reject terminate upload_recording]
def show
render json: call_payload(@call)
end
def accept
call = Whatsapp::CallService.new(call: @call, agent: current_user, sdp_answer: params[:sdp_answer]).accept
render json: call_payload(call)
rescue Voice::CallErrors::NotRinging, Voice::CallErrors::AlreadyAccepted, Voice::CallErrors::CallFailed => e
render json: { error: e.message }, status: :unprocessable_entity
rescue StandardError => e
log_and_render_failure('accept', e)
end
def reject
call = Whatsapp::CallService.new(call: @call, agent: current_user).reject
render json: { id: call.id, status: call.display_status }
rescue StandardError => e
log_and_render_failure('reject', e)
end
def terminate
call = Whatsapp::CallService.new(call: @call, agent: current_user).terminate
render json: { id: call.id, status: call.display_status }
rescue StandardError => e
log_and_render_failure('terminate', e)
end
# Browser-supplied recording captured via MediaRecorder during the call.
# Idempotent: subsequent uploads no-op once the first audio attachment exists,
# so the hangup-vs-pagehide race can't double-attach.
def upload_recording
return render json: { error: 'No recording file provided' }, status: :unprocessable_entity if params[:recording].blank?
return render json: { id: @call.id, status: 'no_message' }, status: :unprocessable_entity if @call.message.blank?
return render json: { id: @call.id, status: 'already_uploaded' } if @call.message.attachments.exists?(file_type: :audio)
@call.message.attachments.create!(account_id: @call.account_id, file_type: :audio, file: params[:recording])
render json: { id: @call.id, status: 'uploaded' }
rescue StandardError => e
log_and_render_failure('upload_recording', e)
end
def initiate
conversation = current_account.conversations.find_by!(display_id: params[:conversation_id])
authorize conversation, :show?
initiate_outbound_call(conversation)
rescue Voice::CallErrors::NoCallPermission
handle_no_call_permission(conversation)
rescue ActiveRecord::RecordNotFound
render json: { error: 'Conversation not found' }, status: :not_found
rescue StandardError => e
Rails.logger.error "[WHATSAPP CALL] initiate failed: #{e.class} #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def initiate_outbound_call(conversation)
return render json: { error: 'Calling is not enabled for this inbox' }, status: :unprocessable_entity unless calling_enabled?(conversation)
return render json: { error: 'sdp_offer is required' }, status: :unprocessable_entity 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)
render json: { status: 'calling', call_id: call.provider_call_id, id: call.id, message_id: message.id, provider: 'whatsapp' }
end
def calling_enabled?(conversation)
channel = conversation.inbox.channel
channel.respond_to?(:voice_enabled?) && channel.voice_enabled?
end
# Browser → Rails → Meta. The browser already opened its mic, built an
# RTCPeerConnection, generated the offer, and waited for ICE gathering;
# we just hand that offer to Meta. On pickup the connect webhook delivers
# Meta's SDP answer (handled in Whatsapp::IncomingCallService).
def create_outbound_call(conversation, sdp_offer)
contact_phone = conversation.contact&.phone_number
raise ArgumentError, 'Contact phone number not available' if contact_phone.blank?
result = conversation.inbox.channel.provider_service.initiate_call(contact_phone.delete('+'), sdp_offer)
provider_call_id = result.dig('calls', 0, 'id') || result['call_id']
current_account.calls.create!(
provider: :whatsapp, inbox: conversation.inbox, conversation: conversation, contact: conversation.contact,
provider_call_id: provider_call_id, direction: :outgoing, status: 'ringing',
accepted_by_agent_id: current_user.id,
meta: { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
)
end
# Meta error 138006 means the contact hasn't granted call permission yet —
# send the interactive permission request, throttle by 5 minutes, and
# surface a permission_pending/requested state to the FE.
def handle_no_call_permission(conversation)
last_requested = conversation.additional_attributes&.dig('call_permission_requested_at')
return render json: { status: 'permission_pending' } if last_requested.present? && Time.zone.parse(last_requested) > 5.minutes.ago
contact_phone = conversation.contact.phone_number.delete('+')
result = conversation.inbox.channel.provider_service.send_call_permission_request(contact_phone)
return render json: { error: 'Failed to send call permission request' }, status: :unprocessable_entity unless result
attrs = (conversation.additional_attributes || {}).merge('call_permission_requested_at' => Time.current.iso8601)
conversation.update!(additional_attributes: attrs)
render json: { status: 'permission_requested' }
end
def call_payload(call)
contact = call.conversation&.contact
{
id: call.id, call_id: call.provider_call_id, provider: call.provider,
status: call.display_status, direction: call.direction_label,
conversation_id: call.conversation_id, inbox_id: call.inbox_id, message_id: call.message_id,
accepted_by_agent_id: call.accepted_by_agent_id,
elapsed_seconds: call.started_at ? (Time.current - call.started_at).to_i : 0,
sdp_offer: call.meta&.dig('sdp_offer'),
ice_servers: call.meta&.dig('ice_servers') || Call.default_ice_servers,
caller: contact ? { name: contact.name, phone: contact.phone_number, avatar: contact.avatar_url } : {}
}
end
def set_call
@call = current_account.calls.whatsapp.find(params[:id])
authorize @call.conversation, :show?
rescue ActiveRecord::RecordNotFound
render json: { error: 'Call not found' }, status: :not_found
end
def log_and_render_failure(action, error)
Rails.logger.error "[WHATSAPP CALL] #{action} failed: #{error.class} #{error.message}"
render json: { error: "Failed to #{action} call" }, status: :internal_server_error
end
end
@@ -13,6 +13,12 @@ module Enterprise::Conversation
super + %w[sla_policy_id]
end
# Surface call lifecycle changes to the FE: writes to additional_attributes
# call_status/call_direction should rebroadcast conversation_updated.
def allowed_keys?
super || call_attributes_changed?
end
def with_captain_activity_context(reason:, reason_type:)
previous_reason = captain_activity_reason
previous_reason_type = captain_activity_reason_type
@@ -30,4 +36,10 @@ module Enterprise::Conversation
def dispatch_captain_inference_event(event_name)
dispatcher_dispatch(event_name)
end
def call_attributes_changed?
return false if previous_changes['additional_attributes'].blank?
previous_changes['additional_attributes'][1].keys.intersect?(%w[call_status call_direction])
end
end
@@ -0,0 +1,87 @@
class Whatsapp::CallService
pattr_initialize [:call!, :agent!, :sdp_answer]
def accept
raise Voice::CallErrors::CallFailed, 'sdp_answer is required' if sdp_answer.blank?
call.with_lock { transition_to_in_progress! }
update_message_status('in_progress')
update_conversation_call_status(call.display_status)
broadcast(:accepted, accepted_by_agent_id: agent.id)
call
end
def reject
call.reload
return call if call.terminal? || call.in_progress?
invoke_provider(:reject_call)
finalize_call('failed')
call
end
def terminate
return call if call.terminal?
invoke_provider(:terminate_call)
finalize_call('completed')
call
end
private
def transition_to_in_progress!
raise Voice::CallErrors::NotRinging, 'Call is not in ringing state' unless call.ringing?
raise Voice::CallErrors::AlreadyAccepted, 'Call already accepted by another agent' if call.in_progress?
forward_answer_to_meta!
call.update!(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: Time.current,
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
claim_conversation_for_agent
end
def forward_answer_to_meta!
svc = call.inbox.channel.provider_service
raise Voice::CallErrors::CallFailed, 'Meta pre_accept failed' unless svc.pre_accept_call(call.provider_call_id, sdp_answer)
raise Voice::CallErrors::CallFailed, 'Meta accept failed' unless svc.accept_call(call.provider_call_id, sdp_answer)
end
# Take ownership of the conversation if no one holds it; leave assignee alone otherwise (transfer via UI).
def claim_conversation_for_agent
call.conversation.update!(assignee: agent) if call.conversation.assignee_id.blank?
end
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
rescue StandardError => e
Rails.logger.error "[WHATSAPP CALL] #{method} failed: #{e.message}"
end
def finalize_call(status)
meta = (call.meta || {}).merge('ended_at' => Time.zone.now.to_i)
call.update!(status: status, meta: meta)
update_message_status(status)
update_conversation_call_status(call.display_status)
broadcast(:ended, status: call.display_status)
end
def update_message_status(status)
Voice::CallMessageBuilder.new(call).update_status!(status: status, agent: agent)
end
def update_conversation_call_status(status)
call.conversation.update!(
additional_attributes: (call.conversation.additional_attributes || {}).merge('call_status' => status)
)
end
def broadcast(event, **extra)
payload = {
event: "voice_call.#{event}",
data: { id: call.id, call_id: call.provider_call_id, provider: call.provider,
conversation_id: call.conversation_id, account_id: call.account_id }.merge(extra)
}
ActionCable.server.broadcast("account_#{call.account_id}", payload)
end
end
@@ -0,0 +1,158 @@
require 'rails_helper'
RSpec.describe 'WhatsApp Calls API', type: :request do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:channel) do
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
validate_provider_config: false, sync_templates: false)
end
let(:inbox) { channel.inbox }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:call) do
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
provider: :whatsapp, direction: :incoming, status: 'ringing', provider_call_id: 'wacid_abc')
end
let(:provider_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
before do
channel.provider_config = channel.provider_config.merge('calling_enabled' => true)
channel.save!
create(:inbox_member, user: agent, inbox: inbox)
allow_any_instance_of(Channel::Whatsapp).to receive(:provider_service).and_return(provider_service) # rubocop:disable RSpec/AnyInstance
end
describe 'GET /api/v1/accounts/:account_id/whatsapp_calls/:id' do
it 'returns the call payload' do
get "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}", headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
body = response.parsed_body
expect(body['id']).to eq(call.id)
expect(body['call_id']).to eq('wacid_abc')
expect(body['provider']).to eq('whatsapp')
end
it 'returns 401 when unauthenticated' do
get "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}"
expect(response).to have_http_status(:unauthorized)
end
end
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/accept' do
it 'forwards SDP and returns the updated call payload' do
allow(provider_service).to receive(:pre_accept_call).and_return(true)
allow(provider_service).to receive(:accept_call).and_return(true)
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/accept",
params: { sdp_answer: 'sdp_answer' }, headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(call.reload.status).to eq('in_progress')
end
it 'returns 422 when sdp_answer is missing' do
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/accept",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
end
end
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/reject' do
it 'rejects the call via Meta and returns its new status' do
allow(provider_service).to receive(:reject_call).and_return(true)
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/reject",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(call.reload.status).to eq('failed')
end
end
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/terminate' do
it 'terminates the call via Meta and returns its new status' do
call.update!(status: 'in_progress')
allow(provider_service).to receive(:terminate_call).and_return(true)
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/terminate",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(call.reload.status).to eq('completed')
end
end
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/initiate' do
let(:contact) { create(:contact, account: account, phone_number: '+15551234567') }
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '15551234567') }
let(:initiate_conversation) do
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox)
end
it 'creates an outbound Call and returns calling status' do
allow(provider_service).to receive(:initiate_call).and_return({ 'calls' => [{ 'id' => 'wacid_outbound' }] })
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(:ok)
expect(response.parsed_body).to include('status' => 'calling', 'call_id' => 'wacid_outbound')
expect(Call.find_by(provider_call_id: 'wacid_outbound')).to have_attributes(direction: 'outgoing', status: 'ringing')
end
it 'sends a permission request when Meta returns NoCallPermission' do
allow(provider_service).to receive(:initiate_call).and_raise(Voice::CallErrors::NoCallPermission)
allow(provider_service).to receive(:send_call_permission_request).and_return({ 'messages' => [{ 'id' => 'wamid' }] })
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(:ok)
expect(response.parsed_body['status']).to eq('permission_requested')
expect(initiate_conversation.reload.additional_attributes['call_permission_requested_at']).to be_present
end
it 'returns 422 when sdp_offer is missing' do
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
params: { conversation_id: initiate_conversation.display_id },
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
end
end
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/upload_recording' do
before do
message = create(:message, conversation: conversation, account: account, inbox: inbox,
content_type: 'voice_call', message_type: 'incoming')
call.update!(message_id: message.id)
end
it 'attaches the recording to the call message' do
file = fixture_file_upload(Rails.root.join('spec/assets/sample.mp3'), 'audio/mpeg')
expect do
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/upload_recording",
params: { recording: file }, headers: agent.create_new_auth_token
end.to change { call.message.attachments.count }.by(1)
expect(response).to have_http_status(:ok)
expect(response.parsed_body['status']).to eq('uploaded')
end
it 'is idempotent: returns already_uploaded if an audio attachment exists' do
call.message.attachments.create!(account_id: account.id, file_type: :audio,
file: fixture_file_upload(Rails.root.join('spec/assets/sample.mp3'), 'audio/mpeg'))
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/upload_recording",
params: { recording: fixture_file_upload(Rails.root.join('spec/assets/sample.mp3'), 'audio/mpeg') },
headers: agent.create_new_auth_token
expect(response.parsed_body['status']).to eq('already_uploaded')
end
end
end
@@ -0,0 +1,104 @@
require 'rails_helper'
describe Whatsapp::CallService do
let(:account) { create(:account) }
let(:channel) do
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
validate_provider_config: false, sync_templates: false)
end
let(:inbox) { channel.inbox }
let(:agent) { create(:user, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:call) do
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
provider: :whatsapp, direction: :incoming, status: 'ringing', provider_call_id: 'wacid_abc')
end
let(:provider_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
before do
channel.provider_config = channel.provider_config.merge('calling_enabled' => true)
channel.save!
allow(channel).to receive(:provider_service).and_return(provider_service)
allow(inbox).to receive(:channel).and_return(channel)
allow(call).to receive(:inbox).and_return(inbox)
allow(ActionCable.server).to receive(:broadcast)
end
describe '#accept' do
let(:sdp_answer) { "v=0\r\n...sdp..." }
before do
allow(provider_service).to receive(:pre_accept_call).and_return(true)
allow(provider_service).to receive(:accept_call).and_return(true)
end
it 'forwards the SDP answer to Meta and transitions the call to in_progress' do
described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept
expect(provider_service).to have_received(:pre_accept_call).with('wacid_abc', sdp_answer)
expect(provider_service).to have_received(:accept_call).with('wacid_abc', sdp_answer)
expect(call.reload).to have_attributes(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: be_present)
expect(call.meta['sdp_answer']).to eq(sdp_answer)
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}", hash_including(event: 'voice_call.accepted')
)
end
it 'claims the conversation when no assignee is set' do
described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept
expect(conversation.reload.assignee_id).to eq(agent.id)
end
it 'raises NotRinging when the call is no longer ringing' do
call.update!(status: 'in_progress')
expect { described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept }
.to raise_error(Voice::CallErrors::NotRinging)
end
it 'raises CallFailed when sdp_answer is missing' do
expect { described_class.new(call: call, agent: agent, sdp_answer: nil).accept }
.to raise_error(Voice::CallErrors::CallFailed, 'sdp_answer is required')
end
end
describe '#reject' do
before { allow(provider_service).to receive(:reject_call).and_return(true) }
it 'tells Meta to reject and finalizes the call as failed' do
described_class.new(call: call, agent: agent).reject
expect(provider_service).to have_received(:reject_call).with('wacid_abc')
expect(call.reload.status).to eq('failed')
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}", hash_including(event: 'voice_call.ended', data: hash_including(status: 'failed'))
)
end
it 'is a no-op for already-terminal calls' do
call.update!(status: 'completed')
described_class.new(call: call, agent: agent).reject
expect(provider_service).not_to have_received(:reject_call)
end
end
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
call.update!(status: 'in_progress')
described_class.new(call: call, agent: agent).terminate
expect(provider_service).to have_received(:terminate_call).with('wacid_abc')
expect(call.reload.status).to eq('completed')
expect(call.meta['ended_at']).to be_present
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}", hash_including(event: 'voice_call.ended')
)
end
end
end