chore: Clean up some code andn simplify
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user