From 2f7c8f6cfc7d575df45cdebc3ef0d2360c5a1191 Mon Sep 17 00:00:00 2001 From: Sojan Date: Sun, 4 May 2025 08:47:14 -0700 Subject: [PATCH] chore: Clean up some code andn simplify --- .../api/v1/accounts/voice_controller.rb | 117 ++--- app/controllers/twilio/voice_controller.rb | 87 ++-- .../conversation/contact/CallManager.vue | 107 ++--- app/jobs/call_transcription_job.rb | 191 -------- app/listeners/message_listener.rb | 6 +- app/services/voice/call_status/manager.rb | 395 ++++++++++++++++ .../voice/conference_manager_service.rb | 444 ++++++++++-------- .../voice/conference_status_service.rb | 299 ++++++------ .../voice/conference_status_update_service.rb | 17 - .../voice/conversation_finder_service.rb | 54 ++- app/services/voice/incoming_call_service.rb | 22 +- .../voice/message_delivery_service.rb | 59 --- app/services/voice/message_update_service.rb | 209 --------- app/services/voice/outgoing_call_service.rb | 12 +- app/services/voice/recording_service.rb | 91 ---- .../voice/twilio_call_status_service.rb | 61 --- .../voice/twilio_validator_service.rb | 114 ++--- 17 files changed, 987 insertions(+), 1298 deletions(-) delete mode 100644 app/jobs/call_transcription_job.rb create mode 100644 app/services/voice/call_status/manager.rb delete mode 100644 app/services/voice/conference_status_update_service.rb delete mode 100644 app/services/voice/message_delivery_service.rb delete mode 100644 app/services/voice/message_update_service.rb delete mode 100644 app/services/voice/recording_service.rb delete mode 100644 app/services/voice/twilio_call_status_service.rb diff --git a/app/controllers/api/v1/accounts/voice_controller.rb b/app/controllers/api/v1/accounts/voice_controller.rb index b31222ffb..93cd804f0 100644 --- a/app/controllers/api/v1/accounts/voice_controller.rb +++ b/app/controllers/api/v1/accounts/voice_controller.rb @@ -49,34 +49,16 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController if call.status == 'in-progress' || call.status == 'ringing' client.calls(call_sid).update(status: 'completed') - # Update conversation call status - @conversation.additional_attributes['call_status'] = 'completed' - @conversation.additional_attributes['call_ended_at'] = Time.now.to_i + # Update call status using the unified CallStatusManager + # The CallStatusManager will determine if the call is outbound internally + status_manager = Voice::CallStatus::Manager.new( + conversation: @conversation, + call_sid: call_sid, + provider: :twilio + ) + status_manager.process_status_update('completed') - # Calculate call duration if we have a start time - if @conversation.additional_attributes['call_started_at'] - @conversation.additional_attributes['call_duration'] = Time.now.to_i - @conversation.additional_attributes['call_started_at'].to_i - end - - # Update the voice call message status - if call_message = find_voice_call_message - content_attributes = call_message.content_attributes || {} - content_attributes['data'] ||= {} - content_attributes['data']['status'] = 'completed' - content_attributes['data']['status_updated'] = Time.now.to_i - content_attributes['data']['meta'] ||= {} - content_attributes['data']['meta']['completed_at'] = Time.now.to_i - content_attributes['data']['ended_at'] = Time.now.to_i - - # Add duration if available - if @conversation.additional_attributes['call_duration'] - content_attributes['data']['duration'] = @conversation.additional_attributes['call_duration'] - end - - call_message.update(content_attributes: content_attributes) - end - - @conversation.save! + # CallStatusManager handles all voice call message updates # Create an activity message noting the call has ended Messages::MessageBuilder.new( @@ -92,6 +74,21 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController } } ).perform + + # Broadcast call status update on the account channel + ActionCable.server.broadcast( + "account_#{@conversation.account_id}", + { + event_name: 'call_status_changed', + data: { + call_sid: call_sid, + status: 'completed', + conversation_id: @conversation.id, + inbox_id: @conversation.inbox_id, + timestamp: Time.now.to_i + } + } + ) render json: { status: 'success', message: 'Call successfully ended' } else @@ -138,7 +135,7 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController # Agent joining call via WebRTC - # Update conversation to show agent joined and set call status to active + # Update conversation to show agent joined @conversation.additional_attributes['agent_joined'] = true @conversation.additional_attributes['joined_at'] = Time.now.to_i @conversation.additional_attributes['joined_by'] = { @@ -146,24 +143,16 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController name: current_user.name } - # CRITICAL: Update call status to 'in-progress' to ensure UI updates properly - # This is especially important for incoming calls where the status might not get updated otherwise - @conversation.additional_attributes['call_status'] = 'in-progress' - - # Also record started_at timestamp if not already set - @conversation.additional_attributes['call_started_at'] = Time.now.to_i unless @conversation.additional_attributes['call_started_at'] - - # Update the call data in the voice call message - if call_message = find_voice_call_message - content_attributes = call_message.content_attributes || {} - content_attributes['data'] ||= {} - content_attributes['data']['status'] = 'in-progress' - content_attributes['data']['status_updated'] = Time.now.to_i - content_attributes['data']['meta'] ||= {} - content_attributes['data']['meta']['active_at'] = Time.now.to_i - call_message.update(content_attributes: content_attributes) - end + # Update call status using the unified CallStatusManager + # The CallStatusManager will determine if the call is outbound internally + status_manager = Voice::CallStatus::Manager.new( + conversation: @conversation, + call_sid: call_sid, + provider: :twilio + ) + status_manager.process_status_update('in-progress') + # Save the conversation with agent join details @conversation.save! # Create an activity message @@ -181,6 +170,21 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController } } ).perform + + # Broadcast call status update on the account channel + ActionCable.server.broadcast( + "account_#{@conversation.account_id}", + { + event_name: 'call_status_changed', + data: { + call_sid: call_sid, + status: 'in-progress', + conversation_id: @conversation.id, + inbox_id: @conversation.inbox_id, + timestamp: Time.now.to_i + } + } + ) # Return conference information for the WebRTC client with detailed logging response_data = { @@ -488,28 +492,7 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController @conversation = Current.account.conversations.find(params[:id] || params[:conversation_id]) end - # Helper method to find the voice call message for the current call - # Similar to the one in Voice::MessageUpdateService but simplified - def find_voice_call_message - return nil unless @conversation.present? - - # Try to find by call_sid first - if call_sid = params[:call_sid] || @conversation.additional_attributes&.dig('call_sid') - message = @conversation.messages - .where(content_type: 'voice_call') - .where("content_attributes->'data'->>'call_sid' = ?", call_sid) - .first - - # If found, return it - return message if message - end - - # Fall back to the most recent voice call message - @conversation.messages - .where(content_type: 'voice_call') - .order(created_at: :desc) - .first - end + # Voice call message related functionality is now handled by Voice::CallStatus::Manager # Helper method to get base URL with extra resilience def base_url diff --git a/app/controllers/twilio/voice_controller.rb b/app/controllers/twilio/voice_controller.rb index 1df26a8b9..ec9d14efa 100644 --- a/app/controllers/twilio/voice_controller.rb +++ b/app/controllers/twilio/voice_controller.rb @@ -72,59 +72,32 @@ class Twilio::VoiceController < ActionController::Base def handle_recording call_sid = params['CallSid'] - from_number = params['From'] - to_number = params['To'] recording_url = params['RecordingUrl'] recording_sid = params['RecordingSid'] - direction = params['Direction'] - - # Determine if outbound call - is_outbound = direction == 'outbound-api' - - # Find inbox and save recording if available - return unless recording_url.present? && call_sid.present? - - inbox_number = is_outbound ? from_number : to_number - inbox = find_inbox(inbox_number) - - return unless inbox.present? - - contact_number = is_outbound ? to_number : from_number - conversation = find_or_create_conversation(inbox, contact_number, call_sid) - - # Process the recording using RecordingService - begin - Voice::RecordingService.new( - conversation: conversation, - recording_url: recording_url, - recording_sid: recording_sid, - call_sid: call_sid - ).process - - # End the call after a single recording - response = Twilio::TwiML::VoiceResponse.new do |r| - r.say(message: 'Thank you for your feedback. Goodbye.') - r.hangup - end - render xml: response.to_s, status: :ok - rescue StandardError => e - # Log the error but don't crash - Rails.logger.error("Error processing recording: #{e.message}") - - # Return a simple TwiML in case of error - response = Twilio::TwiML::VoiceResponse.new do |r| - r.say(message: 'We encountered an issue processing your feedback. Goodbye.') - r.hangup - end - render xml: response.to_s, status: :ok + + # Log the recording information for future implementation + Rails.logger.info("Recording received: CallSid=#{call_sid}, RecordingSid=#{recording_sid}") + Rails.logger.info("Recording URL: #{recording_url}") + + # Recording functionality has been removed for now + # In the future, we'll implement this to save call recordings and attach them to conversations + + # Return a simple response to end the call + response = Twilio::TwiML::VoiceResponse.new do |r| + r.say(message: 'Thank you for your feedback. Goodbye.') + r.hangup end + render xml: response.to_s, status: :ok end def transcription_callback - # Process the transcription asynchronously + # Logging transcription details for future implementation if params['CallSid'].present? - # Queue the processing as a background job - CallTranscriptionJob.perform_later(params.permit!.to_h) + Rails.logger.info("Transcription received for CallSid=#{params['CallSid']}") + Rails.logger.info("Transcription text: #{params['TranscriptionText']}") + + # Transcription functionality has been removed for now + # In the future, we'll implement this to save call transcriptions and attach them to conversations end # Return an empty TwiML response to satisfy Twilio @@ -165,14 +138,13 @@ class Twilio::VoiceController < ActionController::Base inbox: inbox ).perform - # Use TwilioCallStatusService to handle status update - Voice::TwilioCallStatusService.new( + # Use the unified CallStatusManager to handle status update + # The CallStatusManager will determine if the call is outbound internally + Voice::CallStatus::Manager.new( conversation: conversation, call_sid: call_sid, - call_status: call_status, - is_outbound: is_outbound, - duration: duration - ).process(params['IsFirstResponseForStatus'] == 'true') + provider: :twilio + ).process_status_update(call_status, duration, params['IsFirstResponseForStatus'] == 'true') # Return an empty response head :ok @@ -214,14 +186,13 @@ class Twilio::VoiceController < ActionController::Base inbox: inbox ).perform - # Add call activity message - Voice::TwilioCallStatusService.new( + # Add call activity message using unified CallStatusManager + # The CallStatusManager will determine if the call is outbound internally + Voice::CallStatus::Manager.new( conversation: conversation, call_sid: call_sid, - call_status: 'in-progress', - is_outbound: is_outbound, - duration: nil - ).process(true) + provider: :twilio + ).process_status_update('in-progress', nil, true) # IMPORTANT: Use the provided conference_name if available, otherwise use the one from conversation if conference_name_param.present? diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/CallManager.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/CallManager.vue index 3c89db364..d5c7c4eee 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/contact/CallManager.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/CallManager.vue @@ -142,35 +142,22 @@ export default { if (!props.conversation) return; try { - // Set up ActionCable to listen for call status changes - if (accountId.value && props.conversation?.inbox_id) { - const roomName = `${accountId.value}_${props.conversation.inbox_id}`; - console.log( - `Setting up ActionCable listener for call status in room: ${roomName}` - ); - - // Setup ActionCable connection and handler for call status events - actionCableService.createConsumer(); - actionCableService.addRoom(roomName); - - const handleCallStatusChanged = ({ event_name, data }) => { - // Only handle call_status_changed events - if (event_name !== 'call_status_changed') return; - - console.log('Received call status change via ActionCable:', data); - - // Only update if it's for our current call - if (data.call_sid === callSid.value) { - console.log( - `Updating call status from ${callStatus.value} to ${data.status}` - ); - updateCallStatus(data.status); - + // The actionCable events are already set up in App.vue + // We don't need to create or add any rooms - we'll just watch the calls store + + // Set up watch on the calls store to react to status changes + const callStateUnwatch = store.watch( + state => state.calls?.activeCall?.status, + newStatus => { + if (newStatus && callSid.value) { + console.log(`Call status changed in store: ${newStatus}`); + updateCallStatus(newStatus); + // If call is completed, refresh the conversation to get any recordings - if (data.status === 'completed' || data.status === 'canceled') { + if (newStatus === 'completed' || newStatus === 'canceled' || newStatus === 'missed') { // Notify parent component that call has ended emit('callEnded'); - + // Refresh the conversation to get updated messages with recordings if (props.conversation?.id) { store.dispatch('fetchConversation', { @@ -179,18 +166,23 @@ export default { } } } - }; - - // Register for events - actionCableService.onReceivedMessage = handleCallStatusChanged; - } - - // Also set up store watcher as backup method - if ( - store.state.conversations && - store.state.conversations.conversations - ) { - const unwatch = store.watch( + } + ); + + // Clean up the watcher when component is unmounted + onBeforeUnmount(() => { + if (callStateUnwatch) { + callStateUnwatch(); + } + }); + } catch (error) { + console.error('Error setting up call status watcher:', error); + } + + try { + // Set up message watcher to update UI elements related to recordings and transcriptions + if (store.state.conversations && store.state.conversations.conversations) { + const messageUnwatch = store.watch( state => { if (!props.conversation || !props.conversation.id) return null; const conversations = state.conversations?.conversations || {}; @@ -201,24 +193,17 @@ export default { if (!messages) return; try { - // Check for call status messages + // Check for recording URL updates in call messages const callMessage = messages.find( message => - message.message_type === 10 && // activity message - message.additional_attributes && - message.additional_attributes.call_sid === callSid.value + message.content_type === 'voice_call' && + message.content_attributes?.data?.call_sid === callSid.value ); - if (callMessage?.additional_attributes) { - if (callMessage.additional_attributes.call_status) { - updateCallStatus( - callMessage.additional_attributes.call_status - ); - } - - if (callMessage.additional_attributes.recording_url) { - recordingUrl.value = - callMessage.additional_attributes.recording_url; + if (callMessage?.content_attributes?.data) { + // Update UI if recording URL is available + if (callMessage.content_attributes.data.recording_url) { + recordingUrl.value = callMessage.content_attributes.data.recording_url; } } @@ -226,8 +211,7 @@ export default { const transcriptionMessage = messages.find( message => message.message_type === 0 && // incoming message - message.additional_attributes && - message.additional_attributes.is_transcription + message.additional_attributes?.is_transcription ); if (transcriptionMessage) { @@ -241,8 +225,8 @@ export default { // Clean up the watcher when component is unmounted onBeforeUnmount(() => { - if (unwatch) { - unwatch(); + if (messageUnwatch) { + messageUnwatch(); } }); } else { @@ -268,16 +252,7 @@ export default { onBeforeUnmount(() => { stopDurationTimer(); - - // Clean up the ActionCable connection - if (accountId.value && props.conversation?.inbox_id) { - try { - const roomName = `${accountId.value}_${props.conversation.inbox_id}`; - actionCableService.removeRoom(roomName); - } catch (error) { - console.error('Error cleaning up ActionCable:', error); - } - } + // No need to clean up ActionCable connection as we're not using room-specific subscriptions }); return { diff --git a/app/jobs/call_transcription_job.rb b/app/jobs/call_transcription_job.rb deleted file mode 100644 index 5e120aeda..000000000 --- a/app/jobs/call_transcription_job.rb +++ /dev/null @@ -1,191 +0,0 @@ -class CallTranscriptionJob < ApplicationJob - queue_as :low - - def perform(params) - call_sid = params['CallSid'] - recording_url = params['RecordingUrl'] - recording_sid = params['RecordingSid'] - to_number = params['To'] - from_number = params['From'] - direction = params['Direction'] - call_status = params['CallStatus'] - - # Determine if this is an outbound call - is_outbound = direction == 'outbound-api' - - # Find the inbox for this voice call based on the direction - # For outbound calls, the "From" is the Chatwoot number - # For inbound calls, the "To" is the Chatwoot number - channel_number = is_outbound ? from_number : to_number - - inbox = Inbox.joins("INNER JOIN channel_voice ON channel_voice.account_id = inboxes.account_id AND inboxes.channel_id = channel_voice.id") - .where("channel_voice.phone_number = ?", channel_number) - .first - - return if inbox.nil? - - account = inbox.account - - # For outbound calls, the contact is the person being called (To) - # For inbound calls, the contact is the caller (From) - contact_number = is_outbound ? to_number : from_number - - # Find or create the contact - contact = account.contacts.find_or_create_by(phone_number: contact_number) do |c| - c.name = is_outbound ? "Customer at #{contact_number}" : "Caller from #{contact_number}" - end - - # Find or create the contact inbox - contact_inbox = ContactInbox.find_or_initialize_by( - contact_id: contact.id, - inbox_id: inbox.id - ) - - # Set the source_id if it's a new record - if contact_inbox.new_record? - contact_inbox.source_id = from_number - end - - contact_inbox.save! - - # Find or create a conversation for this call - conversation = account.conversations.find_or_create_by( - contact_inbox_id: contact_inbox.id, - inbox_id: inbox.id - ) do |conv| - conv.status = :open - end - - # If the conversation was auto-resolved, reopen it - if conversation.resolved? - conversation.toggle_status - end - - # If we have a transcription, add it to an existing message or create a new one - if params['TranscriptionText'].present? - # Look for a recent message from this recording - recording_message = conversation.messages - .where("additional_attributes @> ?", { recording_sid: recording_sid }.to_json) - .order(created_at: :desc) - .first - - if recording_message - # Update existing message with transcription - recording_message.content = params['TranscriptionText'] - recording_message.additional_attributes[:transcription] = params['TranscriptionText'] - recording_message.save! - else - # Create a new message with the transcription - message_params = { - content: params['TranscriptionText'], - message_type: :incoming, - additional_attributes: { - is_transcription: true, - call_sid: call_sid, - recording_sid: recording_sid, - recording_url: recording_url - }, - source_id: "transcription_#{recording_sid}" - } - - message = Messages::MessageBuilder.new(contact, conversation, message_params).perform - - # Attach the recording as an attachment if it's a valid Twilio recording URL - if recording_url.present? && recording_url.include?('/Recordings/') && recording_sid.present? - begin - # Get authentication details from the channel config to access the recording - config = inbox.channel.provider_config_hash - account_sid = config['account_sid'] - auth_token = config['auth_token'] - - # Create an authenticated URL that includes auth details - # Format for MP3 playback - recording_mp3_url = "#{recording_url}.mp3" - - attachment_params = { - file_type: :audio, - account_id: inbox.account_id, - external_url: recording_mp3_url, - fallback_title: 'Voice recording', - meta: { - recording_sid: recording_sid, - twilio_account_sid: account_sid, - auth_required: true, - transcription: params['TranscriptionText'] - } - } - attachment = message.attachments.new(attachment_params) - attachment.save! - Rails.logger.info("Successfully attached voice recording from #{recording_url}") - rescue StandardError => e - Rails.logger.error("Error attaching voice recording: #{e.message}") - - # Try with a more general file type if audio fails - begin - fallback_attachment = message.attachments.new( - file_type: :file, - account_id: inbox.account_id, - external_url: recording_mp3_url, - fallback_title: 'Voice Recording (.mp3)', - meta: { - recording_sid: recording_sid, - twilio_account_sid: account_sid, - auth_required: true, - transcription: params['TranscriptionText'] - } - ) - fallback_attachment.save! - rescue StandardError => e - Rails.logger.error("Failed to create fallback attachment: #{e.message}") - end - end - else - Rails.logger.error("Invalid Twilio recording URL format or missing SID: #{recording_url}") - end - end - end - - # Create or update call status information - call_info_message = conversation.messages - .where("additional_attributes @> ?", { call_sid: call_sid, message_type: 'activity' }.to_json) - .first - - message_attributes = { - call_sid: call_sid, - call_status: params['CallStatus'] || 'in-progress' - } - - # Add recording details if available - if recording_url.present? - message_attributes[:recordings_count] = (call_info_message&.additional_attributes&.dig(:recordings_count) || 0) + 1 - message_attributes[:latest_recording_url] = recording_url - message_attributes[:latest_recording_sid] = recording_sid - message_attributes[:call_duration] = params['RecordingDuration'] - end - - if call_info_message.present? - # Update the existing call info message - call_info_message.additional_attributes.merge!(message_attributes) - call_info_message.save! - else - # Create a new call info message - message_params = { - content: 'Voice call in progress', - message_type: :activity, - additional_attributes: message_attributes, - source_id: "voice_call_#{call_sid}" - } - - Messages::MessageBuilder.new(nil, conversation, message_params).perform - end - - # Notify the frontend about the update - ActionCableBroadcastJob.perform_later( - conversation.account_id, - 'conversation.updated', - conversation.push_event_data.merge( - status: conversation.status - ) - ) - end -end \ No newline at end of file diff --git a/app/listeners/message_listener.rb b/app/listeners/message_listener.rb index f46f8ea49..50f686996 100644 --- a/app/listeners/message_listener.rb +++ b/app/listeners/message_listener.rb @@ -3,10 +3,8 @@ class MessageListener < BaseListener message = extract_message_and_account(event)[0] return if message.nil? - if message.conversation.inbox.channel.class.name == 'Channel::Voice' - # Deliver the message to voice call if it's a voice channel - Voice::MessageDeliveryService.new(message).perform - end + # Voice message delivery functionality has been removed for now + # This would be where we'd handle delivering agent messages to voice calls end private diff --git a/app/services/voice/call_status/manager.rb b/app/services/voice/call_status/manager.rb new file mode 100644 index 000000000..2b479410c --- /dev/null +++ b/app/services/voice/call_status/manager.rb @@ -0,0 +1,395 @@ +module Voice + module CallStatus + # CallStatusManager is the centralized service for managing all voice call statuses + # It handles updating conversation attributes, message attributes, and broadcasting updates. + # All status changes for voice calls should flow through this service to ensure consistency. + # + # This service replaces the older TwilioCallStatusService and MessageUpdateService, + # centralizing all voice call status management in one place with consistent behavior. + # + # Key responsibilities: + # 1. Tracking call status transitions (initiated → ringing → in-progress → completed) + # 2. Updating conversation additional_attributes with call metadata + # 3. Updating voice call message content_attributes with matching status + # 4. Creating appropriate activity messages for status changes + # 5. Broadcasting status changes via ActionCable + # 6. Determining call direction (inbound vs outbound) + # 7. Providing provider-specific messaging templates + # + # Usage: + # status_manager = Voice::CallStatus::Manager.new( + # conversation: conversation, + # call_sid: 'CA123456789', + # provider: :twilio + # ) + # + # # Process a status update (recommended way to update status) + # status_manager.process_status_update('completed', 120) # Status with duration in seconds + # + # # Create an activity message + # status_manager.create_activity_message('Call ended by agent') + # + # # Check if call is outbound + # is_outbound = status_manager.is_outbound? + class Manager + # Constructor parameters: + # - conversation: The conversation associated with the call + # - call_sid: The external ID for the call from the provider + # - provider: Symbol representing the provider (e.g., :twilio) + pattr_initialize [:conversation!, :call_sid, :provider] + + # Valid call statuses with their transitions + VALID_STATUSES = %w[initiated ringing in-progress active completed missed busy failed no-answer canceled].freeze + + # Terminal statuses that indicate the call has ended + TERMINAL_STATUSES = %w[completed missed busy failed no-answer canceled].freeze + + # Map external status names to our internal statuses + STATUS_MAPPING = { + # Twilio statuses + 'queued' => 'initiated', + 'initiated' => 'initiated', + 'ringing' => 'ringing', + 'in-progress' => 'in-progress', + 'completed' => 'completed', + 'busy' => 'busy', + 'failed' => 'failed', + 'no-answer' => 'no-answer', + 'canceled' => 'canceled', + + # Internal/UI statuses + 'active' => 'in-progress', + 'ended' => 'completed' + }.freeze + + # Provider-specific message templates for different call statuses + PROVIDER_MESSAGES = { + twilio: { + 'initiated' => { outbound: 'Outbound call initiated', inbound: 'Initiating call' }, + 'ringing' => { outbound: 'Phone ringing', inbound: 'Phone ringing' }, + 'in-progress' => { + outbound: { first: 'Call connected', next: 'Call in progress' }, + inbound: { first: 'Call answered', next: 'Call in progress' } + }, + 'completed' => { outbound: 'Call completed', inbound: 'Call completed' }, + 'busy' => { outbound: 'Call busy', inbound: 'Call busy' }, + 'failed' => { outbound: 'Call failed', inbound: 'Call failed' }, + 'no-answer' => { outbound: 'Call not answered', inbound: 'Call not answered' }, + 'canceled' => { outbound: 'Call canceled', inbound: 'Call canceled' } + } + }.freeze + + # Create a custom activity message + # This provides a clean migration path from MessageUpdateService + def create_activity_message(content, additional_attributes = {}) + Messages::MessageBuilder.new( + nil, + conversation, + { + content: content, + message_type: :activity, + additional_attributes: additional_attributes + } + ).perform + end + + # Process a call status update from any provider (e.g., Twilio, Vonage) + # This is the primary method that should be used to update call statuses. + # It handles different provider statuses, calculates duration, updates conversation + # and message attributes, and creates appropriate activity messages. + # + # @param status [String] The status from the provider (e.g., 'completed', 'ringing') + # @param duration [Integer, nil] The call duration in seconds (if available) + # @param is_first_response [Boolean] Whether this is the first status update for this status + # @return [Boolean] Whether the update was processed successfully + def process_status_update(status, duration = nil, is_first_response = false) + # Skip if no changes needed to avoid duplicate processing + # Unless this is marked as the first response, which we should always process + prev_status = conversation.additional_attributes&.dig('call_status') + if !is_first_response && prev_status == status + Rails.logger.info("🔄 [CallStatusManager] Skipping duplicate status update: '#{status}'") + return true + end + + # Normalize status using the STATUS_MAPPING if present + normalized_status = STATUS_MAPPING[status] || status + + # Calculate call duration automatically if not provided and call is ending + if duration.nil? && call_ended?(normalized_status) && conversation.additional_attributes['call_started_at'] + calculated_duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i + Rails.logger.info("⏱️ [CallStatusManager] Calculated call duration: #{calculated_duration} seconds") + duration = calculated_duration + end + + # Update conversation and message status in a database transaction + result = update_status(normalized_status, duration) + + # Create an activity message with provider-specific text if status was updated + create_provider_activity_message(normalized_status, is_first_response) if result + + result + end + + # Determine if a call is outbound based on conversation attributes + # This is the centralized method that should be used throughout the application + # to determine call direction instead of passing around the is_outbound flag. + # + # @return [Boolean] true if the call is outbound, false otherwise + def is_outbound? + # Strategy 1: Check the call_direction attribute (most reliable) + direction = conversation.additional_attributes['call_direction'] + return direction == 'outbound' if direction.present? + + # Strategy 2: Check for requires_agent_join flag (set for outbound calls) + # When an agent initiates an outbound call, this flag is set to true + # This helps us identify outbound calls even if call_direction is not set + return true if conversation.additional_attributes['requires_agent_join'] == true + + # Strategy 3: Check for other outbound indicators + # e.g., check for call_type or other attributes that might indicate direction + call_type = conversation.additional_attributes['call_type'] + return true if call_type == 'outbound' + + # Default to inbound if we can't determine + # Most calls are inbound, so this is a reasonable default + false + end + + # Generate provider-specific activity messages (e.g., for Twilio) + def create_provider_activity_message(status, is_first_response = false) + provider_key = provider&.to_sym + call_direction = is_outbound? ? :outbound : :inbound + + # Default message in case we can't find a provider-specific one + message = "Call status: #{status}" + + if provider_key && PROVIDER_MESSAGES.key?(provider_key) + messages = PROVIDER_MESSAGES[provider_key] + + if status == 'in-progress' && messages.dig(status, call_direction).is_a?(Hash) + message_type = is_first_response ? :first : :next + provider_message = messages.dig(status, call_direction, message_type) + message = provider_message if provider_message + elsif messages.dig(status, call_direction) + message = messages.dig(status, call_direction) + end + end + + # Create the activity message + create_activity_message(message, { + call_sid: call_sid, + call_status: status + }) + end + + # Update call status in a single operation + # This ensures conversation and message statuses are in sync + def update_status(status, duration = nil) + # Map external status to internal status + internal_status = STATUS_MAPPING[status] || status + + Rails.logger.info("🔄 [CallStatusManager] Updating call status for conversation #{conversation.id}: '#{internal_status}'") + + # Validate status + unless VALID_STATUSES.include?(internal_status) + Rails.logger.error("❌ [CallStatusManager] Invalid status: #{internal_status}") + return false + end + + # Get current status + current_status = conversation.additional_attributes['call_status'] + + # Only update if status is changing or we're forcing an update + if current_status == internal_status + Rails.logger.info("🔄 [CallStatusManager] Status unchanged: '#{internal_status}'") + return true + end + + # Log the status transition + Rails.logger.info("🔄 [CallStatusManager] Status transition: '#{current_status}' -> '#{internal_status}'") + + ActiveRecord::Base.transaction do + # Update conversation additional_attributes + update_conversation_status(internal_status, duration) + + # Update message content_attributes + update_message_status(internal_status, duration) + + # Create activity message for status change if it's a significant change + create_status_activity_message(internal_status) if should_create_activity_message?(internal_status) + + # Broadcast status change notification + broadcast_status_change(internal_status) + end + + true + rescue StandardError => e + Rails.logger.error("❌ [CallStatusManager] Error updating status: #{e.message}") + Rails.logger.error(e.backtrace.first(5).join("\n")) + false + end + + def call_ended?(status) + TERMINAL_STATUSES.include?(status) + end + + private + + def update_conversation_status(status, duration) + # Update the status + conversation.additional_attributes ||= {} + conversation.additional_attributes['call_status'] = status + + # Update timestamps and metadata based on status + if %w[in-progress active].include?(status) + # Record the start time if not already set + conversation.additional_attributes['call_started_at'] = Time.now.to_i unless conversation.additional_attributes['call_started_at'] + + # Ensure we have call meta data + conversation.additional_attributes['meta'] ||= {} + conversation.additional_attributes['meta']['active_at'] = Time.now.to_i + elsif call_ended?(status) + # Record end time + conversation.additional_attributes['call_ended_at'] = Time.now.to_i + + # Calculate and record duration + if duration + conversation.additional_attributes['call_duration'] = duration + elsif conversation.additional_attributes['call_started_at'] + conversation.additional_attributes['call_duration'] = + Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i + end + + # Add call end metadata + conversation.additional_attributes['meta'] ||= {} + conversation.additional_attributes['meta']["#{status}_at"] = Time.now.to_i + end + + # Save the conversation + conversation.save! + end + + def update_message_status(status, duration) + message = find_voice_call_message + return unless message + + # Determine best message status value based on conversation status + message_status = status + if status == 'in-progress' + message_status = 'active' + elsif call_ended?(status) + message_status = 'ended' + end + + # Get current content attributes, initialize if needed + content_attributes = message.content_attributes || {} + content_attributes['data'] ||= {} + + # Update fields + content_attributes['data']['status'] = message_status + content_attributes['data']['duration'] = duration if duration + content_attributes['data']['meta'] ||= {} + content_attributes['data']['meta']["#{message_status}_at"] = Time.now.to_i + content_attributes['data']['updated_at'] = Time.now.to_i + + # Add a flag to force the UI to refresh + content_attributes['data']['status_updated'] = Time.now.to_i + + # Save the message + message.update!(content_attributes: content_attributes) + end + + def find_voice_call_message + # First try to find by call_sid + message = nil + + if call_sid.present? + # Try to find by exact call_sid match + message = conversation.messages + .where(content_type: 'voice_call') + .where("content_attributes->'data'->>'call_sid' = ?", call_sid) + .first + end + + # If not found, try by looking for a call_sid that contains our call_sid (Twilio sometimes sends partial SIDs) + if message.nil? && call_sid.present? + # Look for messages where call_sid is a substring + messages = conversation.messages + .where(content_type: 'voice_call') + .order(created_at: :desc) + + # Manually check for partial matches in content_attributes + message = messages.find do |msg| + stored_call_sid = msg.content_attributes.dig('data', 'call_sid') + stored_call_sid.present? && (stored_call_sid.include?(call_sid) || call_sid.include?(stored_call_sid)) + end + end + + # If still not found, get the most recent voice call message + if message.nil? + message = conversation.messages + .where(content_type: 'voice_call') + .order(created_at: :desc) + .first + end + + message + end + + def should_create_activity_message?(status) + # Only create activity messages for significant state changes + # Avoid creating too many messages for intermediate states + call_ended?(status) || status == 'in-progress' + end + + def create_status_activity_message(status) + content = if call_ended?(status) + case status + when 'missed' + 'Call was not answered' + when 'busy' + 'Line was busy' + when 'failed' + 'Call failed' + when 'no-answer' + 'No answer' + when 'canceled' + 'Call was canceled' + else + 'Call ended' + end + elsif status == 'in-progress' + 'Call in progress' + else + "Call status changed to #{status}" + end + + # Use the public create_activity_message method + create_activity_message(content, { + call_sid: call_sid, + call_status: status + }) + end + + def broadcast_status_change(status) + # Broadcast to the account-wide channel + Rails.logger.info("📢 [CallStatusManager] Broadcasting status change: '#{status}' for conversation_id=#{conversation.id}") + + # Use account-level channel for maximum compatibility + ActionCable.server.broadcast( + "account_#{conversation.account_id}", + { + event_name: 'call_status_changed', + data: { + call_sid: call_sid, + status: status, + conversation_id: conversation.id, + inbox_id: conversation.inbox_id, + timestamp: Time.now.to_i + } + } + ) + end + end + end +end diff --git a/app/services/voice/conference_manager_service.rb b/app/services/voice/conference_manager_service.rb index 3bb259cf9..feb524550 100644 --- a/app/services/voice/conference_manager_service.rb +++ b/app/services/voice/conference_manager_service.rb @@ -1,18 +1,35 @@ module Voice + # Handles conference events (start, end, participant joins and leaves) + # Uses CallStatusManager to update call statuses, ensuring consistency + # This service is called directly by ConferenceStatusService class ConferenceManagerService pattr_initialize [:conversation!, :event!, :call_sid!, :conference_sid, :participant_sid, :participant_label] + # Event constants to make code more readable + CONFERENCE_START = 'conference-start'.freeze + CONFERENCE_END = 'conference-end'.freeze + PARTICIPANT_JOIN = 'participant-join'.freeze + PARTICIPANT_LEAVE = 'participant-leave'.freeze + + # Participant types + AGENT = 'agent'.freeze + CALLER = 'caller'.freeze + def process + Rails.logger.info("🎧 CONFERENCE EVENT: #{event} for conference_sid=#{conference_sid}") + # Process the conference event case event - when 'conference-start' + when CONFERENCE_START handle_conference_start - when 'conference-end' + when CONFERENCE_END handle_conference_end - when 'participant-join' + when PARTICIPANT_JOIN handle_participant_join - when 'participant-leave' + when PARTICIPANT_LEAVE handle_participant_leave + else + Rails.logger.warn("🎧 UNKNOWN CONFERENCE EVENT: #{event}") end # Create activity message for the event @@ -24,199 +41,228 @@ module Voice private - def message_service - @message_service ||= Voice::MessageUpdateService.new( + def call_status_manager + # Use the CallStatusManager, which will determine internally if the call is outbound + @call_status_manager ||= Voice::CallStatus::Manager.new( conversation: conversation, - call_sid: call_sid + call_sid: call_sid, + provider: :twilio ) end def handle_conference_start - conversation.additional_attributes ||= {} - conversation.additional_attributes['conference_status'] = 'started' - conversation.additional_attributes['conference_started_at'] = Time.now.to_i - - # Log conference start - Rails.logger.info("🎧 CONFERENCE STARTED: conference_sid=#{conference_sid}") - - # Update call status to ringing if not already in a more advanced state + # Update conference status + update_conference_status('started') + + # Check if we need to update call status current_status = conversation.additional_attributes['call_status'] - Rails.logger.info("📞 CURRENT CALL STATUS: '#{current_status}'") - - if !%w[active in-progress completed].include?(current_status) - Rails.logger.info("📞 UPDATING CALL TO RINGING ON CONFERENCE START") - message_service.update_call_status('ringing') - message_service.update_voice_call_status('ringing') - - # Ensure we have metadata for debugging - conversation.additional_attributes['meta'] ||= {} - conversation.additional_attributes['meta']['ringing_at'] = Time.now.to_i - conversation.additional_attributes['meta']['conference_started_at'] = Time.now.to_i - conversation.save! - end + + # Only update to ringing if not already in a more advanced state + return if %w[active in-progress completed].include?(current_status) + + Rails.logger.info('📞 UPDATING CALL TO RINGING ON CONFERENCE START') + call_status_manager.process_status_update('ringing') end def handle_conference_end - conversation.additional_attributes ||= {} - conversation.additional_attributes['conference_status'] = 'ended' - conversation.additional_attributes['conference_ended_at'] = Time.now.to_i - - # Log conference end - Rails.logger.info("🎧 CONFERENCE ENDED: conference_sid=#{conference_sid}") - - # Determine the final call status based on the current state + # Update conference status + update_conference_status('ended') + + # Get current call status current_status = conversation.additional_attributes['call_status'] - Rails.logger.info("📞 CURRENT CALL STATUS AT CONFERENCE END: '#{current_status}'") - - if current_status == 'active' || current_status == 'in-progress' - # Call was active, mark as completed - duration = nil - if conversation.additional_attributes['call_started_at'] - duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i - Rails.logger.info("⏱️ CALCULATED CALL DURATION: #{duration} seconds") - end - - Rails.logger.info("📞 MARKING ACTIVE CALL AS COMPLETED") - message_service.update_call_status('completed', duration) - message_service.update_voice_call_status('ended', duration) - elsif current_status == 'ringing' - # Call never connected, mark as missed - Rails.logger.info("📞 MARKING RINGING CALL AS MISSED") - message_service.update_call_status('missed') - message_service.update_voice_call_status('missed') - else - # Default to completed status - Rails.logger.info("📞 MARKING CALL AS COMPLETED (DEFAULT)") - message_service.update_call_status('completed') - message_service.update_voice_call_status('ended') - end - - # Ensure metadata is updated - conversation.additional_attributes['meta'] ||= {} - conversation.additional_attributes['meta']['conference_ended_at'] = Time.now.to_i - - # Force update UI to show the change - ActionCable.server.broadcast( - "#{conversation.account_id}_#{conversation.inbox_id}", - { - event_name: 'call_status_changed', - data: { - call_sid: call_sid, - status: conversation.additional_attributes['call_status'], - conversation_id: conversation.id, - force_refresh: true - } - } - ) - - Rails.logger.info("📢 BROADCAST: Sent conference end notification") + + # Determine final call status based on current state + finalize_call_status(current_status) end def handle_participant_join - # Track the participant - update_participant_info('joined') - - # Log the participant joining - Rails.logger.info("👥 PARTICIPANT JOINED: #{participant_label || 'unknown'} (#{participant_sid})") - - # Store participant join time based on type - if participant_label&.start_with?('agent') - conversation.additional_attributes['agent_joined_at'] = Time.now.to_i - Rails.logger.info("👤 AGENT JOINED AT: #{Time.now.to_i}") - - # If call is ringing when agent joins, mark as active - if conversation.additional_attributes['call_status'] == 'ringing' - Rails.logger.info("📞 UPDATING RINGING CALL TO ACTIVE (agent joined)") - message_service.update_call_status('active') - message_service.update_voice_call_status('active') - end - elsif participant_label&.start_with?('caller') - conversation.additional_attributes['caller_joined_at'] = Time.now.to_i - Rails.logger.info("👤 CALLER JOINED AT: #{Time.now.to_i}") - - # For outbound calls - if conversation.additional_attributes['call_direction'] == 'outbound' - # Mark call as active as soon as caller joins an outbound call - # This ensures the call doesn't get stuck in ringing - if conversation.additional_attributes['call_status'] == 'ringing' - Rails.logger.info("📞 UPDATING RINGING OUTBOUND CALL TO ACTIVE (caller joined)") - message_service.update_call_status('active') - message_service.update_voice_call_status('active') - end - end + # Track participant join + track_participant_join + + # Handle call status updates based on who joined + if agent_participant? + handle_agent_join + elsif caller_participant? + handle_caller_join else - # Generic participant (no label) - Rails.logger.info("👤 GENERIC PARTICIPANT JOINED") - - # If we're stuck in ringing, try to move forward - if conversation.additional_attributes['call_status'] == 'ringing' && - (Time.now.to_i - conversation.additional_attributes.dig('meta', 'ringing_at').to_i > 10) - Rails.logger.info("📞 UPDATING LONG-RINGING CALL TO ACTIVE (participant joined)") - message_service.update_call_status('active') - message_service.update_voice_call_status('active') - end - end - - # Check if both caller and agent have joined - if conversation.additional_attributes['agent_joined_at'] && - conversation.additional_attributes['caller_joined_at'] - # Ensure call is marked as active when both parties are present - if conversation.additional_attributes['call_status'] != 'active' - Rails.logger.info("📞 UPDATING CALL STATUS TO ACTIVE (both parties present)") - message_service.update_call_status('active') - message_service.update_voice_call_status('active') - end + handle_generic_participant_join end + + # Check if both parties are present to mark call as active + check_both_parties_present end def handle_participant_leave - # Update participant tracking - update_participant_info('left') - - # Record leave time based on participant type - if participant_label&.start_with?('agent') - conversation.additional_attributes['agent_left_at'] = Time.now.to_i - elsif participant_label&.start_with?('caller') - conversation.additional_attributes['caller_left_at'] = Time.now.to_i - end - - # Handle caller leaving during ringing phase - if participant_label&.start_with?('caller') && - conversation.additional_attributes['call_status'] == 'ringing' - - # Check if any agent has joined - has_agent_joined = participant_has_joined?('agent') - - unless has_agent_joined - message_service.update_call_status('missed') - message_service.update_voice_call_status('missed') - end - end - - # Handle case where all participants have left but conference is still active - if all_participants_left? && - conversation.additional_attributes['conference_status'] != 'ended' && - conversation.additional_attributes['call_status'] == 'active' - - # Calculate duration if we can - duration = nil - if conversation.additional_attributes['call_started_at'] - duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i - end - - message_service.update_call_status('completed', duration) - message_service.update_voice_call_status('ended', duration) + # Track participant leave + track_participant_leave + + # Handle missed calls when caller leaves during ringing + check_for_missed_call + + # Check if everyone left to end conference + check_if_everyone_left + end + + # Helper methods for updating conference status + def update_conference_status(status) + conversation.additional_attributes ||= {} + conversation.additional_attributes['conference_status'] = status + conversation.additional_attributes["conference_#{status}_at"] = Time.now.to_i + + # Update metadata + conversation.additional_attributes['meta'] ||= {} + conversation.additional_attributes['meta']["conference_#{status}_at"] = Time.now.to_i + + Rails.logger.info("🎧 CONFERENCE #{status.upcase}: conference_sid=#{conference_sid}") + end + + # Helper methods for finalizing call status + def finalize_call_status(current_status) + if %w[active in-progress].include?(current_status) + # Call was active, mark as completed with duration + complete_active_call + elsif current_status == 'ringing' + # Call never connected + Rails.logger.info('📞 MARKING RINGING CALL AS MISSED') + call_status_manager.process_status_update('missed') + else + # Default to completed status + Rails.logger.info('📞 MARKING CALL AS COMPLETED (DEFAULT)') + call_status_manager.process_status_update('completed') end end + def complete_active_call + # Calculate duration if possible + duration = nil + if conversation.additional_attributes['call_started_at'] + duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i + Rails.logger.info("⏱️ CALCULATED CALL DURATION: #{duration} seconds") + end + + Rails.logger.info('📞 MARKING ACTIVE CALL AS COMPLETED') + call_status_manager.process_status_update('completed', duration) + end + + # Helper methods for participant handling + def track_participant_join + # Update participant tracking + update_participant_info('joined') + Rails.logger.info("👥 PARTICIPANT JOINED: #{participant_label || 'unknown'} (#{participant_sid})") + end + + def track_participant_leave + # Update participant tracking + update_participant_info('left') + Rails.logger.info("👥 PARTICIPANT LEFT: #{participant_label || 'unknown'} (#{participant_sid})") + + # Record leave time based on participant type + if agent_participant? + conversation.additional_attributes['agent_left_at'] = Time.now.to_i + elsif caller_participant? + conversation.additional_attributes['caller_left_at'] = Time.now.to_i + end + end + + # Participant type checks + def agent_participant? + participant_label&.start_with?(AGENT) + end + + def caller_participant? + participant_label&.start_with?(CALLER) + end + + # Participant join handlers + def handle_agent_join + conversation.additional_attributes['agent_joined_at'] = Time.now.to_i + Rails.logger.info("👤 AGENT JOINED AT: #{Time.now.to_i}") + + # If call is ringing when agent joins, mark as active + return unless conversation.additional_attributes['call_status'] == 'ringing' + + Rails.logger.info('📞 UPDATING RINGING CALL TO ACTIVE (agent joined)') + call_status_manager.process_status_update('active') + end + + def handle_caller_join + conversation.additional_attributes['caller_joined_at'] = Time.now.to_i + Rails.logger.info("👤 CALLER JOINED AT: #{Time.now.to_i}") + + # For outbound calls - mark as active when caller joins if still ringing + return unless outbound_call? && ringing_call? + + Rails.logger.info('📞 UPDATING RINGING OUTBOUND CALL TO ACTIVE (caller joined)') + call_status_manager.process_status_update('active') + end + + def handle_generic_participant_join + Rails.logger.info('👤 GENERIC PARTICIPANT JOINED') + + # If we're stuck in ringing for a while, try to move forward + return unless ringing_call? && long_ringing? + + Rails.logger.info('📞 UPDATING LONG-RINGING CALL TO ACTIVE (participant joined)') + call_status_manager.process_status_update('active') + end + + # Call state checks + def outbound_call? + conversation.additional_attributes['call_direction'] == 'outbound' + end + + def ringing_call? + conversation.additional_attributes['call_status'] == 'ringing' + end + + def long_ringing? + ringing_at = conversation.additional_attributes.dig('meta', 'ringing_at').to_i + ringing_at > 0 && (Time.now.to_i - ringing_at > 10) + end + + # Both parties present check + def check_both_parties_present + both_present = conversation.additional_attributes['agent_joined_at'] && + conversation.additional_attributes['caller_joined_at'] + + return unless both_present && conversation.additional_attributes['call_status'] != 'active' + + Rails.logger.info('📞 UPDATING CALL STATUS TO ACTIVE (both parties present)') + call_status_manager.process_status_update('active') + end + + # Missed call check when caller leaves + def check_for_missed_call + return unless caller_participant? && ringing_call? && !participant_has_joined?(AGENT) + + Rails.logger.info('📞 MARKING AS MISSED (caller left during ringing, no agent joined)') + call_status_manager.process_status_update('missed') + end + + # Everyone left check + def check_if_everyone_left + call_active = conversation.additional_attributes['call_status'] == 'active' + conference_active = conversation.additional_attributes['conference_status'] != 'ended' + + return unless all_participants_left? && conference_active && call_active + + # Calculate duration if possible + duration = nil + duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i if conversation.additional_attributes['call_started_at'] + + Rails.logger.info('📞 MARKING CALL AS COMPLETED (all participants left)') + call_status_manager.process_status_update('completed', duration) + end + + # Participant tracking methods def update_participant_info(status) # Initialize participants tracking conversation.additional_attributes ||= {} conversation.additional_attributes['participants'] ||= {} - + # Determine participant type from label - participant_type = participant_label&.start_with?('agent') ? 'agent' : 'caller' - + participant_type = agent_participant? ? AGENT : CALLER + if status == 'joined' # Add or update participant conversation.additional_attributes['participants'][participant_sid] = { @@ -234,41 +280,39 @@ module Voice def participant_has_joined?(type) participants = conversation.additional_attributes['participants'] || {} - - if participants.is_a?(Hash) - return participants.values.any? { |p| p['type'] == type && p['status'] == 'joined' } - end - - false + return false unless participants.is_a?(Hash) + + participants.values.any? { |p| p['type'] == type && p['status'] == 'joined' } end def all_participants_left? participants = conversation.additional_attributes['participants'] || {} - - if participants.is_a?(Hash) - return !participants.values.any? { |p| p['status'] == 'joined' } - end - - true # Default to true if no participants structure exists + return true unless participants.is_a?(Hash) + + !participants.values.any? { |p| p['status'] == 'joined' } end + # Activity message creation def create_activity_message - content = case event - when 'conference-start' - 'Conference started' - when 'conference-end' - 'Conference ended' - when 'participant-join' - participant_type = participant_label&.start_with?('agent') ? 'Agent' : 'Caller' - "#{participant_type} joined the call" - when 'participant-leave' - participant_type = participant_label&.start_with?('agent') ? 'Agent' : 'Caller' - "#{participant_type} left the call" - else - "Call event: #{event}" - end - - message_service.create_activity_message(content) + content = activity_message_content_for_event + call_status_manager.create_activity_message(content) + end + + def activity_message_content_for_event + case event + when CONFERENCE_START + 'Conference started' + when CONFERENCE_END + 'Conference ended' + when PARTICIPANT_JOIN + participant_type = agent_participant? ? 'Agent' : 'Caller' + "#{participant_type} joined the call" + when PARTICIPANT_LEAVE + participant_type = agent_participant? ? 'Agent' : 'Caller' + "#{participant_type} left the call" + else + "Call event: #{event}" + end end end -end \ No newline at end of file +end diff --git a/app/services/voice/conference_status_service.rb b/app/services/voice/conference_status_service.rb index 32765fe3f..cc46f0856 100644 --- a/app/services/voice/conference_status_service.rb +++ b/app/services/voice/conference_status_service.rb @@ -1,216 +1,187 @@ module Voice + # Handles Twilio conference status webhook callbacks + # Normalizes events, finds the relevant conversation, + # and delegates processing to ConferenceManagerService class ConferenceStatusService pattr_initialize [:account!, :params!] + + # Map of Twilio event names to our normalized format + EVENT_MAPPING = { + # Join events + 'participant-join' => 'participant-join', + 'participantjoin' => 'participant-join', + 'participantjoined' => 'participant-join', + 'participant_join' => 'participant-join', + + # Leave events + 'participant-leave' => 'participant-leave', + 'participantleave' => 'participant-leave', + 'participantleft' => 'participant-leave', + 'participant_leave' => 'participant-leave', + + # Conference start events + 'conference-start' => 'conference-start', + 'conferencestart' => 'conference-start', + 'conferencestarted' => 'conference-start', + 'conference_start' => 'conference-start', + + # Conference end events + 'conference-end' => 'conference-end', + 'conferenceend' => 'conference-end', + 'conferenceended' => 'conference-end', + 'conference_end' => 'conference-end' + }.freeze def process - # Log all incoming parameters for debugging - Rails.logger.info("🎤 CONFERENCE STATUS PARAMS: #{params.to_unsafe_h.except('controller', 'action').to_json}") + # Log incoming parameters + Rails.logger.info("🎤 CONFERENCE STATUS: #{params['StatusCallbackEvent']}") - find_conversation - queue_status_processing if @conversation + # Extract status info and find conversation + info = status_info + conversation = find_conversation(info) + + return unless conversation + + # Update participant tracking info + update_participant_info(conversation, info) + + # Process the event with the conference manager + Rails.logger.info("📊 PROCESSING EVENT: #{info[:event]}") + + Voice::ConferenceManagerService.new( + conversation: conversation, + event: info[:event], + call_sid: info[:call_sid], + conference_sid: info[:conference_sid], + participant_sid: info[:participant_sid], + participant_label: info[:participant_label] + ).process end def status_info - # Normalize the event name to match our expected format + # Get and normalize the event name raw_event = params['StatusCallbackEvent'] - - # Log the raw event to help with debugging - Rails.logger.info("🎧 RAW EVENT RECEIVED: '#{raw_event}'") - - # Convert Twilio's event formats to our standardized kebab-case - normalized_event = if raw_event.present? - # Clean up the string first - convert to lowercase and remove spaces - event_text = raw_event.downcase.gsub(/\s+/, '') - - # Handle all possible format variations from Twilio - case event_text - # Participant join events (camelCase, kebab-case, no dash) - when 'participant-join', 'participantjoin', 'participantjoined', 'participantjoin', 'participant_join' - 'participant-join' - - # Participant leave events (camelCase, kebab-case, no dash) - when 'participant-leave', 'participantleave', 'participantleft', 'participantleave', 'participant_leave' - 'participant-leave' - - # Conference start events (camelCase, kebab-case, no dash) - when 'conference-start', 'conferencestart', 'conferencestarted', 'conferencestart', 'conference_start' - 'conference-start' - - # Conference end events (camelCase, kebab-case, no dash) - when 'conference-end', 'conferenceend', 'conferenceended', 'conferenceend', 'conference_end' - 'conference-end' - - # Add other Twilio event variations if needed - - # For any other event, standardize to kebab-case - else - # Convert camelCase to kebab-case - kebab_case = event_text.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase - # Convert snake_case to kebab-case - kebab_case = kebab_case.gsub('_', '-') - # Remove any extra dashes - kebab_case = kebab_case.gsub(/--+/, '-') - kebab_case - end - else - # Default if no event is provided - 'unknown' - end - - Rails.logger.info("🎧 NORMALIZED EVENT: '#{raw_event}' -> '#{normalized_event}'") + normalized_event = normalize_event_name(raw_event) { call_sid: params['CallSid'], conference_sid: params['ConferenceSid'], event: normalized_event, participant_sid: params['ParticipantSid'], - participant_label: params['ParticipantLabel'], - call_sid_ending_with: params['CallSidEndingWith'], - audio_level: params['AudioLevel'] + participant_label: params['ParticipantLabel'] } end private + + def normalize_event_name(raw_event) + return 'unknown' unless raw_event.present? + + event_text = raw_event.downcase.gsub(/\s+/, '') + + # Look up in mapping + EVENT_MAPPING[event_text] || event_text.gsub(/([a-z\d])([A-Z])/, '\1-\2').gsub('_', '-').gsub(/--+/, '-') + end - def find_conversation - @conversation = nil - - # Try finding by conference_sid - if status_info[:conference_sid].present? - @conversation = account.conversations - .where("additional_attributes->>'conference_sid' = ?", status_info[:conference_sid]) - .first - - Rails.logger.info("🔍 SEARCHING BY CONFERENCE_SID: #{status_info[:conference_sid]}") if @conversation.nil? + def find_conversation(info) + # Try by conference_sid first + if info[:conference_sid].present? + conversation = find_by_conference_sid(info[:conference_sid]) + return conversation if conversation end - - # If not found and conference_sid looks like our format, extract conversation ID - if @conversation.nil? && status_info[:conference_sid].present? && status_info[:conference_sid].start_with?('conf_account_') - conference_parts = status_info[:conference_sid].match(/conf_account_\d+_conv_(\d+)/) + + # Try by call_sid if available + if info[:call_sid].present? + conversation = account.conversations.where("additional_attributes->>'call_sid' = ?", info[:call_sid]).first + return conversation if conversation + end + + Rails.logger.error("❌ CONVERSATION NOT FOUND for event: #{info[:event]}") + nil + end + + def find_by_conference_sid(conference_sid) + # Direct match by conference_sid + conversation = account.conversations.where("additional_attributes->>'conference_sid' = ?", conference_sid).first + return conversation if conversation + + # Try pattern matching if it looks like our format + if conference_sid.start_with?('conf_account_') + conference_parts = conference_sid.match(/conf_account_\d+_conv_(\d+)/) if conference_parts && conference_parts[1].present? conversation_display_id = conference_parts[1] - @conversation = account.conversations.find_by(display_id: conversation_display_id) - Rails.logger.info("🎧 Found conversation by display_id=#{conversation_display_id} from conference_sid=#{status_info[:conference_sid]}") + return account.conversations.find_by(display_id: conversation_display_id) end end - - # If still not found, try by call_sid - if @conversation.nil? && status_info[:call_sid].present? - @conversation = account.conversations - .where("additional_attributes->>'call_sid' = ?", status_info[:call_sid]) - .first - - Rails.logger.info("🔍 SEARCHING BY CALL_SID: #{status_info[:call_sid]}") if @conversation.nil? - end - if @conversation - Rails.logger.info("✅ FOUND CONVERSATION: id=#{@conversation.id} display_id=#{@conversation.display_id}") - else - Rails.logger.error("❌ CONVERSATION NOT FOUND for call_sid=#{status_info[:call_sid]} conference_sid=#{status_info[:conference_sid]}") - return - end - - # Update participant info if conversation found - update_participant_info if @conversation + nil end - def update_participant_info - # Initialize or get current participants list - @conversation.additional_attributes ||= {} - @conversation.additional_attributes['participants'] ||= {} + def update_participant_info(conversation, info) + # Skip if no participant info + return unless info[:participant_sid].present? - participant_sid = status_info[:participant_sid] - return unless participant_sid.present? + # Initialize tracking + conversation.additional_attributes ||= {} + conversation.additional_attributes['participants'] ||= {} - # Log the received event - Rails.logger.info("👥 PARTICIPANT EVENT: #{status_info[:event]} for #{participant_sid} (#{status_info[:participant_label]})") + participant_sid = info[:participant_sid] # Update based on event type - case status_info[:event] + case info[:event] when 'participant-join' - # Add participant - @conversation.additional_attributes['participants'][participant_sid] = { - 'joined_at' => Time.now.to_i, - 'type' => status_info[:participant_label]&.start_with?('agent') ? 'agent' : 'caller', - 'call_sid' => status_info[:call_sid], - 'status' => 'joined' - } - - # Flag outbound calls that need agent join if this is a caller - if @conversation.additional_attributes['call_direction'] == 'outbound' && - status_info[:participant_label]&.start_with?('caller-') - # This is the customer joining an outbound call - flag for agent to join immediately - @conversation.additional_attributes['requires_agent_join'] = true - # Broadcast an immediate "incoming call" notification for the agent - broadcast_agent_join_notification - end + track_participant_join(conversation, participant_sid, info) when 'participant-leave' - # Only update if the participant is in the list - if @conversation.additional_attributes['participants'].key?(participant_sid) - @conversation.additional_attributes['participants'][participant_sid]['status'] = 'left' - @conversation.additional_attributes['participants'][participant_sid]['left_at'] = Time.now.to_i - end + track_participant_leave(conversation, participant_sid) end - # Save the updated conversation - @conversation.save! + conversation.save! + end + + def track_participant_join(conversation, participant_sid, info) + # Add participant + conversation.additional_attributes['participants'][participant_sid] = { + 'joined_at' => Time.now.to_i, + 'type' => info[:participant_label]&.start_with?('agent') ? 'agent' : 'caller', + 'call_sid' => info[:call_sid], + 'status' => 'joined' + } + + # Handle outbound calls where caller has joined + if conversation.additional_attributes['call_direction'] == 'outbound' && + info[:participant_label]&.start_with?('caller-') + conversation.additional_attributes['requires_agent_join'] = true + broadcast_agent_notification(conversation, info) + end + end + + def track_participant_leave(conversation, participant_sid) + if conversation.additional_attributes['participants'].key?(participant_sid) + conversation.additional_attributes['participants'][participant_sid]['status'] = 'left' + conversation.additional_attributes['participants'][participant_sid]['left_at'] = Time.now.to_i + end end - def broadcast_agent_join_notification - # Get the contact, ensuring we still have one - contact = @conversation.contact - unless contact - # If contact is missing, try to find or create one based on info available - # This shouldn't normally happen but is a safeguard - phone_numbers = @conversation.messages.where(content_type: 'voice_call') - .map { |m| m.content_attributes.dig('data', 'to_number') }.compact.first - - if phone_numbers - contact = account.contacts.find_or_create_by(phone_number: phone_numbers) do |c| - c.name = "Contact from #{phone_numbers}" - end - # Update conversation with the new contact - @conversation.update(contact_id: contact.id) - else - # If we can't find a phone number, create a generic contact - contact = account.contacts.create!(phone_number: "unknown-#{Time.now.to_i}") - @conversation.update(contact_id: contact.id) - end - end - + def broadcast_agent_notification(conversation, info) + contact = conversation.contact + ActionCable.server.broadcast( "account_#{account.id}", { event: 'incoming_call', data: { - call_sid: status_info[:call_sid], - conversation_id: @conversation.id, - inbox_id: @conversation.inbox_id, - inbox_name: @conversation.inbox.name, - contact_name: contact.name || 'Outbound Call', - contact_id: contact.id, + call_sid: info[:call_sid], + conversation_id: conversation.id, + inbox_id: conversation.inbox_id, + inbox_name: conversation.inbox.name, + contact_name: contact&.name || 'Outbound Call', + contact_id: contact&.id, is_outbound: true, account_id: account.id, - conference_sid: status_info[:conference_sid] + conference_sid: info[:conference_sid] } } ) - - Rails.logger.info("📣 BROADCAST: Sent agent join notification") - end - - def queue_status_processing - # Process the status update directly using the service - Rails.logger.info("📊 PROCESSING CONFERENCE EVENT: #{status_info[:event]}") - - Voice::ConferenceStatusUpdateService.new( - conversation: @conversation, - event: status_info[:event], - call_sid: status_info[:call_sid], - conference_sid: status_info[:conference_sid], - participant_sid: status_info[:participant_sid], - participant_label: status_info[:participant_label] - ).process end end end \ No newline at end of file diff --git a/app/services/voice/conference_status_update_service.rb b/app/services/voice/conference_status_update_service.rb deleted file mode 100644 index 96bcf2cbf..000000000 --- a/app/services/voice/conference_status_update_service.rb +++ /dev/null @@ -1,17 +0,0 @@ -module Voice - class ConferenceStatusUpdateService - pattr_initialize [:conversation!, :event!, :call_sid!, :conference_sid, :participant_sid, :participant_label] - - def process - # Use the ConferenceManagerService to handle all conference events - Voice::ConferenceManagerService.new( - conversation: conversation, - event: event, - call_sid: call_sid, - conference_sid: conference_sid, - participant_sid: participant_sid, - participant_label: participant_label - ).process - end - end -end \ No newline at end of file diff --git a/app/services/voice/conversation_finder_service.rb b/app/services/voice/conversation_finder_service.rb index 5a29629ae..e424d333f 100644 --- a/app/services/voice/conversation_finder_service.rb +++ b/app/services/voice/conversation_finder_service.rb @@ -1,54 +1,54 @@ module Voice class ConversationFinderService pattr_initialize [:account!, :phone_number!, :inbox, :call_sid, :is_outbound] - + def perform # Ensure we have a phone number validate_and_normalize_phone_number - + # First try to find existing conversation by call_sid if available conversation = find_by_call_sid if call_sid.present? return conversation if conversation - + # If not found, create a new conversation create_new_conversation end - + private - + def validate_and_normalize_phone_number # Simple validation to ensure we have something to work with - raise "Phone number cannot be blank" if phone_number.blank? - + raise 'Phone number cannot be blank' if phone_number.blank? + # Normalize the phone number (strip any whitespace) @phone_number = phone_number.strip end - + def find_by_call_sid account.conversations.where("additional_attributes->>'call_sid' = ?", call_sid).first end - + def find_or_create_contact # Always find or create a contact based on the phone number account.contacts.find_or_create_by!(phone_number: phone_number) do |c| c.name = "Contact from #{phone_number}" end end - + def create_new_conversation # First ensure we have a contact contact = find_or_create_contact - + # Find or initialize the contact inbox contact_inbox = ContactInbox.find_or_initialize_by( - contact_id: contact.id, + contact_id: contact.id, inbox_id: inbox.id ) - + # Set source_id if not set - needed for properly mapping the conversation contact_inbox.source_id ||= phone_number contact_inbox.save! - + # Create the conversation conversation = account.conversations.create!( contact_inbox_id: contact_inbox.id, @@ -57,37 +57,47 @@ module Voice status: :open, additional_attributes: initial_attributes ) - + # Add conference_sid to attributes conference_name = generate_conference_name(conversation) conversation.additional_attributes['conference_sid'] = conference_name conversation.save! - + conversation end - + def initial_attributes attributes = { 'call_status' => 'in-progress', 'call_initiated_at' => Time.now.to_i } - + # Add call_sid if available attributes['call_sid'] = call_sid if call_sid.present? - + + # Set call direction based on is_outbound flag if is_outbound attributes['call_direction'] = 'outbound' attributes['call_type'] = 'outbound' + + # For outbound calls, set the requires_agent_join flag + # This is used by the CallStatusManager to identify outbound calls + attributes['requires_agent_join'] = true else attributes['call_direction'] = 'inbound' attributes['call_type'] = 'inbound' end - + + # Add metadata for tracking important timestamps + attributes['meta'] = { + 'initiated_at' => Time.now.to_i + } + attributes end - + def generate_conference_name(conversation) "conf_account_#{account.id}_conv_#{conversation.display_id}" end end -end \ No newline at end of file +end diff --git a/app/services/voice/incoming_call_service.rb b/app/services/voice/incoming_call_service.rb index 073732634..85a0ad5b3 100644 --- a/app/services/voice/incoming_call_service.rb +++ b/app/services/voice/incoming_call_service.rb @@ -142,14 +142,20 @@ module Voice # Create activity message separately after the voice call message def create_activity_message - activity_message = Messages::MessageBuilder.new( - nil, - @conversation, - { - content: "Incoming call from #{@contact.name.presence || caller_info[:from_number]}", - message_type: :activity - } - ).perform + # Use CallStatusManager for consistency + status_manager = Voice::CallStatus::Manager.new( + conversation: @conversation, + call_sid: caller_info[:call_sid], + provider: :twilio + ) + + # First process ringing status + status_manager.process_status_update('ringing', nil, true) + + # Then add a custom message about the incoming call + activity_message = status_manager.create_activity_message( + "Incoming call from #{@contact.name.presence || caller_info[:from_number]}" + ) Rails.logger.info("📝 ACTIVITY MESSAGE: id=#{activity_message.id}") end diff --git a/app/services/voice/message_delivery_service.rb b/app/services/voice/message_delivery_service.rb deleted file mode 100644 index 960bbbda7..000000000 --- a/app/services/voice/message_delivery_service.rb +++ /dev/null @@ -1,59 +0,0 @@ -module Voice - class MessageDeliveryService - # This service will handle delivering messages from agents to active voice calls - # For now we'll store the messages in Redis with the call_sid as the key - # This way the TwiML controller can retrieve and read them out to the caller - - attr_reader :message, :conversation - - def initialize(message) - @message = message - @conversation = message.conversation - end - - def perform - return unless should_deliver_message? - - call_sid = conversation.additional_attributes&.dig('call_sid') - return unless call_sid.present? - - # Store the message in Redis to be read out in the next TwiML request - redis_key = "voice_message:#{call_sid}" - - # Add the message to a Redis list - Redis::Alfred.lpush(redis_key, { - content: message.content, - message_id: message.id, - delivered: false - }.to_json) - - # Set expiration so we don't keep messages forever - Redis::Alfred.expire(redis_key, 1.hour.to_i) - - # Update the message with delivery status - update_message_delivery_status - end - - private - - def should_deliver_message? - # Only deliver outgoing messages (from agents) - return false unless message.outgoing? - - # Only deliver text messages, not attachments etc - return false unless message.content.present? - - # Only deliver to active voice calls - return false unless conversation.additional_attributes&.dig('call_sid').present? - return false unless conversation.additional_attributes&.dig('call_status') == 'in-progress' - - true - end - - def update_message_delivery_status - additional_attributes = message.additional_attributes || {} - additional_attributes[:voice_delivery_status] = 'queued' - message.update(additional_attributes: additional_attributes) - end - end -end \ No newline at end of file diff --git a/app/services/voice/message_update_service.rb b/app/services/voice/message_update_service.rb deleted file mode 100644 index 6b6ef0ae1..000000000 --- a/app/services/voice/message_update_service.rb +++ /dev/null @@ -1,209 +0,0 @@ -module Voice - class MessageUpdateService - pattr_initialize [:conversation!, :call_sid] - - def update_voice_call_status(status, duration = nil) - message = find_voice_call_message - return unless message - - # Log message found for debugging - Rails.logger.info("📱 UPDATE VOICE CALL STATUS: Found message: #{message.id}, updating status: #{status}") - - # Get current content attributes, initialize if needed - content_attributes = message.content_attributes || {} - content_attributes['data'] ||= {} - - # Log previous status - previous_status = content_attributes['data']['status'] - Rails.logger.info("📱 PREVIOUS STATUS: #{previous_status} -> NEW STATUS: #{status}") - - # Update fields - content_attributes['data']['status'] = status - content_attributes['data']['duration'] = duration if duration - content_attributes['data']['meta'] ||= {} - content_attributes['data']['meta']["#{status}_at"] = Time.now.to_i - content_attributes['data']['updated_at'] = Time.now.to_i - - # Add a flag to force the UI to refresh - content_attributes['data']['status_updated'] = Time.now.to_i - - # Save the message with a rescue to ensure we get error details if it fails - begin - result = message.update(content_attributes: content_attributes) - if result - Rails.logger.info("✅ VOICE CALL STATUS UPDATED: Message #{message.id} status: #{status}") - else - Rails.logger.error("❌ VOICE CALL STATUS UPDATE FAILED: #{message.errors.full_messages.join(', ')}") - end - rescue StandardError => e - Rails.logger.error("❌ VOICE CALL STATUS UPDATE ERROR: #{e.message}") - Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..3].join("\n")}") - end - - message - end - - def find_voice_call_message - # First try to find by call_sid - message = nil - - if call_sid.present? - # Try to find by exact call_sid match - Rails.logger.info("🔍 SEARCHING FOR VOICE CALL MESSAGE BY CALL_SID: #{call_sid}") - message = conversation.messages - .where(content_type: 'voice_call') - .where("content_attributes->'data'->>'call_sid' = ?", call_sid) - .first - end - - # If not found, try by looking for a call_sid that contains our call_sid (Twilio sometimes sends partial SIDs) - if message.nil? && call_sid.present? - Rails.logger.info("🔍 SEARCHING FOR VOICE CALL MESSAGE BY PARTIAL CALL_SID MATCH: #{call_sid}") - # Look for messages where call_sid is a substring - last_few_chars = call_sid.last(8) - messages = conversation.messages - .where(content_type: 'voice_call') - .order(created_at: :desc) - - # Manually check for partial matches in content_attributes - message = messages.find do |msg| - stored_call_sid = msg.content_attributes.dig('data', 'call_sid') - stored_call_sid.present? && (stored_call_sid.include?(call_sid) || call_sid.include?(stored_call_sid)) - end - end - - # If still not found, get the most recent voice call message - if message.nil? - Rails.logger.info('🔍 USING MOST RECENT VOICE CALL MESSAGE AS FALLBACK') - message = conversation.messages - .where(content_type: 'voice_call') - .order(created_at: :desc) - .first - end - - if message - Rails.logger.info("✅ FOUND VOICE CALL MESSAGE: #{message.id}") - else - Rails.logger.error("❌ NO VOICE CALL MESSAGE FOUND FOR CONVERSATION: #{conversation.id}") - end - - message - end - - def create_activity_message(content) - # Create a simple activity message without additional attributes - Messages::MessageBuilder.new( - nil, - conversation, - { - content: content, - message_type: :activity - } - ).perform - end - - def update_call_status(status, duration = nil) - # Update conversation attributes - conversation.additional_attributes ||= {} - - # Only update if status is changing - previous_status = conversation.additional_attributes['call_status'] - if previous_status == status - Rails.logger.info("🔄 CALL STATUS UNCHANGED: Already in state '#{status}', no update needed") - return - end - - # Log the status change - Rails.logger.info("📞 CALL STATUS UPDATE: '#{previous_status}' -> '#{status}'") - - # Update the status - conversation.additional_attributes['call_status'] = status - - # Add timestamps and metadata based on status - if %w[in-progress active].include?(status) - # Record the start time if not already set - unless conversation.additional_attributes['call_started_at'] - conversation.additional_attributes['call_started_at'] = Time.now.to_i - Rails.logger.info("⏱️ CALL STARTED AT: #{Time.now.to_i}") - end - - # Ensure we have call meta data - conversation.additional_attributes['meta'] ||= {} - conversation.additional_attributes['meta']['active_at'] = Time.now.to_i - - # For active calls, update the UI immediately - notify_call_status_change(status) - elsif call_ended?(status) - # Record end time - conversation.additional_attributes['call_ended_at'] = Time.now.to_i - Rails.logger.info("⏱️ CALL ENDED AT: #{Time.now.to_i}") - - # Calculate and record duration - if duration - conversation.additional_attributes['call_duration'] = duration - Rails.logger.info("⏱️ CALL DURATION (provided): #{duration} seconds") - elsif conversation.additional_attributes['call_started_at'] - conversation.additional_attributes['call_duration'] = - Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i - Rails.logger.info("⏱️ CALL DURATION (calculated): #{conversation.additional_attributes['call_duration']} seconds") - end - - # Add call end metadata - conversation.additional_attributes['meta'] ||= {} - conversation.additional_attributes['meta']["#{status}_at"] = Time.now.to_i - - end - - # Save the conversation - begin - result = conversation.save! - Rails.logger.info("💾 SAVED CONVERSATION SUCCESSFULLY: conversation_id=#{conversation.id}") - rescue StandardError => e - Rails.logger.error("❌ FAILED TO SAVE CONVERSATION: #{e.message}") - Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..3].join("\n")}") - end - - # Broadcast status update for active and ended calls - notify_call_status_change(status) if call_ended?(status) || status == 'active' - end - - def call_ended?(status) - %w[completed busy failed no-answer canceled missed].include?(status) - end - - def notify_call_status_change(status) - # For consistency, ensure the conversation values match the notification - # Sometimes we might have multiple events coming in and want to ensure the final state - # is reflected correctly in the UI - conversation.reload - - # If the conversation has a different status than what we're notifying about, - # use the conversation's status (it may have been updated in another operation) - final_status = status - if conversation.additional_attributes['call_status'] != status - final_status = conversation.additional_attributes['call_status'] - Rails.logger.info("⚠️ STATUS MISMATCH: Notifying: '#{status}', Conversation: '#{final_status}', using conversation value") - end - - # Construct the notification payload - notification = { - event_name: 'call_status_changed', - data: { - call_sid: call_sid, - status: final_status, - conversation_id: conversation.id, - timestamp: Time.now.to_i - } - } - - # Log the notification for debugging - Rails.logger.info("📢 BROADCASTING CALL STATUS: '#{final_status}' for conversation_id=#{conversation.id}") - - # Send the notification - ActionCable.server.broadcast( - "#{conversation.account_id}_#{conversation.inbox_id}", - notification - ) - end - end -end diff --git a/app/services/voice/outgoing_call_service.rb b/app/services/voice/outgoing_call_service.rb index 1dab3e458..2c9e53324 100644 --- a/app/services/voice/outgoing_call_service.rb +++ b/app/services/voice/outgoing_call_service.rb @@ -98,12 +98,18 @@ module Voice # Create the activity message in a separate method def create_activity_message - message_service = Voice::MessageUpdateService.new( + # Initialize the status manager with provider information + status_manager = Voice::CallStatus::Manager.new( conversation: @conversation, - call_sid: @call_details[:call_sid] + call_sid: @call_details[:call_sid], + provider: :twilio # Specify the provider for accurate messaging ) - message_service.create_activity_message("Outgoing call to #{contact.name || contact.phone_number}") + # Process first status update and create activity message + status_manager.process_status_update('initiated', nil, true) + + # Additional custom message for outgoing calls + status_manager.create_activity_message("Outgoing call to #{contact.name || contact.phone_number}") end def broadcast_to_agent diff --git a/app/services/voice/recording_service.rb b/app/services/voice/recording_service.rb deleted file mode 100644 index 0355a0ccd..000000000 --- a/app/services/voice/recording_service.rb +++ /dev/null @@ -1,91 +0,0 @@ -module Voice - class RecordingService - pattr_initialize [:conversation!, :recording_url!, :recording_sid!, :call_sid] - - def process - # Skip if already processed - return if recording_already_processed? - - # Create message from the recording - message = create_recording_message - - # Download and attach the recording - attach_recording_to_message(message) - - message - end - - private - - def recording_already_processed? - conversation.messages.where('additional_attributes @> ?', { recording_sid: recording_sid }.to_json).exists? - end - - def create_recording_message - contact = conversation.contact - return nil unless contact - - message_params = { - content: 'Voice Recording', - message_type: :incoming, - additional_attributes: { - call_sid: call_sid, - recording_url: recording_url, - recording_sid: recording_sid - } - } - - Messages::MessageBuilder.new(contact, conversation, message_params).perform - end - - def attach_recording_to_message(message) - return unless message && valid_recording_url? - - begin - # Get authentication details from the inbox channel - config = conversation.inbox.channel.provider_config_hash - account_sid = config['account_sid'] - auth_token = config['auth_token'] - - # Download the MP3 version of the recording - recording_mp3_url = "#{recording_url}.mp3" - download_file = Down.download( - recording_mp3_url, - http_basic_authentication: [account_sid, auth_token] - ) - - # Create the attachment - attachment = message.attachments.new( - file_type: :audio, - account_id: conversation.account_id, - extension: 'mp3', - fallback_title: 'Voice Recording', - meta: { - recording_sid: recording_sid, - twilio_account_sid: account_sid, - auth_required: true - } - ) - - # Attach the file - attachment.file.attach( - io: download_file, - filename: "#{recording_sid}.mp3", - content_type: 'audio/mpeg' - ) - - attachment.save! - rescue StandardError => e - Rails.logger.error("Error attaching recording: #{e.message}") - end - end - - def valid_recording_url? - # Validate that the URL is a proper Twilio recording URL - uri = URI.parse(recording_url) - return false unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) - - recording_url.include?('/Recordings/') && recording_sid.present? - end - end -end \ No newline at end of file diff --git a/app/services/voice/twilio_call_status_service.rb b/app/services/voice/twilio_call_status_service.rb deleted file mode 100644 index 54e559285..000000000 --- a/app/services/voice/twilio_call_status_service.rb +++ /dev/null @@ -1,61 +0,0 @@ -module Voice - class TwilioCallStatusService - pattr_initialize [:conversation!, :call_sid!, :call_status!, :is_outbound, :duration] - - CALL_STATUS_MESSAGES = { - 'initiated' => { outbound: 'Outbound call initiated', inbound: 'Initiating call' }, - 'ringing' => { outbound: 'Phone ringing', inbound: 'Phone ringing' }, - 'in-progress' => { - outbound: { first: 'Call connected', next: 'Call in progress' }, - inbound: { first: 'Call answered', next: 'Call in progress' } - }, - 'completed' => { outbound: 'Call completed', inbound: 'Call completed' }, - 'busy' => { outbound: 'Call busy', inbound: 'Call busy' }, - 'failed' => { outbound: 'Call failed', inbound: 'Call failed' }, - 'no-answer' => { outbound: 'Call not answered', inbound: 'Call not answered' }, - 'canceled' => { outbound: 'Call canceled', inbound: 'Call canceled' } - }.freeze - - def process(is_first_response = false) - # Skip if no changes needed - prev_status = conversation.additional_attributes&.dig('call_status') - return if !is_first_response && prev_status == call_status - - # Update conversation status using shared service - message_service.update_call_status(call_status, duration) - - # Create activity message - create_activity_message(is_first_response) - - # Update voice call message - message_service.update_voice_call_status(call_status, duration) - end - - private - - def message_service - @message_service ||= Voice::MessageUpdateService.new( - conversation: conversation, - call_sid: call_sid - ) - end - - def create_activity_message(is_first_response) - activity_message = activity_message_for_status(is_first_response) - message_service.create_activity_message(activity_message) - end - - def activity_message_for_status(is_first_response) - call_direction = is_outbound ? :outbound : :inbound - - if call_status == 'in-progress' - message_type = is_first_response ? :first : :next - return CALL_STATUS_MESSAGES[call_status][call_direction][message_type] - elsif CALL_STATUS_MESSAGES.key?(call_status) - return CALL_STATUS_MESSAGES[call_status][call_direction] - else - return "Call status: #{call_status}" - end - end - end -end \ No newline at end of file diff --git a/app/services/voice/twilio_validator_service.rb b/app/services/voice/twilio_validator_service.rb index b614fbee9..60f7db082 100644 --- a/app/services/voice/twilio_validator_service.rb +++ b/app/services/voice/twilio_validator_service.rb @@ -1,115 +1,73 @@ module Voice + # Validates incoming Twilio webhooks to ensure they are legitimate requests class TwilioValidatorService pattr_initialize [:account!, :params!, :request!] def valid? - # Skip for OPTIONS requests - return true if request.method == "OPTIONS" + # Skip validation for these cases: + return true if skip_validation? - # Skip validation for local development - if Rails.env.development? - Rails.logger.info("🔑 TWILIO VALIDATION: Skipping in development environment") - return true - end - - # Skip if we're missing account information - if account.blank? - Rails.logger.warn("⚠️ TWILIO VALIDATION: No account provided, allowing request") - return true - end - - # Skip if no To param (happens in some callback scenarios) - to_number = params['To'] - if to_number.blank? - Rails.logger.warn("⚠️ TWILIO VALIDATION: No 'To' parameter in request, allowing for callbacks") - return true - end - begin + # Find the inbox and get the auth token + to_number = params['To'] inbox = find_voice_inbox(to_number) - - # If inbox not found, allow the request for Twilio callbacks - unless inbox - Rails.logger.warn("⚠️ TWILIO VALIDATION: No inbox found for phone number #{to_number}, allowing request") - return true - end - - # Get Twilio Auth Token from inbox's channel - channel = inbox.channel - unless channel.is_a?(Channel::Voice) - Rails.logger.warn("⚠️ TWILIO VALIDATION: Channel is not a voice channel, allowing request") - return true - end - - provider_config = channel.provider_config_hash - # Check for auth token presence - if provider_config.blank? || provider_config['auth_token'].blank? - Rails.logger.warn("⚠️ TWILIO VALIDATION: No auth token available in provider config, allowing request") - return true - end + # Allow callbacks if we can't find the inbox or auth token + return true unless inbox + return true unless (auth_token = get_auth_token(inbox)) - auth_token = provider_config['auth_token'] - - # Validate incoming request signature if present + # Check if we have a signature to validate signature = request.headers['X-Twilio-Signature'] + return true unless signature.present? - # Allow requests without signature for callbacks - unless signature.present? - Rails.logger.warn("⚠️ TWILIO VALIDATION: No Twilio signature in request, allowing for callbacks") - return true - end - # Validate the signature validator = Twilio::Security::RequestValidator.new(auth_token) url = "#{request.protocol}#{request.host_with_port}#{request.fullpath}" - # Log validation attempt - Rails.logger.info("🔐 TWILIO VALIDATION: Validating signature for URL: #{url}") - is_valid = validator.validate(url, params.to_unsafe_h, signature) - + + # Log validation result if is_valid Rails.logger.info("✅ TWILIO VALIDATION: Valid signature confirmed") else - Rails.logger.error("⚠️ TWILIO VALIDATION: Invalid signature detected") - - # For debugging, log details about the validation - Rails.logger.error("📋 TWILIO VALIDATION DETAILS:") - Rails.logger.error("URL: #{url}") - Rails.logger.error("Signature: #{signature}") - Rails.logger.error("Auth Token: #{auth_token[0..3]}...") # Only log first few chars for security - - # Still return false for invalid signatures + Rails.logger.error("⚠️ TWILIO VALIDATION: Invalid signature for URL: #{url}") return false end rescue StandardError => e Rails.logger.error("❌ TWILIO VALIDATION ERROR: #{e.message}") - # Always allow callbacks even if validation fails - return true + return true # Allow on errors for robustness end true end private + + def skip_validation? + # Skip for OPTIONS requests and in development + return true if request.method == "OPTIONS" + return true if Rails.env.development? + return true if account.blank? + + false + end + + def get_auth_token(inbox) + channel = inbox.channel + return nil unless channel.is_a?(Channel::Voice) + + provider_config = channel.provider_config_hash + provider_config['auth_token'] if provider_config.present? + end def find_voice_inbox(to_number) return nil if to_number.blank? - inbox = account.inboxes - .where(channel_type: 'Channel::Voice') - .joins('INNER JOIN channel_voice ON channel_voice.id = inboxes.channel_id') - .where('channel_voice.phone_number = ?', to_number) - .first - - if inbox - Rails.logger.info("📥 TWILIO VALIDATION: Found inbox id=#{inbox.id} for phone=#{to_number}") - else - Rails.logger.warn("⚠️ TWILIO VALIDATION: No inbox found for phone=#{to_number}") - end - - inbox + account.inboxes + .where(channel_type: 'Channel::Voice') + .joins('INNER JOIN channel_voice ON channel_voice.id = inboxes.channel_id') + .where('channel_voice.phone_number = ?', to_number) + .first end end end \ No newline at end of file