feat(voice): Wire Twilio voice flow through unified call model (#14091)

Twilio voice now uses first-class `Call` records as the source of truth
for call state, instead of storing it on
`conversation.additional_attributes` and `conversation.identifier`. Each
call gets its own record, its own `voice_call` bubble matched by
`call_sid`, and its own conference name keyed off `Call.id`. Multiple
calls on the same conversation (for `lock_to_single_conversation`
inboxes) now work correctly, and the conversation card stays in sync
with the real latest message.
Fixes https://linear.app/chatwoot/issue/PLA-121/lock-to-single-thread

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2026-04-30 11:25:39 +04:00
committed by GitHub
parent cd9c8e3303
commit 1124c1b4c2
37 changed files with 630 additions and 811 deletions

View File

@@ -10,36 +10,35 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
end
def create
conversation = fetch_conversation_by_display_id
ensure_call_sid!(conversation)
call = resolve_call!
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
conference_service = Voice::Provider::Twilio::ConferenceService.new(call: call)
conference_sid = conference_service.ensure_conference_sid
conference_service.mark_agent_joined(user: current_user)
render json: {
status: 'success',
id: conversation.display_id,
id: call.conversation.display_id,
conference_sid: conference_sid,
using_webrtc: true
}
end
def destroy
conversation = fetch_conversation_by_display_id
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
render json: { status: 'success', id: conversation.display_id }
call = resolve_call!
Voice::Provider::Twilio::ConferenceService.new(call: call).end_conference
render json: { status: 'success', id: call.conversation.display_id }
end
private
def ensure_call_sid!(conversation)
return conversation.identifier if conversation.identifier.present?
def resolve_call!
sid = params[:call_sid].presence
raise ActionController::ParameterMissing, :call_sid if sid.blank?
incoming_sid = params.require(:call_sid)
conversation.update!(identifier: incoming_sid)
incoming_sid
conversation = fetch_conversation_by_display_id
Call.where(inbox_id: @voice_inbox.id, provider: :twilio, conversation_id: conversation.id)
.find_by!(provider_call_id: sid)
end
def set_voice_inbox_for_conference

View File

@@ -6,20 +6,18 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
authorize contact, :show?
authorize voice_inbox, :show?
result = Voice::OutboundCallBuilder.perform!(
call = Voice::OutboundCallBuilder.perform!(
account: Current.account,
inbox: voice_inbox,
user: Current.user,
contact: contact
)
conversation = result[:conversation]
render json: {
conversation_id: conversation.display_id,
conversation_id: call.conversation.display_id,
inbox_id: voice_inbox.id,
call_sid: result[:call_sid],
conference_sid: conversation.additional_attributes['conference_sid']
call_sid: call.provider_call_id,
conference_sid: call.conference_sid
}
end

View File

@@ -20,30 +20,28 @@ class Twilio::VoiceController < ApplicationController
end
def call_twiml
account = current_account
Rails.logger.info(
"TWILIO_VOICE_TWIML account=#{account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
"TWILIO_VOICE_TWIML account=#{current_account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
)
conversation = resolve_conversation
conference_sid = ensure_conference_sid!(conversation)
render xml: conference_twiml(conference_sid, agent_leg?(twilio_from))
call = resolve_call
render xml: conference_twiml(call)
end
def conference_status
event = mapped_conference_event
return head :no_content unless event
if event.nil?
Rails.logger.info(
"TWILIO_VOICE_CONFERENCE_UNMAPPED_EVENT account=#{current_account.id} event=#{params[:StatusCallbackEvent]} call_sid=#{twilio_call_sid}"
)
return head :no_content
end
conversation = find_conversation_for_conference!(
friendly_name: params[:FriendlyName],
call_sid: twilio_call_sid
)
call = find_call_for_conference!(params[:FriendlyName], twilio_call_sid)
Voice::Conference::Manager.new(
conversation: conversation,
call: call,
event: event,
call_sid: twilio_call_sid,
participant_label: participant_label
).process
@@ -80,8 +78,8 @@ class Twilio::VoiceController < ApplicationController
from_number.start_with?('client:')
end
def resolve_conversation
return find_conversation_for_agent if agent_leg?(twilio_from)
def resolve_call
return find_call_for_agent if agent_leg?(twilio_from)
case twilio_direction
when 'inbound'
@@ -92,79 +90,72 @@ class Twilio::VoiceController < ApplicationController
call_sid: twilio_call_sid
)
when 'outbound-api', 'outbound-dial'
sync_outbound_leg(
call_sid: twilio_call_sid,
from_number: twilio_from,
direction: twilio_direction
)
sync_outbound_leg(call_sid: twilio_call_sid, direction: twilio_direction)
else
raise ArgumentError, "Unsupported Twilio direction: #{twilio_direction}"
end
end
def find_conversation_for_agent
if params[:conversation_id].present?
current_account.conversations.find_by!(display_id: params[:conversation_id])
else
current_account.conversations.find_by!(identifier: twilio_call_sid)
end
def find_call_for_agent
sid = params[:call_sid].presence
raise ArgumentError, 'call_sid is required for agent leg' if sid.blank?
inbox_calls.find_by!(provider_call_id: sid)
end
def sync_outbound_leg(call_sid:, from_number:, direction:)
def sync_outbound_leg(call_sid:, direction:)
parent_sid = params['ParentCallSid'].presence
lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
conversation = current_account.conversations.find_by!(identifier: lookup_sid)
call = inbox_calls.find_by!(provider_call_id: lookup_sid)
Voice::CallSessionSyncService.new(
conversation: conversation,
call_sid: call_sid,
message_call_sid: conversation.identifier,
leg: {
from_number: from_number,
to_number: twilio_to,
direction: 'outbound'
}
).perform
call.update!(parent_call_sid: parent_sid) if parent_sid.present? && call.parent_call_sid != parent_sid
call
end
def ensure_conference_sid!(conversation)
attrs = conversation.additional_attributes || {}
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
conversation.update!(additional_attributes: attrs)
attrs['conference_sid']
def inbox_calls
Call.where(inbox_id: inbox.id, provider: :twilio)
end
def conference_twiml(conference_sid, agent_leg)
def conference_twiml(call)
conference_sid = ensure_conference_sid!(call)
Twilio::TwiML::VoiceResponse.new.tap do |response|
response.dial do |dial|
dial.conference(
conference_sid,
start_conference_on_enter: agent_leg,
start_conference_on_enter: agent_leg?(twilio_from),
end_conference_on_exit: false,
status_callback: conference_status_callback_url,
status_callback_event: 'start end join leave',
status_callback_method: 'POST',
participant_label: agent_leg ? 'agent' : 'contact'
participant_label: participant_label_for(twilio_from)
)
end
end.to_s
end
def ensure_conference_sid!(call)
return call.conference_sid if call.conference_sid.present?
call.update!(conference_sid: call.default_conference_sid)
call.conference_sid
end
def participant_label_for(from_number)
return from_number.delete_prefix('client:') if from_number.start_with?('client:')
'contact'
end
def conference_status_callback_url
phone_digits = inbox_channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
end
def find_conversation_for_conference!(friendly_name:, call_sid:)
def find_call_for_conference!(friendly_name, call_sid)
name = friendly_name.to_s
scope = current_account.conversations
if name.present?
conversation = scope.where("additional_attributes->>'conference_sid' = ?", name).first
return conversation if conversation
end
scope.find_by!(identifier: call_sid)
call = inbox_calls.by_conference_sid(name).first if name.present?
call || inbox_calls.find_by!(provider_call_id: call_sid)
end
def set_inbox!

View File

@@ -0,0 +1,5 @@
module Enterprise::MessageFinder
def conversation_messages
super.includes(call: [:contact, { inbox: :channel }])
end
end

View File

@@ -34,6 +34,8 @@ class Call < ApplicationRecord
# Statuses where the call is finished and won't change again
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
store_accessor :meta, :conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
enum :provider, { twilio: 0, whatsapp: 1 }
enum :direction, { incoming: 0, outgoing: 1 }
@@ -41,7 +43,7 @@ class Call < ApplicationRecord
belongs_to :inbox
belongs_to :conversation
belongs_to :contact
belongs_to :message, optional: true
belongs_to :message, optional: true, inverse_of: :call
belongs_to :accepted_by_agent, class_name: 'User', optional: true
has_one_attached :recording
@@ -52,4 +54,50 @@ class Call < ApplicationRecord
validates :status, presence: true, inclusion: { in: STATUSES }
scope :active, -> { where.not(status: TERMINAL_STATUSES) }
scope :by_conference_sid, ->(sid) { where("meta->>'conference_sid' = ?", sid) }
def self.find_by_provider_call_id(provider, sid)
find_by(provider: provider, provider_call_id: sid)
end
def default_conference_sid
"conf_account_#{account_id}_call_#{id}"
end
def display_status
status.to_s.tr('_', '-')
end
def from_number
incoming? ? contact.phone_number : inbox.channel&.phone_number
end
def to_number
incoming? ? inbox.channel&.phone_number : contact.phone_number
end
def recording_url
return nil unless recording.attached?
Rails.application.routes.url_helpers.rails_blob_url(recording)
end
def push_event_data
{
id: id,
provider_call_id: provider_call_id,
provider: provider,
direction: direction,
status: display_status,
duration_seconds: duration_seconds,
conference_sid: conference_sid,
accepted_by_agent_id: accepted_by_agent_id,
started_at: started_at&.to_i,
ended_at: ended_at,
from_number: from_number,
to_number: to_number,
recording_url: recording_url,
transcript: transcript
}
end
end

View File

@@ -25,17 +25,6 @@ module Enterprise::Conversation
self.captain_activity_reason_type = previous_reason_type
end
# Include select additional_attributes keys (call related) for update events
def allowed_keys?
return true if super
attrs_change = previous_changes['additional_attributes']
return false unless attrs_change.is_a?(Array) && attrs_change[1].is_a?(Hash)
changed_attr_keys = attrs_change[1].keys
changed_attr_keys.intersect?(%w[call_status])
end
private
def dispatch_captain_inference_event(event_name)

View File

@@ -1,4 +1,18 @@
module Enterprise::Message
def self.prepended(base)
base.class_eval do
has_one :call, class_name: 'Call', foreign_key: :message_id, dependent: :nullify, inverse_of: :message
scope :with_call, -> { includes(call: [:contact, { inbox: :channel }]) }
end
end
def push_event_data
data = super
data[:call] = call.push_event_data if content_type == 'voice_call' && call.present?
data
end
private
def mark_pending_conversation_as_open_for_human_response

View File

@@ -1,90 +1,26 @@
class Voice::CallMessageBuilder
def self.perform!(conversation:, direction:, payload:, user: nil, timestamps: {})
new(
conversation: conversation,
direction: direction,
payload: payload,
user: user,
timestamps: timestamps
).perform!
end
def initialize(conversation:, direction:, payload:, user:, timestamps:)
@conversation = conversation
@direction = direction
@payload = payload
@user = user
@timestamps = timestamps
def initialize(call)
@call = call
end
def perform!
validate_sender!
message = latest_message
message ? update_message!(message) : create_message!
call.message || create_message!
end
private
attr_reader :conversation, :direction, :payload, :user, :timestamps
def latest_message
conversation.messages.voice_calls.order(created_at: :desc).first
end
def update_message!(message)
message.update!(
message_type: message_type,
content_attributes: { 'data' => base_payload },
sender: sender
)
end
attr_reader :call
def create_message!
params = {
content: 'Voice Call',
message_type: message_type,
content_type: 'voice_call',
content_attributes: { 'data' => base_payload }
message_type: call.outgoing? ? 'outgoing' : 'incoming',
content_type: 'voice_call'
}
Messages::MessageBuilder.new(sender, conversation, params).perform
end
def base_payload
@base_payload ||= begin
data = payload.slice(
:call_sid,
:status,
:call_direction,
:conference_sid,
:from_number,
:to_number
).stringify_keys
data['call_direction'] = direction
data['meta'] = {
'created_at' => timestamps[:created_at] || current_timestamp,
'ringing_at' => timestamps[:ringing_at] || current_timestamp
}.compact
data
end
end
def message_type
direction == 'outbound' ? 'outgoing' : 'incoming'
Messages::MessageBuilder.new(sender, call.conversation, params).perform
end
def sender
return user if direction == 'outbound'
conversation.contact
end
def validate_sender!
return unless direction == 'outbound'
raise ArgumentError, 'Agent sender required for outbound calls' unless user
end
def current_timestamp
@current_timestamp ||= Time.zone.now.to_i
call.outgoing? ? call.accepted_by_agent : call.contact
end
end

View File

@@ -1,94 +0,0 @@
class Voice::CallSessionSyncService
attr_reader :conversation, :call_sid, :message_call_sid, :from_number, :to_number, :direction
def initialize(conversation:, call_sid:, leg:, message_call_sid: nil)
@conversation = conversation
@call_sid = call_sid
@message_call_sid = message_call_sid || call_sid
@from_number = leg[:from_number]
@to_number = leg[:to_number]
@direction = leg[:direction]
end
def perform
ActiveRecord::Base.transaction do
attrs = refreshed_attributes
conversation.update!(
additional_attributes: attrs,
last_activity_at: current_time
)
sync_voice_call_message!(attrs)
end
conversation
end
private
def refreshed_attributes
attrs = (conversation.additional_attributes || {}).dup
attrs['call_direction'] = direction
attrs['call_status'] ||= 'ringing'
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
attrs['meta'] ||= {}
attrs['meta']['initiated_at'] ||= current_timestamp
attrs
end
def sync_voice_call_message!(attrs)
Voice::CallMessageBuilder.perform!(
conversation: conversation,
direction: direction,
payload: {
call_sid: message_call_sid,
status: attrs['call_status'],
conference_sid: attrs['conference_sid'],
from_number: origin_number_for(direction),
to_number: target_number_for(direction)
},
user: agent_for(attrs),
timestamps: {
created_at: attrs.dig('meta', 'initiated_at'),
ringing_at: attrs.dig('meta', 'ringing_at')
}
)
end
def origin_number_for(current_direction)
return outbound_origin if current_direction == 'outbound'
from_number.presence || inbox_number
end
def target_number_for(current_direction)
return conversation.contact&.phone_number || to_number if current_direction == 'outbound'
to_number || conversation.contact&.phone_number
end
def agent_for(attrs)
agent_id = attrs['agent_id']
return nil unless agent_id
agent = conversation.account.users.find_by(id: agent_id)
raise ArgumentError, 'Agent sender required for outbound call sync' if direction == 'outbound' && agent.nil?
agent
end
def current_timestamp
@current_timestamp ||= current_time.to_i
end
def current_time
@current_time ||= Time.zone.now
end
def outbound_origin
inbox_number || from_number
end
def inbox_number
conversation.inbox&.channel&.phone_number
end
end

View File

@@ -1,66 +1,44 @@
class Voice::CallStatus::Manager
pattr_initialize [:conversation!, :call_sid]
ALLOWED_STATUSES = %w[ringing in-progress completed no-answer failed].freeze
TERMINAL_STATUSES = %w[completed no-answer failed].freeze
pattr_initialize [:call!]
def process_status_update(status, duration: nil, timestamp: nil)
return unless ALLOWED_STATUSES.include?(status)
return unless Call::STATUSES.include?(status)
return if call.status == status
current_status = conversation.additional_attributes&.dig('call_status')
return if current_status == status
apply_status(status, duration: duration, timestamp: timestamp)
update_message(status)
apply_call_updates!(status, duration: duration, timestamp: timestamp)
call.conversation.update!(last_activity_at: Time.zone.now)
# Bump updated_at so the message.updated dispatcher rebroadcasts with the fresh Call embedded.
call.message&.touch # rubocop:disable Rails/SkipsModelValidations
end
private
def apply_status(status, duration:, timestamp:)
attrs = (conversation.additional_attributes || {}).dup
attrs['call_status'] = status
def apply_call_updates!(status, duration:, timestamp:)
attrs = { status: status }
ts = timestamp || now_seconds
if status == 'in-progress'
attrs['call_started_at'] ||= timestamp || now_seconds
elsif TERMINAL_STATUSES.include?(status)
attrs['call_ended_at'] = timestamp || now_seconds
attrs['call_duration'] = resolved_duration(attrs, duration, timestamp)
if status == 'in_progress'
# Twilio can emit multiple in-progress updates (answered + in-progress, retries).
# Keep the earliest timestamp so duration_seconds doesn't shift forward.
started_at = Time.zone.at(ts)
attrs[:started_at] = started_at if call.started_at.nil? || started_at < call.started_at
elsif Call::TERMINAL_STATUSES.include?(status)
call.ended_at = ts
attrs[:meta] = call.meta
attrs[:duration_seconds] = resolved_duration(duration, ts)
end
conversation.update!(
additional_attributes: attrs,
last_activity_at: current_time
)
call.update!(attrs)
end
def resolved_duration(attrs, provided_duration, timestamp)
def resolved_duration(provided_duration, timestamp)
return provided_duration if provided_duration
return unless call.started_at
started_at = attrs['call_started_at']
return unless started_at && timestamp
[timestamp - started_at.to_i, 0].max
end
def update_message(status)
message = conversation.messages
.where(content_type: 'voice_call')
.order(created_at: :desc)
.first
return unless message
data = (message.content_attributes || {}).dup
data['data'] ||= {}
data['data']['status'] = status
message.update!(content_attributes: data)
[timestamp - call.started_at.to_i, 0].max
end
def now_seconds
current_time.to_i
end
def current_time
@current_time ||= Time.zone.now
Time.zone.now.to_i
end
end

View File

@@ -1,71 +1,71 @@
class Voice::Conference::Manager
pattr_initialize [:conversation!, :event!, :call_sid!, :participant_label]
pattr_initialize [:call!, :event!, :participant_label]
AGENT_LABEL_PATTERN = /\Aagent-(\d+)-account-(\d+)\z/
def process
case event
when 'start'
ensure_conference_sid!
mark_ringing!
when 'join'
mark_in_progress! if agent_participant?
join_agent! if agent_participant?
when 'leave'
handle_leave!
when 'end'
finalize_conference!
finalize!
end
end
private
def status_manager
@status_manager ||= Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: call_sid
)
end
def ensure_conference_sid!
attrs = conversation.additional_attributes || {}
return if attrs['conference_sid'].present?
attrs['conference_sid'] = Voice::Conference::Name.for(conversation)
conversation.update!(additional_attributes: attrs)
@status_manager ||= Voice::CallStatus::Manager.new(call: call)
end
def mark_ringing!
return if current_status
# Guard against delayed conference-start retries rolling a progressed call back to ringing.
return unless call.status == 'ringing'
status_manager.process_status_update('ringing')
end
def mark_in_progress!
status_manager.process_status_update('in-progress', timestamp: current_timestamp)
def join_agent!
user_id = extract_user_id
call.update!(accepted_by_agent_id: user_id) if user_id
status_manager.process_status_update('in_progress', timestamp: now)
end
# Parses agent user_id from participant_label. Only returns an id when the
# label's embedded account id matches the call's account — protects against
# a spoofed/cross-account label attaching a foreign user to the call.
def extract_user_id
match = participant_label.to_s.match(AGENT_LABEL_PATTERN)
return unless match
return unless match[2].to_i == call.account_id
match[1].to_i
end
def handle_leave!
case current_status
case call.status
when 'ringing'
status_manager.process_status_update('no-answer', timestamp: current_timestamp)
when 'in-progress'
status_manager.process_status_update('completed', timestamp: current_timestamp)
status_manager.process_status_update('no_answer', timestamp: now)
when 'in_progress'
status_manager.process_status_update('completed', timestamp: now)
end
end
def finalize_conference!
return if %w[completed no-answer failed].include?(current_status)
def finalize!
return if Call::TERMINAL_STATUSES.include?(call.status)
status_manager.process_status_update('completed', timestamp: current_timestamp)
end
def current_status
conversation.additional_attributes&.dig('call_status')
status_manager.process_status_update('completed', timestamp: now)
end
def agent_participant?
participant_label.to_s.start_with?('agent')
participant_label.to_s.start_with?('agent-')
end
def current_timestamp
def now
Time.zone.now.to_i
end
end

View File

@@ -1,5 +0,0 @@
module Voice::Conference::Name
def self.for(conversation)
"conf_account_#{conversation.account_id}_conv_#{conversation.display_id}"
end
end

View File

@@ -13,21 +13,30 @@ class Voice::InboundCallBuilder
end
def perform!
timestamp = current_timestamp
existing = find_existing_call
return existing if existing
ActiveRecord::Base.transaction do
contact = ensure_contact!
contact_inbox = ensure_contact_inbox!(contact)
conversation = find_conversation || create_conversation!(contact, contact_inbox)
conversation.reload
update_conversation!(conversation, timestamp)
build_voice_message!(conversation, timestamp)
conversation
conversation = resolve_conversation!(contact, contact_inbox)
call = create_call!(contact, conversation)
message = Voice::CallMessageBuilder.new(call).perform!
call.update!(message_id: message.id)
call
end
rescue ActiveRecord::RecordNotUnique
# A concurrent Twilio retry won the create race; return what now exists.
find_existing_call || raise
end
private
def find_existing_call
Call.where(account_id: account.id, inbox_id: inbox.id)
.find_by(provider: :twilio, provider_call_id: call_sid)
end
def ensure_contact!
account.contacts.find_or_create_by!(phone_number: from_number) do |record|
record.name = from_number if record.name.blank?
@@ -43,57 +52,37 @@ class Voice::InboundCallBuilder
end
end
def find_conversation
return if call_sid.blank?
def resolve_conversation!(contact, contact_inbox)
if inbox.lock_to_single_conversation
reusable = account.conversations
.where(contact_id: contact.id, inbox_id: inbox.id)
.where.not(status: :resolved)
.order(last_activity_at: :desc)
.first
return reusable if reusable
end
account.conversations.includes(:contact).find_by(identifier: call_sid)
end
def create_conversation!(contact, contact_inbox)
account.conversations.create!(
contact_inbox_id: contact_inbox.id,
inbox_id: inbox.id,
contact_id: contact.id,
status: :open,
identifier: call_sid
status: :open
)
end
def update_conversation!(conversation, timestamp)
attrs = {
'call_direction' => 'inbound',
'call_status' => 'ringing',
'conference_sid' => Voice::Conference::Name.for(conversation),
'meta' => { 'initiated_at' => timestamp }
}
conversation.update!(
identifier: call_sid,
additional_attributes: attrs,
last_activity_at: current_time
)
end
def build_voice_message!(conversation, timestamp)
Voice::CallMessageBuilder.perform!(
def create_call!(contact, conversation)
call = Call.create!(
account: account,
inbox: inbox,
conversation: conversation,
direction: 'inbound',
payload: {
call_sid: call_sid,
status: 'ringing',
conference_sid: conversation.additional_attributes['conference_sid'],
from_number: from_number,
to_number: inbox.channel&.phone_number
},
timestamps: { created_at: timestamp, ringing_at: timestamp }
contact: contact,
provider: :twilio,
direction: :incoming,
status: 'ringing',
provider_call_id: call_sid,
meta: { 'initiated_at' => Time.zone.now.to_i }
)
end
def current_timestamp
@current_timestamp ||= current_time.to_i
end
def current_time
@current_time ||= Time.zone.now
call.update!(conference_sid: call.default_conference_sid)
call
end
end

View File

@@ -16,17 +16,14 @@ class Voice::OutboundCallBuilder
raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank?
raise ArgumentError, 'Agent required' if user.blank?
timestamp = current_timestamp
ActiveRecord::Base.transaction do
contact_inbox = ensure_contact_inbox!
conversation = create_conversation!(contact_inbox)
conversation.reload
conference_sid = Voice::Conference::Name.for(conversation)
call_sid = initiate_call!
update_conversation!(conversation, call_sid, conference_sid, timestamp)
build_voice_message!(conversation, call_sid, conference_sid, timestamp)
{ conversation: conversation, call_sid: call_sid }
call = create_call!(conversation, call_sid)
message = Voice::CallMessageBuilder.new(call).perform!
call.update!(message_id: message.id)
call
end
end
@@ -51,48 +48,23 @@ class Voice::OutboundCallBuilder
end
def initiate_call!
inbox.channel.initiate_call(
to: contact.phone_number
)[:call_sid]
inbox.channel.initiate_call(to: contact.phone_number)[:call_sid]
end
def update_conversation!(conversation, call_sid, conference_sid, timestamp)
attrs = {
'call_direction' => 'outbound',
'call_status' => 'ringing',
'agent_id' => user.id,
'conference_sid' => conference_sid,
'meta' => { 'initiated_at' => timestamp }
}
conversation.update!(
identifier: call_sid,
additional_attributes: attrs,
last_activity_at: current_time
)
end
def build_voice_message!(conversation, call_sid, conference_sid, timestamp)
Voice::CallMessageBuilder.perform!(
def create_call!(conversation, call_sid)
call = Call.create!(
account: account,
inbox: inbox,
conversation: conversation,
direction: 'outbound',
payload: {
call_sid: call_sid,
status: 'ringing',
conference_sid: conference_sid,
from_number: inbox.channel&.phone_number,
to_number: contact.phone_number
},
user: user,
timestamps: { created_at: timestamp, ringing_at: timestamp }
contact: contact,
accepted_by_agent: user,
provider: :twilio,
direction: :outgoing,
status: 'ringing',
provider_call_id: call_sid,
meta: { 'initiated_at' => Time.zone.now.to_i }
)
end
def current_timestamp
@current_timestamp ||= current_time.to_i
end
def current_time
@current_time ||= Time.zone.now
call.update!(conference_sid: call.default_conference_sid)
call
end
end

View File

@@ -1,35 +1,24 @@
class Voice::Provider::Twilio::ConferenceService
pattr_initialize [:conversation!]
pattr_initialize [:call!]
def ensure_conference_sid
existing = conversation.additional_attributes&.dig('conference_sid')
return existing if existing.present?
return call.conference_sid if call.conference_sid.present?
sid = Voice::Conference::Name.for(conversation)
merge_attributes('conference_sid' => sid)
sid
call.update!(conference_sid: call.default_conference_sid)
call.conference_sid
end
def mark_agent_joined(user:)
merge_attributes(
'agent_joined' => true,
'joined_at' => Time.current.to_i,
'joined_by' => { id: user.id, name: user.name }
)
call.update!(accepted_by_agent: user)
end
def end_conference
client = conversation.inbox.channel.client
return if call.conference_sid.blank?
client = call.inbox.channel.client
client
.conferences
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
.list(friendly_name: call.conference_sid, status: 'in-progress')
.each { |conf| client.conferences(conf.sid).update(status: 'completed') }
end
private
def merge_attributes(attrs)
current = conversation.additional_attributes || {}
conversation.update!(additional_attributes: current.merge(attrs))
end
end

View File

@@ -5,12 +5,12 @@ class Voice::StatusUpdateService
'queued' => 'ringing',
'initiated' => 'ringing',
'ringing' => 'ringing',
'in-progress' => 'in-progress',
'inprogress' => 'in-progress',
'answered' => 'in-progress',
'in-progress' => 'in_progress',
'inprogress' => 'in_progress',
'answered' => 'in_progress',
'completed' => 'completed',
'busy' => 'no-answer',
'no-answer' => 'no-answer',
'busy' => 'no_answer',
'no-answer' => 'no_answer',
'failed' => 'failed',
'canceled' => 'failed'
}.freeze
@@ -19,13 +19,10 @@ class Voice::StatusUpdateService
normalized_status = normalize_status(call_status)
return if normalized_status.blank?
conversation = account.conversations.find_by(identifier: call_sid)
return unless conversation
call = Call.where(account_id: account.id).find_by(provider: :twilio, provider_call_id: call_sid)
return unless call
Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: call_sid
).process_status_update(
Voice::CallStatus::Manager.new(call: call).process_status_update(
normalized_status,
duration: payload_duration,
timestamp: payload_timestamp