refactor: organize voice conference services

This commit is contained in:
Sojan Jose
2025-09-29 16:28:20 +05:30
parent cf7559fbd0
commit 2b6b2fe897
10 changed files with 119 additions and 107 deletions

View File

@@ -31,7 +31,7 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
end
conference_sid = conversation.additional_attributes&.dig('conference_sid')
if conference_sid.blank?
conference_sid = Voice::ConferenceSid.friendly_name(conversation)
conference_sid = Voice::Conference::Name.for(conversation)
conversation.update!(
additional_attributes:
(conversation.additional_attributes || {}).merge('conference_sid' => conference_sid)
@@ -59,7 +59,7 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
def conference_leave
conversation = fetch_conversation_by_display_id
# End the conference when an agent leaves from the app
Voice::ConferenceEndService.new(conversation: conversation).perform
Voice::Conference::EndService.new(conversation: conversation).perform
render json: { status: 'success', conversation_id: conversation.display_id }
rescue ActiveRecord::RecordNotFound => e
render json: { error: 'conversation_not_found', code: 'not_found', details: e.message }, status: :not_found

View File

@@ -56,7 +56,7 @@ class Twilio::VoiceController < ApplicationController
return render xml: fallback.to_s
end
conference_sid = Voice::ConferenceSid.friendly_name(conversation)
conference_sid = Voice::Conference::Name.for(conversation)
Rails.logger.info("TWILIO_VOICE_TWIML_CONFERENCE account=#{account.id} conference_sid=#{conference_sid}")
response = Twilio::TwiML::VoiceResponse.new
@@ -129,7 +129,7 @@ class Twilio::VoiceController < ApplicationController
end
end
Voice::ConferenceManagerService.new(
Voice::Conference::Manager.new(
conversation: conversation,
event: mapped,
call_sid: call_sid,

View File

@@ -41,7 +41,7 @@ class Voice::BaseCallBuilder
attrs = conversation.additional_attributes || {}
return if attrs['conference_sid'].present?
attrs['conference_sid'] = Voice::ConferenceSid.friendly_name(conversation)
attrs['conference_sid'] = Voice::Conference::Name.for(conversation)
conversation.update!(additional_attributes: attrs)
end

View File

@@ -29,7 +29,7 @@ class Voice::CallSessionSyncService
# We always persist the human-readable conference friendly name. Twilio's
# opaque ConferenceSid is stored separately (see controller callbacks) and
# is only used to correlate webhook payloads.
attrs['conference_sid'] = Voice::ConferenceSid.friendly_name(conversation)
attrs['conference_sid'] = Voice::Conference::Name.for(conversation)
conversation.update!(additional_attributes: attrs)
end
@@ -59,7 +59,7 @@ class Voice::CallSessionSyncService
conversation: conversation,
direction: current_direction,
call_sid: message_call_sid,
conference_sid: attrs['conference_sid'] || Voice::ConferenceSid.friendly_name(conversation),
conference_sid: attrs['conference_sid'] || Voice::Conference::Name.for(conversation),
from_number: origin_number_for(current_direction),
to_number: target_number_for(current_direction),
user: agent_for(attrs)

View File

@@ -0,0 +1,29 @@
module Voice
module Conference
class EndService
pattr_initialize [:conversation!]
def perform
name = Voice::Conference::Name.for(conversation)
cfg = conversation.inbox.channel.provider_config_hash
account_sid = cfg['account_sid']
auth_token = cfg['auth_token']
return if account_sid.blank? || auth_token.blank?
client = ::Twilio::REST::Client.new(account_sid, auth_token)
client.conferences.list(friendly_name: name, status: 'in-progress').each do |conf|
begin
client.conferences(conf.sid).update(status: 'completed')
rescue StandardError => e
Rails.logger.error("VOICE_CONFERENCE_END_UPDATE_ERROR conf=#{conf.sid} error=#{e.class}: #{e.message}")
end
end
rescue StandardError => e
Rails.logger.error(
"VOICE_CONFERENCE_END_ERROR account=#{conversation.account_id} conversation=#{conversation.display_id} name=#{name} error=#{e.class}: #{e.message}"
)
end
end
end
end

View File

@@ -0,0 +1,74 @@
module Voice
module Conference
class Manager
pattr_initialize [:conversation!, :event!, :call_sid!, :participant_label]
EVENT_HANDLERS = {
'start' => :handle_start,
'end' => :handle_end,
'join' => :handle_join,
'leave' => :handle_leave
}.freeze
END_STATUS = {
'in-progress' => 'completed',
'ringing' => 'no-answer'
}.freeze
def process
handler = EVENT_HANDLERS[event]
return unless handler
send(handler)
end
private
def call_status_manager
@call_status_manager ||= Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: call_sid,
provider: :twilio
)
end
def handle_start
return if %w[in-progress completed].include?(current_status)
call_status_manager.process_status_update('ringing')
end
def handle_end
target_status = END_STATUS[current_status] || 'completed'
call_status_manager.process_status_update(target_status)
end
def handle_join
return unless mark_in_progress?
call_status_manager.process_status_update('in-progress')
end
def handle_leave
return unless %w[in-progress ringing].include?(current_status)
next_status = current_status == 'ringing' ? 'no-answer' : 'completed'
call_status_manager.process_status_update(next_status)
return if conversation.additional_attributes['call_ended_at'].present?
Voice::Conference::EndService.new(conversation: conversation).perform
end
def current_status
conversation.additional_attributes['call_status']
end
def mark_in_progress?
conversation.additional_attributes['call_ended_at'].blank? &&
current_status == 'ringing' &&
participant_label.to_s.start_with?('agent')
end
end
end
end

View File

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

View File

@@ -1,25 +0,0 @@
module Voice
class ConferenceEndService
pattr_initialize [:conversation!]
def perform
# Compute conference friendly name from readable conversation ID
name = Voice::ConferenceSid.friendly_name(conversation)
cfg = conversation.inbox.channel.provider_config_hash
client = ::Twilio::REST::Client.new(cfg['account_sid'], cfg['auth_token'])
# Find all in-progress conferences matching this friendly name and end them
client.conferences.list(friendly_name: name, status: 'in-progress').each do |conf|
begin
client.conferences(conf.sid).update(status: 'completed')
rescue StandardError => e
Rails.logger.error("VOICE_CONFERENCE_END_UPDATE_ERROR conf=#{conf.sid} error=#{e.class}: #{e.message}")
end
end
rescue StandardError => e
Rails.logger.error(
"VOICE_CONFERENCE_END_ERROR account=#{conversation.account_id} conversation=#{conversation.display_id} name=#{name} error=#{e.class}: #{e.message}"
)
end
end
end

View File

@@ -1,68 +0,0 @@
module Voice
class ConferenceManagerService
pattr_initialize [:conversation!, :event!, :call_sid!, :participant_label]
def process
case event
when 'start'
handle_conference_start
when 'end'
handle_conference_end
when 'join'
handle_any_participant_join
when 'leave'
handle_any_participant_leave
end
end
private
def call_status_manager
@call_status_manager ||= Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: call_sid,
provider: :twilio
)
end
def handle_conference_start
current_status = conversation.additional_attributes['call_status']
return if %w[in-progress completed].include?(current_status)
call_status_manager.process_status_update('ringing')
end
def handle_conference_end
current_status = conversation.additional_attributes['call_status']
if current_status == 'in-progress'
call_status_manager.process_status_update('completed')
elsif current_status == 'ringing'
call_status_manager.process_status_update('no-answer')
else
call_status_manager.process_status_update('completed')
end
end
# Treat any participant join as moving to in-progress from ringing
def handle_any_participant_join
return if conversation.additional_attributes['call_ended_at'].present?
return unless conversation.additional_attributes['call_status'] == 'ringing'
return unless participant_label.to_s.start_with?('agent')
call_status_manager.process_status_update('in-progress')
end
# If a participant leaves while in-progress, mark completed; if still ringing, mark no-answer
def handle_any_participant_leave
status = conversation.additional_attributes['call_status']
if status == 'in-progress'
call_status_manager.process_status_update('completed')
elsif status == 'ringing'
call_status_manager.process_status_update('no-answer')
end
# Proactively end the conference when any participant leaves
Voice::ConferenceEndService.new(conversation: conversation).perform
end
end
end

View File

@@ -1,7 +0,0 @@
module Voice
module ConferenceSid
def self.friendly_name(conversation)
"conf_account_#{conversation.account_id}_conv_#{conversation.display_id}"
end
end
end