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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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!
|
||||
|
||||
5
enterprise/app/finders/enterprise/message_finder.rb
Normal file
5
enterprise/app/finders/enterprise/message_finder.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
module Enterprise::MessageFinder
|
||||
def conversation_messages
|
||||
super.includes(call: [:contact, { inbox: :channel }])
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module Voice::Conference::Name
|
||||
def self.for(conversation)
|
||||
"conf_account_#{conversation.account_id}_conv_#{conversation.display_id}"
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user