chore: Clean up some code andn simplify

This commit is contained in:
Sojan
2025-05-04 08:47:14 -07:00
parent e8d3679aba
commit 2f7c8f6cfc
17 changed files with 987 additions and 1298 deletions
@@ -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
+29 -58
View File
@@ -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?
@@ -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 {
-191
View File
@@ -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
+2 -4
View File
@@ -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
+395
View File
@@ -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
+244 -200
View File
@@ -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
end
+135 -164
View File
@@ -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
@@ -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
@@ -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
end
+14 -8
View File
@@ -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
@@ -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
@@ -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
+9 -3
View File
@@ -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
-91
View File
@@ -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
@@ -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
+36 -78
View File
@@ -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