From 83cd568ca2b2dce48c1fd2ed3a461a99186e047e Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Thu, 30 Apr 2026 16:24:39 +0700 Subject: [PATCH] feat(voice): add WhatsApp Cloud Calling agent service, controller and routes --- config/routes.rb | 15 ++ .../v1/accounts/whatsapp_calls_controller.rb | 136 +++++++++++++++ .../app/models/enterprise/conversation.rb | 12 ++ .../app/services/whatsapp/call_service.rb | 87 ++++++++++ .../whatsapp_calls_controller_spec.rb | 158 ++++++++++++++++++ .../services/whatsapp/call_service_spec.rb | 104 ++++++++++++ 6 files changed, 512 insertions(+) create mode 100644 enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb create mode 100644 enterprise/app/services/whatsapp/call_service.rb create mode 100644 spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb create mode 100644 spec/enterprise/services/whatsapp/call_service_spec.rb diff --git a/config/routes.rb b/config/routes.rb index eff17e714..ebb38efc4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb new file mode 100644 index 000000000..370760052 --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb @@ -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 diff --git a/enterprise/app/models/enterprise/conversation.rb b/enterprise/app/models/enterprise/conversation.rb index 09cb2c569..c5d7dde85 100644 --- a/enterprise/app/models/enterprise/conversation.rb +++ b/enterprise/app/models/enterprise/conversation.rb @@ -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 diff --git a/enterprise/app/services/whatsapp/call_service.rb b/enterprise/app/services/whatsapp/call_service.rb new file mode 100644 index 000000000..5802b379e --- /dev/null +++ b/enterprise/app/services/whatsapp/call_service.rb @@ -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 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 new file mode 100644 index 000000000..857c678bf --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb @@ -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 diff --git a/spec/enterprise/services/whatsapp/call_service_spec.rb b/spec/enterprise/services/whatsapp/call_service_spec.rb new file mode 100644 index 000000000..615f2cf03 --- /dev/null +++ b/spec/enterprise/services/whatsapp/call_service_spec.rb @@ -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