feat(voice): add WhatsApp inbound call webhook pipeline

This commit is contained in:
Tanmay Deep Sharma
2026-04-30 14:59:46 +07:00
parent 48022833ea
commit 2b0aaa237f
13 changed files with 513 additions and 16 deletions
+2
View File
@@ -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
+28
View File
@@ -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