feat(voice): wire Twilio voice to unified Call model

Twilio voice flows now persist Call records instead of stuffing state
into conversation.additional_attributes + conversation.identifier.

- InboundCallBuilder / OutboundCallBuilder create Call rows
  (provider, direction, status, provider_call_id, contact, meta),
  link call.message_id to the voice_call message, and denormalize
  call_status / call_direction / conference_sid onto the conversation
  for frontend compatibility.
- Conference::Name.for(call) uses call.id, removing collisions with
  multiple calls per conversation.
- CallMessageBuilder matches existing voice_call messages by
  content_attributes.data.call_sid, so each call gets its own bubble.
- CallStatus::Manager, StatusUpdateService, Conference::Manager,
  CallSessionSyncService, ConferenceController and ConferenceService
  all operate on Call records and look up by provider_call_id.
- InboundCallBuilder honours inbox.lock_to_single_conversation:
  reuses the latest non-resolved conversation when enabled, creates
  a new conversation otherwise.
This commit is contained in:
Muhsin
2026-04-14 15:19:28 +04:00
parent b8df28c8a3
commit da01ed142c
11 changed files with 221 additions and 116 deletions
@@ -11,9 +11,9 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
def create
conversation = fetch_conversation_by_display_id
ensure_call_sid!(conversation)
call = find_or_initialize_call!(conversation)
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)
@@ -27,19 +27,34 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
def destroy
conversation = fetch_conversation_by_display_id
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
call = Current.account.calls.where(conversation_id: conversation.id).order(created_at: :desc).first
return render(json: { status: 'success', id: conversation.display_id }) unless call
Voice::Provider::Twilio::ConferenceService.new(call: call).end_conference
render json: { status: 'success', id: conversation.display_id }
end
private
def ensure_call_sid!(conversation)
return conversation.identifier if conversation.identifier.present?
def find_or_initialize_call!(conversation)
sid = params[:call_sid].presence
existing = Current.account.calls.where(conversation_id: conversation.id, provider: :twilio)
existing = existing.where(provider_call_id: sid) if sid
call = existing.order(created_at: :desc).first
return call if call
incoming_sid = params.require(:call_sid)
raise ActionController::ParameterMissing, :call_sid unless sid
conversation.update!(identifier: incoming_sid)
incoming_sid
Current.account.calls.create!(
inbox_id: conversation.inbox_id,
conversation: conversation,
contact_id: conversation.contact_id,
provider: :twilio,
direction: :outgoing,
status: 'ringing',
provider_call_id: sid,
accepted_by_agent_id: current_user.id
)
end
def set_voice_inbox_for_conference
@@ -25,8 +25,8 @@ class Twilio::VoiceController < ApplicationController
"TWILIO_VOICE_TWIML account=#{account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
)
conversation = resolve_conversation
conference_sid = ensure_conference_sid!(conversation)
call = resolve_call
conference_sid = ensure_conference_sid!(call)
render xml: conference_twiml(conference_sid, agent_leg?(twilio_from))
end
@@ -35,15 +35,14 @@ class Twilio::VoiceController < ApplicationController
event = mapped_conference_event
return head :no_content unless event
conversation = find_conversation_for_conference!(
call = find_call_for_conference!(
friendly_name: params[:FriendlyName],
call_sid: 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 +79,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'
@@ -91,6 +90,7 @@ class Twilio::VoiceController < ApplicationController
from_number: twilio_from,
call_sid: twilio_call_sid
)
find_call!(twilio_call_sid)
when 'outbound-api', 'outbound-dial'
sync_outbound_leg(
call_sid: twilio_call_sid,
@@ -102,36 +102,47 @@ class Twilio::VoiceController < ApplicationController
end
end
def find_conversation_for_agent
def find_call_for_agent
if params[:conversation_id].present?
current_account.conversations.find_by!(display_id: params[:conversation_id])
conversation = current_account.conversations.find_by!(display_id: params[:conversation_id])
current_account.calls.active.where(conversation_id: conversation.id).order(created_at: :desc).first!
else
current_account.conversations.find_by!(identifier: twilio_call_sid)
find_call!(twilio_call_sid)
end
end
def find_call!(sid)
current_account.calls.find_by!(provider: :twilio, provider_call_id: sid)
end
def sync_outbound_leg(call_sid:, from_number:, 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 = find_call!(lookup_sid)
Voice::CallSessionSyncService.new(
conversation: conversation,
call_sid: call_sid,
message_call_sid: conversation.identifier,
call: call,
leg_call_sid: call_sid,
leg: {
from_number: from_number,
to_number: twilio_to,
direction: 'outbound'
}
).perform
call
end
def ensure_conference_sid!(conversation)
def ensure_conference_sid!(call)
name = call.meta['conference_sid'].presence || Voice::Conference::Name.for(call)
call.update!(meta: call.meta.merge('conference_sid' => name)) if call.meta['conference_sid'].blank?
conversation = call.conversation
attrs = conversation.additional_attributes || {}
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
conversation.update!(additional_attributes: attrs)
attrs['conference_sid']
if attrs['conference_sid'] != name
attrs['conference_sid'] = name
conversation.update!(additional_attributes: attrs)
end
name
end
def conference_twiml(conference_sid, agent_leg)
@@ -155,16 +166,16 @@ class Twilio::VoiceController < ApplicationController
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
scope = current_account.calls
if name.present?
conversation = scope.where("additional_attributes->>'conference_sid' = ?", name).first
return conversation if conversation
call = scope.where("meta ->> 'conference_sid' = ?", name).first
return call if call
end
scope.find_by!(identifier: call_sid)
scope.find_by!(provider: :twilio, provider_call_id: call_sid)
end
def set_inbox!
@@ -19,16 +19,21 @@ class Voice::CallMessageBuilder
def perform!
validate_sender!
message = latest_message
message ? update_message!(message) : create_message!
existing = existing_message
existing ? update_message!(existing) : create_message!
end
private
attr_reader :conversation, :direction, :payload, :user, :timestamps
def latest_message
conversation.messages.voice_calls.order(created_at: :desc).first
def existing_message
sid = payload[:call_sid] || payload['call_sid']
return if sid.blank?
conversation.messages.voice_calls
.where("content_attributes -> 'data' ->> 'call_sid' = ?", sid)
.first
end
def update_message!(message)
@@ -37,6 +42,7 @@ class Voice::CallMessageBuilder
content_attributes: { 'data' => base_payload },
sender: sender
)
message
end
def create_message!
@@ -1,10 +1,9 @@
class Voice::CallSessionSyncService
attr_reader :conversation, :call_sid, :message_call_sid, :from_number, :to_number, :direction
attr_reader :call, :leg_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
def initialize(call:, leg:, leg_call_sid: nil)
@call = call
@leg_call_sid = leg_call_sid || call.provider_call_id
@from_number = leg[:from_number]
@to_number = leg[:to_number]
@direction = leg[:direction]
@@ -20,16 +19,18 @@ class Voice::CallSessionSyncService
sync_voice_call_message!(attrs)
end
conversation
call
end
private
delegate :conversation, to: :call
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['conference_sid'] ||= call.meta['conference_sid'] || Voice::Conference::Name.for(call)
attrs['meta'] ||= {}
attrs['meta']['initiated_at'] ||= current_timestamp
attrs
@@ -40,7 +41,7 @@ class Voice::CallSessionSyncService
conversation: conversation,
direction: direction,
payload: {
call_sid: message_call_sid,
call_sid: call.provider_call_id,
status: attrs['call_status'],
conference_sid: attrs['conference_sid'],
from_number: origin_number_for(direction),
@@ -1,13 +1,20 @@
class Voice::CallStatus::Manager
pattr_initialize [:conversation!, :call_sid]
pattr_initialize [:call!]
ALLOWED_STATUSES = %w[ringing in-progress completed no-answer failed].freeze
TERMINAL_STATUSES = %w[completed no-answer failed].freeze
# Map dashed statuses (Twilio-native / frontend) to underscored Call model statuses.
CALL_MODEL_STATUS = {
'ringing' => 'ringing',
'in-progress' => 'in_progress',
'completed' => 'completed',
'no-answer' => 'no_answer',
'failed' => 'failed'
}.freeze
def process_status_update(status, duration: nil, timestamp: nil)
return unless ALLOWED_STATUSES.include?(status)
current_status = conversation.additional_attributes&.dig('call_status')
return if current_status == status
apply_status(status, duration: duration, timestamp: timestamp)
@@ -16,6 +23,12 @@ class Voice::CallStatus::Manager
private
delegate :conversation, to: :call
def current_status
conversation.additional_attributes&.dig('call_status')
end
def apply_status(status, duration:, timestamp:)
attrs = (conversation.additional_attributes || {}).dup
attrs['call_status'] = status
@@ -31,6 +44,16 @@ class Voice::CallStatus::Manager
additional_attributes: attrs,
last_activity_at: current_time
)
persist_on_call!(status, attrs)
end
def persist_on_call!(status, attrs)
updates = { status: CALL_MODEL_STATUS[status] }
updates[:started_at] = Time.zone.at(attrs['call_started_at']) if status == 'in-progress' && attrs['call_started_at']
updates[:duration_seconds] = attrs['call_duration'] if TERMINAL_STATUSES.include?(status) && attrs['call_duration']
call.update!(updates)
end
def resolved_duration(attrs, provided_duration, timestamp)
@@ -43,10 +66,7 @@ class Voice::CallStatus::Manager
end
def update_message(status)
message = conversation.messages
.where(content_type: 'voice_call')
.order(created_at: :desc)
.first
message = call.message || fallback_message
return unless message
data = (message.content_attributes || {}).dup
@@ -56,6 +76,13 @@ class Voice::CallStatus::Manager
message.update!(content_attributes: data)
end
def fallback_message
conversation.messages.voice_calls
.where("content_attributes -> 'data' ->> 'call_sid' = ?", call.provider_call_id)
.order(created_at: :desc)
.first
end
def now_seconds
current_time.to_i
end
@@ -1,5 +1,5 @@
class Voice::Conference::Manager
pattr_initialize [:conversation!, :event!, :call_sid!, :participant_label]
pattr_initialize [:call!, :event!, :participant_label]
def process
case event
@@ -17,19 +17,21 @@ class Voice::Conference::Manager
private
delegate :conversation, to: :call
def status_manager
@status_manager ||= Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: call_sid
)
@status_manager ||= Voice::CallStatus::Manager.new(call: call)
end
def ensure_conference_sid!
attrs = conversation.additional_attributes || {}
return if attrs['conference_sid'].present?
name = Voice::Conference::Name.for(call)
call.update!(meta: call.meta.merge('conference_sid' => name)) if call.meta['conference_sid'].blank?
attrs['conference_sid'] = Voice::Conference::Name.for(conversation)
conversation.update!(additional_attributes: attrs)
conv_attrs = (conversation.additional_attributes || {}).dup
return if conv_attrs['conference_sid'].present?
conv_attrs['conference_sid'] = name
conversation.update!(additional_attributes: conv_attrs)
end
def mark_ringing!
@@ -1,5 +1,5 @@
module Voice::Conference::Name
def self.for(conversation)
"conf_account_#{conversation.account_id}_conv_#{conversation.display_id}"
def self.for(call)
"conf_account_#{call.account_id}_call_#{call.id}"
end
end
@@ -13,21 +13,32 @@ class Voice::InboundCallBuilder
end
def perform!
existing = find_existing_call
return existing.conversation if existing
timestamp = current_timestamp
ActiveRecord::Base.transaction do
contact = ensure_contact!
contact_inbox = ensure_contact_inbox!(contact)
conversation = find_conversation || create_conversation!(contact, contact_inbox)
conversation = find_open_conversation(contact_inbox) || create_conversation!(contact, contact_inbox)
conversation.reload
update_conversation!(conversation, timestamp)
build_voice_message!(conversation, timestamp)
call = create_call!(conversation, contact, timestamp)
message = build_voice_message!(conversation, call, timestamp)
call.update!(message_id: message.id)
denormalize_to_conversation!(conversation, call, timestamp)
conversation
end
end
private
def find_existing_call
account.calls.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,10 +54,10 @@ class Voice::InboundCallBuilder
end
end
def find_conversation
return if call_sid.blank?
def find_open_conversation(contact_inbox)
return unless inbox.lock_to_single_conversation
account.conversations.includes(:contact).find_by(identifier: call_sid)
contact_inbox.conversations.where.not(status: :resolved).order(created_at: :desc).first
end
def create_conversation!(contact, contact_inbox)
@@ -54,34 +65,33 @@ class Voice::InboundCallBuilder
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
def create_call!(conversation, contact, timestamp)
call = account.calls.create!(
inbox: inbox,
conversation: conversation,
contact: contact,
provider: :twilio,
direction: :incoming,
status: 'ringing',
provider_call_id: call_sid,
meta: { 'initiated_at' => timestamp }
)
call.update!(meta: call.meta.merge('conference_sid' => Voice::Conference::Name.for(call)))
call
end
def build_voice_message!(conversation, timestamp)
def build_voice_message!(conversation, call, timestamp)
Voice::CallMessageBuilder.perform!(
conversation: conversation,
direction: 'inbound',
payload: {
call_sid: call_sid,
call_sid: call.provider_call_id,
status: 'ringing',
conference_sid: conversation.additional_attributes['conference_sid'],
conference_sid: call.meta['conference_sid'],
from_number: from_number,
to_number: inbox.channel&.phone_number
},
@@ -89,6 +99,20 @@ class Voice::InboundCallBuilder
)
end
def denormalize_to_conversation!(conversation, call, timestamp)
attrs = (conversation.additional_attributes || {}).merge(
'call_direction' => 'inbound',
'call_status' => 'ringing',
'conference_sid' => call.meta['conference_sid'],
'meta' => { 'initiated_at' => timestamp }
)
conversation.update!(
additional_attributes: attrs,
last_activity_at: current_time
)
end
def current_timestamp
@current_timestamp ||= current_time.to_i
end
@@ -22,10 +22,13 @@ class Voice::OutboundCallBuilder
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)
call = create_call!(conversation, call_sid, timestamp)
message = build_voice_message!(conversation, call, timestamp)
call.update!(message_id: message.id)
denormalize_to_conversation!(conversation, call, timestamp)
{ conversation: conversation, call_sid: call_sid }
end
end
@@ -56,30 +59,30 @@ class Voice::OutboundCallBuilder
)[: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
def create_call!(conversation, call_sid, timestamp)
call = account.calls.create!(
inbox: inbox,
conversation: conversation,
contact: contact,
provider: :twilio,
direction: :outgoing,
status: 'ringing',
provider_call_id: call_sid,
accepted_by_agent_id: user.id,
meta: { 'initiated_at' => timestamp }
)
call.update!(meta: call.meta.merge('conference_sid' => Voice::Conference::Name.for(call)))
call
end
def build_voice_message!(conversation, call_sid, conference_sid, timestamp)
def build_voice_message!(conversation, call, timestamp)
Voice::CallMessageBuilder.perform!(
conversation: conversation,
direction: 'outbound',
payload: {
call_sid: call_sid,
call_sid: call.provider_call_id,
status: 'ringing',
conference_sid: conference_sid,
conference_sid: call.meta['conference_sid'],
from_number: inbox.channel&.phone_number,
to_number: contact.phone_number
},
@@ -88,6 +91,21 @@ class Voice::OutboundCallBuilder
)
end
def denormalize_to_conversation!(conversation, call, timestamp)
attrs = (conversation.additional_attributes || {}).merge(
'call_direction' => 'outbound',
'call_status' => 'ringing',
'agent_id' => user.id,
'conference_sid' => call.meta['conference_sid'],
'meta' => { 'initiated_at' => timestamp }
)
conversation.update!(
additional_attributes: attrs,
last_activity_at: current_time
)
end
def current_timestamp
@current_timestamp ||= current_time.to_i
end
@@ -1,17 +1,19 @@
class Voice::Provider::Twilio::ConferenceService
pattr_initialize [:conversation!, { twilio_client: nil }]
pattr_initialize [:call!, { twilio_client: nil }]
def ensure_conference_sid
existing = conversation.additional_attributes&.dig('conference_sid')
existing = call.meta['conference_sid']
return existing if existing.present?
sid = Voice::Conference::Name.for(conversation)
merge_attributes('conference_sid' => sid)
sid = Voice::Conference::Name.for(call)
call.update!(meta: call.meta.merge('conference_sid' => sid))
merge_conversation_attributes('conference_sid' => sid)
sid
end
def mark_agent_joined(user:)
merge_attributes(
call.update!(accepted_by_agent_id: user.id)
merge_conversation_attributes(
'agent_joined' => true,
'joined_at' => Time.current.to_i,
'joined_by' => { id: user.id, name: user.name }
@@ -21,13 +23,15 @@ class Voice::Provider::Twilio::ConferenceService
def end_conference
twilio_client
.conferences
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
.list(friendly_name: Voice::Conference::Name.for(call), status: 'in-progress')
.each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') }
end
private
def merge_attributes(attrs)
delegate :conversation, to: :call
def merge_conversation_attributes(attrs)
current = conversation.additional_attributes || {}
conversation.update!(additional_attributes: current.merge(attrs))
end
@@ -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 = account.calls.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