feat(voice): add WhatsApp inbound call webhook pipeline
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:<id>` 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user