From 2b0aaa237f1432597361d02a5127c554fddb6340 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Thu, 30 Apr 2026 14:59:46 +0700 Subject: [PATCH] feat(voice): add WhatsApp inbound call webhook pipeline --- app/jobs/webhooks/whatsapp_events_job.rb | 2 + .../enterprise/messages/message_builder.rb | 11 +- .../controllers/twilio/voice_controller.rb | 1 - .../webhooks/whatsapp_events_job.rb | 49 +++++++ enterprise/app/models/call.rb | 28 ++++ .../services/voice/call_message_builder.rb | 33 ++++- .../services/voice/inbound_call_builder.rb | 38 ++++-- .../whatsapp/call_permission_reply_service.rb | 62 +++++++++ .../whatsapp/incoming_call_service.rb | 112 +++++++++++++++ .../twilio/voice_controller_spec.rb | 1 - .../voice/inbound_call_builder_spec.rb | 1 - .../call_permission_reply_service_spec.rb | 62 +++++++++ .../whatsapp/incoming_call_service_spec.rb | 129 ++++++++++++++++++ 13 files changed, 513 insertions(+), 16 deletions(-) create mode 100644 enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb create mode 100644 enterprise/app/services/whatsapp/call_permission_reply_service.rb create mode 100644 enterprise/app/services/whatsapp/incoming_call_service.rb create mode 100644 spec/enterprise/services/whatsapp/call_permission_reply_service_spec.rb create mode 100644 spec/enterprise/services/whatsapp/incoming_call_service_spec.rb diff --git a/app/jobs/webhooks/whatsapp_events_job.rb b/app/jobs/webhooks/whatsapp_events_job.rb index b0bede8b0..49b7265b8 100644 --- a/app/jobs/webhooks/whatsapp_events_job.rb +++ b/app/jobs/webhooks/whatsapp_events_job.rb @@ -128,3 +128,5 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id end end + +Webhooks::WhatsappEventsJob.prepend_mod_with('Webhooks::WhatsappEventsJob') diff --git a/enterprise/app/builders/enterprise/messages/message_builder.rb b/enterprise/app/builders/enterprise/messages/message_builder.rb index 06bd0357c..4956ebd80 100644 --- a/enterprise/app/builders/enterprise/messages/message_builder.rb +++ b/enterprise/app/builders/enterprise/messages/message_builder.rb @@ -2,13 +2,22 @@ module Enterprise::Messages::MessageBuilder private def message_type - return @message_type if @message_type == 'incoming' && twilio_voice_inbox? && @params[:content_type] == 'voice_call' + return @message_type if @message_type == 'incoming' && voice_call_inbox? && @params[:content_type] == 'voice_call' super end + def voice_call_inbox? + twilio_voice_inbox? || whatsapp_call_inbox? + end + def twilio_voice_inbox? inbox = @conversation.inbox inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled? end + + def whatsapp_call_inbox? + inbox = @conversation.inbox + inbox.channel_type == 'Channel::Whatsapp' && inbox.channel.provider_config['calling_enabled'] + end end diff --git a/enterprise/app/controllers/twilio/voice_controller.rb b/enterprise/app/controllers/twilio/voice_controller.rb index 6024e2b77..4a5c94f62 100644 --- a/enterprise/app/controllers/twilio/voice_controller.rb +++ b/enterprise/app/controllers/twilio/voice_controller.rb @@ -84,7 +84,6 @@ class Twilio::VoiceController < ApplicationController case twilio_direction when 'inbound' Voice::InboundCallBuilder.perform!( - account: current_account, inbox: inbox, from_number: twilio_from, call_sid: twilio_call_sid diff --git a/enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb b/enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb new file mode 100644 index 000000000..2a0db6f98 --- /dev/null +++ b/enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb @@ -0,0 +1,49 @@ +module Enterprise::Webhooks::WhatsappEventsJob + def handle_message_events(channel, params) + return handle_call_events(channel, params) if call_event?(params) + return handle_call_permission_reply(channel, params) if call_permission_reply?(params) + + super + end + + private + + # OSS contact_sender_id returns nil for `field: 'calls'` payloads, which makes + # the parent job bypass the per-(inbox, sender) mutex. That lets a fast + # `terminate` webhook be processed before the `connect` transaction commits, + # silently dropping the terminate and stranding the call as `ringing`. Falling + # through to `call:` reuses the existing mutex to serialize connect and + # terminate for the same call. + def contact_sender_id(params) + super.presence || call_event_sender_id(params) + end + + def call_event_sender_id(params) + call_id = params.dig(:entry, 0, :changes, 0, :value, :calls, 0, :id) + call_id.present? ? "call:#{call_id}" : nil + end + + def call_event?(params) + params.dig(:entry, 0, :changes, 0, :field) == 'calls' + end + + def call_permission_reply?(params) + message = params.dig(:entry, 0, :changes, 0, :value, :messages, 0) + message&.dig(:type) == 'interactive' && message&.dig(:interactive, :type) == 'call_permission_reply' + end + + def handle_call_events(channel, params) + Whatsapp::IncomingCallService.new( + inbox: channel.inbox, + params: extract_call_params(params) + ).perform + end + + def handle_call_permission_reply(channel, params) + Whatsapp::CallPermissionReplyService.new(inbox: channel.inbox, params: params).perform + end + + def extract_call_params(params) + params.dig(:entry, 0, :changes, 0, :value) || {} + end +end diff --git a/enterprise/app/models/call.rb b/enterprise/app/models/call.rb index ec3d82d63..47cea7fc4 100644 --- a/enterprise/app/models/call.rb +++ b/enterprise/app/models/call.rb @@ -36,6 +36,9 @@ class Call < ApplicationRecord store_accessor :meta, :conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at + # Frontend voice bubbles/stores expect inbound/outbound string values + DISPLAY_DIRECTION = { 'incoming' => 'inbound', 'outgoing' => 'outbound' }.freeze + enum :provider, { twilio: 0, whatsapp: 1 } enum :direction, { incoming: 0, outgoing: 1 } @@ -64,6 +67,31 @@ class Call < ApplicationRecord "conf_account_#{account_id}_call_#{id}" end + # Browser ↔ Meta WebRTC needs at least one STUN server to discover its + # public srflx candidate. Configurable via VOICE_CALL_STUN_URLS (comma- + # separated). TURN can be added by appending turn:user@host?credential=... + # entries to the same env var. + def self.default_ice_servers + urls = ENV.fetch('VOICE_CALL_STUN_URLS', 'stun:stun.l.google.com:19302').split(',').map(&:strip).reject(&:blank?) + [{ urls: urls }] + end + + def direction_label + DISPLAY_DIRECTION[direction] + end + + def ringing? + status == 'ringing' + end + + def in_progress? + status == 'in_progress' + end + + def terminal? + TERMINAL_STATUSES.include?(status) + end + def display_status status.to_s.tr('_', '-') end diff --git a/enterprise/app/services/voice/call_message_builder.rb b/enterprise/app/services/voice/call_message_builder.rb index 8563a8282..74bb7825e 100644 --- a/enterprise/app/services/voice/call_message_builder.rb +++ b/enterprise/app/services/voice/call_message_builder.rb @@ -1,4 +1,8 @@ class Voice::CallMessageBuilder + def self.update_status!(call:, status: nil, agent: nil, duration_seconds: nil) + new(call).update_status!(status: status, agent: agent, duration_seconds: duration_seconds) + end + def initialize(call) @call = call end @@ -7,6 +11,20 @@ class Voice::CallMessageBuilder call.message || create_message! end + def update_status!(status:, agent: nil, duration_seconds: nil) + message = call.message + return unless message + + data = (message.content_attributes || {}).deep_dup + data['data'] ||= {} + data['data']['status'] = status.to_s.tr('_', '-') if status + data['data']['accepted_by'] = { 'id' => agent.id, 'name' => agent.name } if agent + data['data']['duration_seconds'] = duration_seconds if duration_seconds + + message.update!(content_attributes: data) + message + end + private attr_reader :call @@ -15,7 +33,8 @@ class Voice::CallMessageBuilder params = { content: 'Voice Call', message_type: call.outgoing? ? 'outgoing' : 'incoming', - content_type: 'voice_call' + content_type: 'voice_call', + content_attributes: { 'data' => build_data_payload } } Messages::MessageBuilder.new(sender, call.conversation, params).perform end @@ -23,4 +42,16 @@ class Voice::CallMessageBuilder def sender call.outgoing? ? call.accepted_by_agent : call.contact end + + # `call_source` lets the FE disambiguate WhatsApp vs Twilio for UI copy and + # event routing without fetching the whole Call record client-side. + def build_data_payload + { + 'call_id' => call.id, + 'call_sid' => call.provider_call_id, + 'call_source' => call.provider, + 'call_direction' => call.direction_label, + 'status' => call.display_status + } + end end diff --git a/enterprise/app/services/voice/inbound_call_builder.rb b/enterprise/app/services/voice/inbound_call_builder.rb index a2790dda8..72d5809cb 100644 --- a/enterprise/app/services/voice/inbound_call_builder.rb +++ b/enterprise/app/services/voice/inbound_call_builder.rb @@ -1,15 +1,20 @@ class Voice::InboundCallBuilder - attr_reader :account, :inbox, :from_number, :call_sid + attr_reader :inbox, :from_number, :call_sid, :provider, :extra_meta - def self.perform!(account:, inbox:, from_number:, call_sid:) - new(account: account, inbox: inbox, from_number: from_number, call_sid: call_sid).perform! + # `provider` defaults to :twilio for back-compat with the original Twilio-only + # call site; WhatsApp passes :whatsapp + extra_meta carrying the SDP offer + # and ICE servers. + def self.perform!(inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {}) + new(inbox: inbox, from_number: from_number, call_sid: call_sid, + provider: provider, extra_meta: extra_meta).perform! end - def initialize(account:, inbox:, from_number:, call_sid:) - @account = account + def initialize(inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {}) @inbox = inbox @from_number = from_number @call_sid = call_sid + @provider = provider.to_sym + @extra_meta = extra_meta || {} end def perform! @@ -26,15 +31,19 @@ class Voice::InboundCallBuilder call end rescue ActiveRecord::RecordNotUnique - # A concurrent Twilio retry won the create race; return what now exists. + # A concurrent provider retry won the create race; return what now exists. find_existing_call || raise end private + def account + inbox.account + end + def find_existing_call Call.where(account_id: account.id, inbox_id: inbox.id) - .find_by(provider: :twilio, provider_call_id: call_sid) + .find_by(provider: provider, provider_call_id: call_sid) end def ensure_contact! @@ -48,10 +57,16 @@ class Voice::InboundCallBuilder contact_id: contact.id, inbox_id: inbox.id ) do |record| - record.source_id = from_number + record.source_id = source_id_for_provider end end + # WhatsApp ContactInbox.source_id must be digits-only (the wa_id); Twilio + # accepts the `+`-prefixed phone number as-is. + def source_id_for_provider + provider == :whatsapp ? from_number.to_s.delete_prefix('+') : from_number + end + def resolve_conversation!(contact, contact_inbox) if inbox.lock_to_single_conversation reusable = account.conversations @@ -76,13 +91,14 @@ class Voice::InboundCallBuilder inbox: inbox, conversation: conversation, contact: contact, - provider: :twilio, + provider: provider, direction: :incoming, status: 'ringing', provider_call_id: call_sid, - meta: { 'initiated_at' => Time.zone.now.to_i } + meta: { 'initiated_at' => Time.zone.now.to_i }.merge(extra_meta.stringify_keys) ) - call.update!(conference_sid: call.default_conference_sid) + # `conference_sid` is a Twilio bridging concept; WhatsApp goes browser↔Meta. + call.update!(conference_sid: call.default_conference_sid) if call.twilio? call end end diff --git a/enterprise/app/services/whatsapp/call_permission_reply_service.rb b/enterprise/app/services/whatsapp/call_permission_reply_service.rb new file mode 100644 index 000000000..95798b8d7 --- /dev/null +++ b/enterprise/app/services/whatsapp/call_permission_reply_service.rb @@ -0,0 +1,62 @@ +class Whatsapp::CallPermissionReplyService + pattr_initialize [:inbox!, :params!] + + def perform + return unless inbox.channel.provider_config['calling_enabled'] + + reply_data = extract_reply_data + return unless reply_data&.dig(:accepted) + + contact = find_contact(reply_data[:from_number]) + return unless contact + + conversation = find_active_conversation(contact) + return unless conversation + + clear_permission_flag(conversation) + broadcast_permission_granted(contact, conversation) + end + + private + + def extract_reply_data + value = params.dig(:entry, 0, :changes, 0, :value) + message = value&.dig(:messages, 0) + reply = message&.dig(:interactive, :call_permission_reply) + return unless reply + + accepted = reply[:response] == 'accept' + Rails.logger.info "[WHATSAPP CALL] call_permission_reply from=#{message[:from]} accepted=#{accepted} permanent=#{reply[:is_permanent]}" + + { from_number: message[:from], accepted: accepted } + end + + def find_contact(from_number) + inbox.contact_inboxes.joins(:contact) + .where(contacts: { phone_number: "+#{from_number}" }) + .first&.contact + end + + def find_active_conversation(contact) + inbox.conversations.where(contact: contact).where.not(status: :resolved).order(:created_at).last + end + + def clear_permission_flag(conversation) + attrs = conversation.additional_attributes || {} + attrs.delete('call_permission_requested_at') + conversation.update!(additional_attributes: attrs) + end + + def broadcast_permission_granted(contact, conversation) + ActionCable.server.broadcast( + "account_#{inbox.account_id}", + { + event: 'voice_call.permission_granted', + data: { + account_id: inbox.account_id, conversation_id: conversation.id, + contact_name: contact.name, contact_phone: contact.phone_number + } + } + ) + end +end diff --git a/enterprise/app/services/whatsapp/incoming_call_service.rb b/enterprise/app/services/whatsapp/incoming_call_service.rb new file mode 100644 index 000000000..7e3d58e1e --- /dev/null +++ b/enterprise/app/services/whatsapp/incoming_call_service.rb @@ -0,0 +1,112 @@ +class Whatsapp::IncomingCallService + pattr_initialize [:inbox!, :params!] + + def perform + return unless inbox.channel.provider_config['calling_enabled'] + + Array(params[:calls]).each do |call_payload| + process_call_event(call_payload.with_indifferent_access) + end + end + + private + + def process_call_event(call_payload) + case call_payload[:event] + when 'connect' then handle_call_connect(call_payload) + when 'terminate' then handle_call_terminate(call_payload) + else Rails.logger.warn "[WHATSAPP CALL] Unknown call event: #{call_payload[:event]}" + end + end + + def handle_call_connect(call_payload) + existing = Call.whatsapp.find_by(provider_call_id: call_payload[:id]) + existing ? handle_outbound_connect(existing, call_payload) : handle_inbound_connect(call_payload) + rescue ActiveRecord::RecordNotUnique + Rails.logger.warn "[WHATSAPP CALL] Duplicate provider_call_id received: #{call_payload[:id]}" + end + + def handle_outbound_connect(call, call_payload) + return if call.in_progress? + + sdp_answer = fix_sdp_setup(call_payload.dig(:session, :sdp)) + call.update!(status: 'in_progress', started_at: Time.current, + meta: (call.meta || {}).merge('sdp_answer' => sdp_answer)) + Voice::CallMessageBuilder.update_status!(call: call, status: 'in_progress', agent: call.accepted_by_agent) + update_conversation_call_status(call.conversation, call.display_status, call.direction_label) + broadcast(call, 'voice_call.outbound_connected', sdp_answer: sdp_answer) + end + + # Inbound delegates to Voice::InboundCallBuilder for contact + conversation + + # call + message creation; auto-assignment falls out of the standard + # Conversation lifecycle. We just stash Meta's SDP offer in meta. + def handle_inbound_connect(call_payload) + sdp_offer = call_payload.dig(:session, :sdp) + call = Voice::InboundCallBuilder.perform!( + inbox: inbox, + from_number: "+#{call_payload[:from]}", call_sid: call_payload[:id], + provider: :whatsapp, + extra_meta: { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers } + ) + update_conversation_call_status(call.conversation, call.display_status, call.direction_label) + broadcast_incoming_call(call, sdp_offer) + end + + def handle_call_terminate(call_payload) + call = Call.whatsapp.find_by(provider_call_id: call_payload[:id]) + return unless call + + duration = call_payload[:duration]&.to_i + final_status = answered?(call, duration) ? 'completed' : 'no_answer' + call.update!(status: final_status, duration_seconds: duration, end_reason: call_payload[:terminate_reason]) + Voice::CallMessageBuilder.update_status!(call: call, status: final_status, agent: call.accepted_by_agent, + duration_seconds: duration) + update_conversation_call_status(call.conversation, call.display_status, call.direction_label) + broadcast(call, 'voice_call.ended', status: call.status, duration_seconds: call.duration_seconds) + end + + def answered?(call, duration) + call.in_progress? || duration.to_i.positive? || call.accepted_by_agent_id.present? + end + + def update_conversation_call_status(conversation, call_status, direction) + conversation.update!( + additional_attributes: (conversation.additional_attributes || {}).merge( + 'call_status' => call_status, 'call_direction' => direction + ) + ) + end + + # Ring only the conversation's assignee when assigned, account-wide otherwise + # so any eligible agent can pick up. + def broadcast_incoming_call(call, sdp_offer) + contact = call.contact + data = base_payload(call).merge( + direction: call.direction_label, inbox_id: call.inbox_id, + sdp_offer: sdp_offer, ice_servers: Call.default_ice_servers, + caller: { name: contact.name, phone: contact.phone_number, avatar: contact.avatar_url } + ) + streams = call.conversation.assignee&.pubsub_token ? [call.conversation.assignee.pubsub_token] : ["account_#{inbox.account_id}"] + streams.each { |s| ActionCable.server.broadcast(s, { event: 'voice_call.incoming', data: data }) } + end + + def broadcast(call, event, **extra) + ActionCable.server.broadcast( + "account_#{inbox.account_id}", + { event: event, data: base_payload(call).merge(extra) } + ) + end + + def base_payload(call) + { + account_id: inbox.account_id, id: call.id, call_id: call.provider_call_id, + provider: 'whatsapp', conversation_id: call.conversation_id + } + end + + # Browsers always emit a=setup:active in answers, but Meta sometimes echoes + # actpass in its outbound answer; pin it to active so peers don't renegotiate. + def fix_sdp_setup(sdp) + sdp.present? ? sdp.gsub('a=setup:actpass', 'a=setup:active') : sdp + end +end diff --git a/spec/enterprise/controllers/twilio/voice_controller_spec.rb b/spec/enterprise/controllers/twilio/voice_controller_spec.rb index 205bb2b59..cde513c86 100644 --- a/spec/enterprise/controllers/twilio/voice_controller_spec.rb +++ b/spec/enterprise/controllers/twilio/voice_controller_spec.rb @@ -32,7 +32,6 @@ RSpec.describe 'Twilio::VoiceController', type: :request do call.update!(conference_sid: call.default_conference_sid) expect(Voice::InboundCallBuilder).to receive(:perform!).with( - account: account, inbox: inbox, from_number: from_number, call_sid: call_sid diff --git a/spec/enterprise/services/voice/inbound_call_builder_spec.rb b/spec/enterprise/services/voice/inbound_call_builder_spec.rb index 7af9be8e6..3b1ede3ba 100644 --- a/spec/enterprise/services/voice/inbound_call_builder_spec.rb +++ b/spec/enterprise/services/voice/inbound_call_builder_spec.rb @@ -16,7 +16,6 @@ RSpec.describe Voice::InboundCallBuilder do def perform_builder described_class.perform!( - account: account, inbox: inbox, from_number: from_number, call_sid: call_sid diff --git a/spec/enterprise/services/whatsapp/call_permission_reply_service_spec.rb b/spec/enterprise/services/whatsapp/call_permission_reply_service_spec.rb new file mode 100644 index 000000000..d8a2aca78 --- /dev/null +++ b/spec/enterprise/services/whatsapp/call_permission_reply_service_spec.rb @@ -0,0 +1,62 @@ +require 'rails_helper' + +describe Whatsapp::CallPermissionReplyService 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(:contact) { create(:contact, account: account, phone_number: '+15550001111') } + let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '15550001111') } + let!(:conversation) do + create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :open, + additional_attributes: { 'call_permission_requested_at' => Time.zone.now.to_i }) + end + + before do + channel.provider_config = channel.provider_config.merge('calling_enabled' => true) + channel.save! + end + + def reply_params(response:) + { + entry: [{ changes: [{ value: { messages: [{ from: '15550001111', type: 'interactive', + interactive: { type: 'call_permission_reply', + call_permission_reply: { response: response, + is_permanent: false } } }] } }] }] + } + end + + it 'clears the requested-at flag and broadcasts voice_call.permission_granted on accept' do + allow(ActionCable.server).to receive(:broadcast) + + described_class.new(inbox: inbox, params: reply_params(response: 'accept')).perform + + expect(conversation.reload.additional_attributes).not_to include('call_permission_requested_at') + expect(ActionCable.server).to have_received(:broadcast).with( + "account_#{account.id}", + hash_including(event: 'voice_call.permission_granted', + data: hash_including(conversation_id: conversation.id)) + ) + end + + it 'is a no-op when the contact rejected the request' do + allow(ActionCable.server).to receive(:broadcast) + + described_class.new(inbox: inbox, params: reply_params(response: 'reject')).perform + + expect(conversation.reload.additional_attributes).to include('call_permission_requested_at') + expect(ActionCable.server).not_to have_received(:broadcast) + end + + it 'is a no-op when calling is disabled on the channel' do + channel.provider_config = channel.provider_config.merge('calling_enabled' => false) + channel.save! + allow(ActionCable.server).to receive(:broadcast) + + described_class.new(inbox: inbox, params: reply_params(response: 'accept')).perform + + expect(ActionCable.server).not_to have_received(:broadcast) + end +end diff --git a/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb b/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb new file mode 100644 index 000000000..6ca3aa489 --- /dev/null +++ b/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb @@ -0,0 +1,129 @@ +require 'rails_helper' + +describe Whatsapp::IncomingCallService 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(:from_number) { '15550001111' } + let(:provider_call_id) { 'wacid_abc' } + + before do + channel.provider_config = channel.provider_config.merge('calling_enabled' => true) + channel.save! + end + + def call_payload(event:, **extra) + { calls: [{ id: provider_call_id, from: from_number, event: event, **extra }] } + end + + context 'when calling is disabled on the channel' do + it 'is a no-op' do + channel.provider_config = channel.provider_config.merge('calling_enabled' => false) + channel.save! + + expect { described_class.new(inbox: inbox, params: call_payload(event: 'connect')).perform } + .not_to change(Call, :count) + end + end + + describe 'inbound connect' do + let(:sdp_offer) { "v=0\r\n...sdp..." } + + it 'creates the Call + Conversation + voice_call message and broadcasts voice_call.incoming' do + allow(ActionCable.server).to receive(:broadcast) + + params = call_payload(event: 'connect', session: { sdp: sdp_offer, sdp_type: 'offer' }) + expect { described_class.new(inbox: inbox, params: params).perform } + .to change(Call, :count).by(1).and change(Conversation, :count).by(1) + + call = Call.last + expect(call).to have_attributes(provider: 'whatsapp', direction: 'incoming', status: 'ringing', + provider_call_id: provider_call_id) + expect(call.meta['sdp_offer']).to eq(sdp_offer) + expect(ActionCable.server).to have_received(:broadcast).with( + "account_#{account.id}", + hash_including(event: 'voice_call.incoming', data: hash_including(sdp_offer: sdp_offer)) + ) + end + end + + describe 'outbound connect (existing call)' do + let!(:call) do + conversation = create(:conversation, account: account, inbox: inbox) + create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact, + provider: :whatsapp, direction: :outgoing, status: 'ringing', provider_call_id: provider_call_id) + end + + it 'transitions the call to in_progress and broadcasts voice_call.outbound_connected with the SDP answer' do + allow(ActionCable.server).to receive(:broadcast) + sdp_answer = "v=0\r\na=setup:actpass\r\n" + + params = call_payload(event: 'connect', session: { sdp: sdp_answer, sdp_type: 'answer' }) + described_class.new(inbox: inbox, params: params).perform + + expect(call.reload).to have_attributes(status: 'in_progress', started_at: be_present) + expect(call.meta['sdp_answer']).to include('a=setup:active') + expect(ActionCable.server).to have_received(:broadcast).with( + "account_#{account.id}", + hash_including(event: 'voice_call.outbound_connected') + ) + end + end + + describe 'terminate' do + let!(:call) do + conversation = create(:conversation, account: account, inbox: inbox) + create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact, + provider: :whatsapp, direction: :incoming, status: 'in_progress', provider_call_id: provider_call_id) + end + + it 'marks the call completed when the call had been answered' do + allow(ActionCable.server).to receive(:broadcast) + + params = call_payload(event: 'terminate', duration: 42, terminate_reason: 'completed_normally') + described_class.new(inbox: inbox, params: params).perform + + expect(call.reload).to have_attributes(status: 'completed', duration_seconds: 42, end_reason: 'completed_normally') + expect(ActionCable.server).to have_received(:broadcast).with( + "account_#{account.id}", hash_including(event: 'voice_call.ended') + ) + end + + it 'marks unanswered ringing calls as no_answer' do + call.update!(status: 'ringing') + params = call_payload(event: 'terminate', duration: 0, terminate_reason: 'no_answer') + + described_class.new(inbox: inbox, params: params).perform + expect(call.reload.status).to eq('no_answer') + end + end + + describe 'unknown event' do + it 'logs a warning and does not raise' do + allow(Rails.logger).to receive(:warn) + params = call_payload(event: 'mystery') + + expect { described_class.new(inbox: inbox, params: params).perform }.not_to raise_error + expect(Rails.logger).to have_received(:warn).with(/Unknown call event: mystery/) + end + end + + describe 'multiple calls in one webhook payload' do + it 'processes every call in the array' do + allow(ActionCable.server).to receive(:broadcast) + + params = { + calls: [ + { id: 'wacid_a', from: from_number, event: 'connect', session: { sdp: 'sdp_a', sdp_type: 'offer' } }, + { id: 'wacid_b', from: '15550002222', event: 'connect', session: { sdp: 'sdp_b', sdp_type: 'offer' } } + ] + } + + expect { described_class.new(inbox: inbox, params: params).perform }.to change(Call, :count).by(2) + expect(Call.where(provider_call_id: %w[wacid_a wacid_b]).pluck(:provider_call_id)).to contain_exactly('wacid_a', 'wacid_b') + end + end +end