diff --git a/enterprise/app/controllers/api/v1/accounts/voice_controller.rb b/enterprise/app/controllers/api/v1/accounts/voice_controller.rb index 0903fd533..b562de2ab 100644 --- a/enterprise/app/controllers/api/v1/accounts/voice_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/voice_controller.rb @@ -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 diff --git a/enterprise/app/controllers/twilio/voice_controller.rb b/enterprise/app/controllers/twilio/voice_controller.rb index 57b5bf7dd..4841a6812 100644 --- a/enterprise/app/controllers/twilio/voice_controller.rb +++ b/enterprise/app/controllers/twilio/voice_controller.rb @@ -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, diff --git a/enterprise/app/services/voice/base_call_builder.rb b/enterprise/app/services/voice/base_call_builder.rb index 060731be3..abb15220b 100644 --- a/enterprise/app/services/voice/base_call_builder.rb +++ b/enterprise/app/services/voice/base_call_builder.rb @@ -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 diff --git a/enterprise/app/services/voice/call_session_sync_service.rb b/enterprise/app/services/voice/call_session_sync_service.rb index 2873eb093..b998e706a 100644 --- a/enterprise/app/services/voice/call_session_sync_service.rb +++ b/enterprise/app/services/voice/call_session_sync_service.rb @@ -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) diff --git a/enterprise/app/services/voice/conference/end_service.rb b/enterprise/app/services/voice/conference/end_service.rb new file mode 100644 index 000000000..b46d04721 --- /dev/null +++ b/enterprise/app/services/voice/conference/end_service.rb @@ -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 diff --git a/enterprise/app/services/voice/conference/manager.rb b/enterprise/app/services/voice/conference/manager.rb new file mode 100644 index 000000000..f763cae2d --- /dev/null +++ b/enterprise/app/services/voice/conference/manager.rb @@ -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 diff --git a/enterprise/app/services/voice/conference/name.rb b/enterprise/app/services/voice/conference/name.rb new file mode 100644 index 000000000..91cc3f4c4 --- /dev/null +++ b/enterprise/app/services/voice/conference/name.rb @@ -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 diff --git a/enterprise/app/services/voice/conference_end_service.rb b/enterprise/app/services/voice/conference_end_service.rb deleted file mode 100644 index c5aff9cde..000000000 --- a/enterprise/app/services/voice/conference_end_service.rb +++ /dev/null @@ -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 diff --git a/enterprise/app/services/voice/conference_manager_service.rb b/enterprise/app/services/voice/conference_manager_service.rb deleted file mode 100644 index fdacc619e..000000000 --- a/enterprise/app/services/voice/conference_manager_service.rb +++ /dev/null @@ -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 diff --git a/enterprise/app/services/voice/conference_sid.rb b/enterprise/app/services/voice/conference_sid.rb deleted file mode 100644 index c565bf132..000000000 --- a/enterprise/app/services/voice/conference_sid.rb +++ /dev/null @@ -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