chore: Clean up some code andn simplify

This commit is contained in:
Sojan
2025-05-04 08:47:14 -07:00
parent e8d3679aba
commit 2f7c8f6cfc
17 changed files with 987 additions and 1298 deletions
+395
View File
@@ -0,0 +1,395 @@
module Voice
module CallStatus
# CallStatusManager is the centralized service for managing all voice call statuses
# It handles updating conversation attributes, message attributes, and broadcasting updates.
# All status changes for voice calls should flow through this service to ensure consistency.
#
# This service replaces the older TwilioCallStatusService and MessageUpdateService,
# centralizing all voice call status management in one place with consistent behavior.
#
# Key responsibilities:
# 1. Tracking call status transitions (initiated → ringing → in-progress → completed)
# 2. Updating conversation additional_attributes with call metadata
# 3. Updating voice call message content_attributes with matching status
# 4. Creating appropriate activity messages for status changes
# 5. Broadcasting status changes via ActionCable
# 6. Determining call direction (inbound vs outbound)
# 7. Providing provider-specific messaging templates
#
# Usage:
# status_manager = Voice::CallStatus::Manager.new(
# conversation: conversation,
# call_sid: 'CA123456789',
# provider: :twilio
# )
#
# # Process a status update (recommended way to update status)
# status_manager.process_status_update('completed', 120) # Status with duration in seconds
#
# # Create an activity message
# status_manager.create_activity_message('Call ended by agent')
#
# # Check if call is outbound
# is_outbound = status_manager.is_outbound?
class Manager
# Constructor parameters:
# - conversation: The conversation associated with the call
# - call_sid: The external ID for the call from the provider
# - provider: Symbol representing the provider (e.g., :twilio)
pattr_initialize [:conversation!, :call_sid, :provider]
# Valid call statuses with their transitions
VALID_STATUSES = %w[initiated ringing in-progress active completed missed busy failed no-answer canceled].freeze
# Terminal statuses that indicate the call has ended
TERMINAL_STATUSES = %w[completed missed busy failed no-answer canceled].freeze
# Map external status names to our internal statuses
STATUS_MAPPING = {
# Twilio statuses
'queued' => 'initiated',
'initiated' => 'initiated',
'ringing' => 'ringing',
'in-progress' => 'in-progress',
'completed' => 'completed',
'busy' => 'busy',
'failed' => 'failed',
'no-answer' => 'no-answer',
'canceled' => 'canceled',
# Internal/UI statuses
'active' => 'in-progress',
'ended' => 'completed'
}.freeze
# Provider-specific message templates for different call statuses
PROVIDER_MESSAGES = {
twilio: {
'initiated' => { outbound: 'Outbound call initiated', inbound: 'Initiating call' },
'ringing' => { outbound: 'Phone ringing', inbound: 'Phone ringing' },
'in-progress' => {
outbound: { first: 'Call connected', next: 'Call in progress' },
inbound: { first: 'Call answered', next: 'Call in progress' }
},
'completed' => { outbound: 'Call completed', inbound: 'Call completed' },
'busy' => { outbound: 'Call busy', inbound: 'Call busy' },
'failed' => { outbound: 'Call failed', inbound: 'Call failed' },
'no-answer' => { outbound: 'Call not answered', inbound: 'Call not answered' },
'canceled' => { outbound: 'Call canceled', inbound: 'Call canceled' }
}
}.freeze
# Create a custom activity message
# This provides a clean migration path from MessageUpdateService
def create_activity_message(content, additional_attributes = {})
Messages::MessageBuilder.new(
nil,
conversation,
{
content: content,
message_type: :activity,
additional_attributes: additional_attributes
}
).perform
end
# Process a call status update from any provider (e.g., Twilio, Vonage)
# This is the primary method that should be used to update call statuses.
# It handles different provider statuses, calculates duration, updates conversation
# and message attributes, and creates appropriate activity messages.
#
# @param status [String] The status from the provider (e.g., 'completed', 'ringing')
# @param duration [Integer, nil] The call duration in seconds (if available)
# @param is_first_response [Boolean] Whether this is the first status update for this status
# @return [Boolean] Whether the update was processed successfully
def process_status_update(status, duration = nil, is_first_response = false)
# Skip if no changes needed to avoid duplicate processing
# Unless this is marked as the first response, which we should always process
prev_status = conversation.additional_attributes&.dig('call_status')
if !is_first_response && prev_status == status
Rails.logger.info("🔄 [CallStatusManager] Skipping duplicate status update: '#{status}'")
return true
end
# Normalize status using the STATUS_MAPPING if present
normalized_status = STATUS_MAPPING[status] || status
# Calculate call duration automatically if not provided and call is ending
if duration.nil? && call_ended?(normalized_status) && conversation.additional_attributes['call_started_at']
calculated_duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i
Rails.logger.info("⏱️ [CallStatusManager] Calculated call duration: #{calculated_duration} seconds")
duration = calculated_duration
end
# Update conversation and message status in a database transaction
result = update_status(normalized_status, duration)
# Create an activity message with provider-specific text if status was updated
create_provider_activity_message(normalized_status, is_first_response) if result
result
end
# Determine if a call is outbound based on conversation attributes
# This is the centralized method that should be used throughout the application
# to determine call direction instead of passing around the is_outbound flag.
#
# @return [Boolean] true if the call is outbound, false otherwise
def is_outbound?
# Strategy 1: Check the call_direction attribute (most reliable)
direction = conversation.additional_attributes['call_direction']
return direction == 'outbound' if direction.present?
# Strategy 2: Check for requires_agent_join flag (set for outbound calls)
# When an agent initiates an outbound call, this flag is set to true
# This helps us identify outbound calls even if call_direction is not set
return true if conversation.additional_attributes['requires_agent_join'] == true
# Strategy 3: Check for other outbound indicators
# e.g., check for call_type or other attributes that might indicate direction
call_type = conversation.additional_attributes['call_type']
return true if call_type == 'outbound'
# Default to inbound if we can't determine
# Most calls are inbound, so this is a reasonable default
false
end
# Generate provider-specific activity messages (e.g., for Twilio)
def create_provider_activity_message(status, is_first_response = false)
provider_key = provider&.to_sym
call_direction = is_outbound? ? :outbound : :inbound
# Default message in case we can't find a provider-specific one
message = "Call status: #{status}"
if provider_key && PROVIDER_MESSAGES.key?(provider_key)
messages = PROVIDER_MESSAGES[provider_key]
if status == 'in-progress' && messages.dig(status, call_direction).is_a?(Hash)
message_type = is_first_response ? :first : :next
provider_message = messages.dig(status, call_direction, message_type)
message = provider_message if provider_message
elsif messages.dig(status, call_direction)
message = messages.dig(status, call_direction)
end
end
# Create the activity message
create_activity_message(message, {
call_sid: call_sid,
call_status: status
})
end
# Update call status in a single operation
# This ensures conversation and message statuses are in sync
def update_status(status, duration = nil)
# Map external status to internal status
internal_status = STATUS_MAPPING[status] || status
Rails.logger.info("🔄 [CallStatusManager] Updating call status for conversation #{conversation.id}: '#{internal_status}'")
# Validate status
unless VALID_STATUSES.include?(internal_status)
Rails.logger.error("❌ [CallStatusManager] Invalid status: #{internal_status}")
return false
end
# Get current status
current_status = conversation.additional_attributes['call_status']
# Only update if status is changing or we're forcing an update
if current_status == internal_status
Rails.logger.info("🔄 [CallStatusManager] Status unchanged: '#{internal_status}'")
return true
end
# Log the status transition
Rails.logger.info("🔄 [CallStatusManager] Status transition: '#{current_status}' -> '#{internal_status}'")
ActiveRecord::Base.transaction do
# Update conversation additional_attributes
update_conversation_status(internal_status, duration)
# Update message content_attributes
update_message_status(internal_status, duration)
# Create activity message for status change if it's a significant change
create_status_activity_message(internal_status) if should_create_activity_message?(internal_status)
# Broadcast status change notification
broadcast_status_change(internal_status)
end
true
rescue StandardError => e
Rails.logger.error("❌ [CallStatusManager] Error updating status: #{e.message}")
Rails.logger.error(e.backtrace.first(5).join("\n"))
false
end
def call_ended?(status)
TERMINAL_STATUSES.include?(status)
end
private
def update_conversation_status(status, duration)
# Update the status
conversation.additional_attributes ||= {}
conversation.additional_attributes['call_status'] = status
# Update timestamps and metadata based on status
if %w[in-progress active].include?(status)
# Record the start time if not already set
conversation.additional_attributes['call_started_at'] = Time.now.to_i unless conversation.additional_attributes['call_started_at']
# Ensure we have call meta data
conversation.additional_attributes['meta'] ||= {}
conversation.additional_attributes['meta']['active_at'] = Time.now.to_i
elsif call_ended?(status)
# Record end time
conversation.additional_attributes['call_ended_at'] = Time.now.to_i
# Calculate and record duration
if duration
conversation.additional_attributes['call_duration'] = duration
elsif conversation.additional_attributes['call_started_at']
conversation.additional_attributes['call_duration'] =
Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i
end
# Add call end metadata
conversation.additional_attributes['meta'] ||= {}
conversation.additional_attributes['meta']["#{status}_at"] = Time.now.to_i
end
# Save the conversation
conversation.save!
end
def update_message_status(status, duration)
message = find_voice_call_message
return unless message
# Determine best message status value based on conversation status
message_status = status
if status == 'in-progress'
message_status = 'active'
elsif call_ended?(status)
message_status = 'ended'
end
# Get current content attributes, initialize if needed
content_attributes = message.content_attributes || {}
content_attributes['data'] ||= {}
# Update fields
content_attributes['data']['status'] = message_status
content_attributes['data']['duration'] = duration if duration
content_attributes['data']['meta'] ||= {}
content_attributes['data']['meta']["#{message_status}_at"] = Time.now.to_i
content_attributes['data']['updated_at'] = Time.now.to_i
# Add a flag to force the UI to refresh
content_attributes['data']['status_updated'] = Time.now.to_i
# Save the message
message.update!(content_attributes: content_attributes)
end
def find_voice_call_message
# First try to find by call_sid
message = nil
if call_sid.present?
# Try to find by exact call_sid match
message = conversation.messages
.where(content_type: 'voice_call')
.where("content_attributes->'data'->>'call_sid' = ?", call_sid)
.first
end
# If not found, try by looking for a call_sid that contains our call_sid (Twilio sometimes sends partial SIDs)
if message.nil? && call_sid.present?
# Look for messages where call_sid is a substring
messages = conversation.messages
.where(content_type: 'voice_call')
.order(created_at: :desc)
# Manually check for partial matches in content_attributes
message = messages.find do |msg|
stored_call_sid = msg.content_attributes.dig('data', 'call_sid')
stored_call_sid.present? && (stored_call_sid.include?(call_sid) || call_sid.include?(stored_call_sid))
end
end
# If still not found, get the most recent voice call message
if message.nil?
message = conversation.messages
.where(content_type: 'voice_call')
.order(created_at: :desc)
.first
end
message
end
def should_create_activity_message?(status)
# Only create activity messages for significant state changes
# Avoid creating too many messages for intermediate states
call_ended?(status) || status == 'in-progress'
end
def create_status_activity_message(status)
content = if call_ended?(status)
case status
when 'missed'
'Call was not answered'
when 'busy'
'Line was busy'
when 'failed'
'Call failed'
when 'no-answer'
'No answer'
when 'canceled'
'Call was canceled'
else
'Call ended'
end
elsif status == 'in-progress'
'Call in progress'
else
"Call status changed to #{status}"
end
# Use the public create_activity_message method
create_activity_message(content, {
call_sid: call_sid,
call_status: status
})
end
def broadcast_status_change(status)
# Broadcast to the account-wide channel
Rails.logger.info("📢 [CallStatusManager] Broadcasting status change: '#{status}' for conversation_id=#{conversation.id}")
# Use account-level channel for maximum compatibility
ActionCable.server.broadcast(
"account_#{conversation.account_id}",
{
event_name: 'call_status_changed',
data: {
call_sid: call_sid,
status: status,
conversation_id: conversation.id,
inbox_id: conversation.inbox_id,
timestamp: Time.now.to_i
}
}
)
end
end
end
end
+244 -200
View File
@@ -1,18 +1,35 @@
module Voice
# Handles conference events (start, end, participant joins and leaves)
# Uses CallStatusManager to update call statuses, ensuring consistency
# This service is called directly by ConferenceStatusService
class ConferenceManagerService
pattr_initialize [:conversation!, :event!, :call_sid!, :conference_sid, :participant_sid, :participant_label]
# Event constants to make code more readable
CONFERENCE_START = 'conference-start'.freeze
CONFERENCE_END = 'conference-end'.freeze
PARTICIPANT_JOIN = 'participant-join'.freeze
PARTICIPANT_LEAVE = 'participant-leave'.freeze
# Participant types
AGENT = 'agent'.freeze
CALLER = 'caller'.freeze
def process
Rails.logger.info("🎧 CONFERENCE EVENT: #{event} for conference_sid=#{conference_sid}")
# Process the conference event
case event
when 'conference-start'
when CONFERENCE_START
handle_conference_start
when 'conference-end'
when CONFERENCE_END
handle_conference_end
when 'participant-join'
when PARTICIPANT_JOIN
handle_participant_join
when 'participant-leave'
when PARTICIPANT_LEAVE
handle_participant_leave
else
Rails.logger.warn("🎧 UNKNOWN CONFERENCE EVENT: #{event}")
end
# Create activity message for the event
@@ -24,199 +41,228 @@ module Voice
private
def message_service
@message_service ||= Voice::MessageUpdateService.new(
def call_status_manager
# Use the CallStatusManager, which will determine internally if the call is outbound
@call_status_manager ||= Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: call_sid
call_sid: call_sid,
provider: :twilio
)
end
def handle_conference_start
conversation.additional_attributes ||= {}
conversation.additional_attributes['conference_status'] = 'started'
conversation.additional_attributes['conference_started_at'] = Time.now.to_i
# Log conference start
Rails.logger.info("🎧 CONFERENCE STARTED: conference_sid=#{conference_sid}")
# Update call status to ringing if not already in a more advanced state
# Update conference status
update_conference_status('started')
# Check if we need to update call status
current_status = conversation.additional_attributes['call_status']
Rails.logger.info("📞 CURRENT CALL STATUS: '#{current_status}'")
if !%w[active in-progress completed].include?(current_status)
Rails.logger.info("📞 UPDATING CALL TO RINGING ON CONFERENCE START")
message_service.update_call_status('ringing')
message_service.update_voice_call_status('ringing')
# Ensure we have metadata for debugging
conversation.additional_attributes['meta'] ||= {}
conversation.additional_attributes['meta']['ringing_at'] = Time.now.to_i
conversation.additional_attributes['meta']['conference_started_at'] = Time.now.to_i
conversation.save!
end
# Only update to ringing if not already in a more advanced state
return if %w[active in-progress completed].include?(current_status)
Rails.logger.info('📞 UPDATING CALL TO RINGING ON CONFERENCE START')
call_status_manager.process_status_update('ringing')
end
def handle_conference_end
conversation.additional_attributes ||= {}
conversation.additional_attributes['conference_status'] = 'ended'
conversation.additional_attributes['conference_ended_at'] = Time.now.to_i
# Log conference end
Rails.logger.info("🎧 CONFERENCE ENDED: conference_sid=#{conference_sid}")
# Determine the final call status based on the current state
# Update conference status
update_conference_status('ended')
# Get current call status
current_status = conversation.additional_attributes['call_status']
Rails.logger.info("📞 CURRENT CALL STATUS AT CONFERENCE END: '#{current_status}'")
if current_status == 'active' || current_status == 'in-progress'
# Call was active, mark as completed
duration = nil
if conversation.additional_attributes['call_started_at']
duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i
Rails.logger.info("⏱️ CALCULATED CALL DURATION: #{duration} seconds")
end
Rails.logger.info("📞 MARKING ACTIVE CALL AS COMPLETED")
message_service.update_call_status('completed', duration)
message_service.update_voice_call_status('ended', duration)
elsif current_status == 'ringing'
# Call never connected, mark as missed
Rails.logger.info("📞 MARKING RINGING CALL AS MISSED")
message_service.update_call_status('missed')
message_service.update_voice_call_status('missed')
else
# Default to completed status
Rails.logger.info("📞 MARKING CALL AS COMPLETED (DEFAULT)")
message_service.update_call_status('completed')
message_service.update_voice_call_status('ended')
end
# Ensure metadata is updated
conversation.additional_attributes['meta'] ||= {}
conversation.additional_attributes['meta']['conference_ended_at'] = Time.now.to_i
# Force update UI to show the change
ActionCable.server.broadcast(
"#{conversation.account_id}_#{conversation.inbox_id}",
{
event_name: 'call_status_changed',
data: {
call_sid: call_sid,
status: conversation.additional_attributes['call_status'],
conversation_id: conversation.id,
force_refresh: true
}
}
)
Rails.logger.info("📢 BROADCAST: Sent conference end notification")
# Determine final call status based on current state
finalize_call_status(current_status)
end
def handle_participant_join
# Track the participant
update_participant_info('joined')
# Log the participant joining
Rails.logger.info("👥 PARTICIPANT JOINED: #{participant_label || 'unknown'} (#{participant_sid})")
# Store participant join time based on type
if participant_label&.start_with?('agent')
conversation.additional_attributes['agent_joined_at'] = Time.now.to_i
Rails.logger.info("👤 AGENT JOINED AT: #{Time.now.to_i}")
# If call is ringing when agent joins, mark as active
if conversation.additional_attributes['call_status'] == 'ringing'
Rails.logger.info("📞 UPDATING RINGING CALL TO ACTIVE (agent joined)")
message_service.update_call_status('active')
message_service.update_voice_call_status('active')
end
elsif participant_label&.start_with?('caller')
conversation.additional_attributes['caller_joined_at'] = Time.now.to_i
Rails.logger.info("👤 CALLER JOINED AT: #{Time.now.to_i}")
# For outbound calls
if conversation.additional_attributes['call_direction'] == 'outbound'
# Mark call as active as soon as caller joins an outbound call
# This ensures the call doesn't get stuck in ringing
if conversation.additional_attributes['call_status'] == 'ringing'
Rails.logger.info("📞 UPDATING RINGING OUTBOUND CALL TO ACTIVE (caller joined)")
message_service.update_call_status('active')
message_service.update_voice_call_status('active')
end
end
# Track participant join
track_participant_join
# Handle call status updates based on who joined
if agent_participant?
handle_agent_join
elsif caller_participant?
handle_caller_join
else
# Generic participant (no label)
Rails.logger.info("👤 GENERIC PARTICIPANT JOINED")
# If we're stuck in ringing, try to move forward
if conversation.additional_attributes['call_status'] == 'ringing' &&
(Time.now.to_i - conversation.additional_attributes.dig('meta', 'ringing_at').to_i > 10)
Rails.logger.info("📞 UPDATING LONG-RINGING CALL TO ACTIVE (participant joined)")
message_service.update_call_status('active')
message_service.update_voice_call_status('active')
end
end
# Check if both caller and agent have joined
if conversation.additional_attributes['agent_joined_at'] &&
conversation.additional_attributes['caller_joined_at']
# Ensure call is marked as active when both parties are present
if conversation.additional_attributes['call_status'] != 'active'
Rails.logger.info("📞 UPDATING CALL STATUS TO ACTIVE (both parties present)")
message_service.update_call_status('active')
message_service.update_voice_call_status('active')
end
handle_generic_participant_join
end
# Check if both parties are present to mark call as active
check_both_parties_present
end
def handle_participant_leave
# Update participant tracking
update_participant_info('left')
# Record leave time based on participant type
if participant_label&.start_with?('agent')
conversation.additional_attributes['agent_left_at'] = Time.now.to_i
elsif participant_label&.start_with?('caller')
conversation.additional_attributes['caller_left_at'] = Time.now.to_i
end
# Handle caller leaving during ringing phase
if participant_label&.start_with?('caller') &&
conversation.additional_attributes['call_status'] == 'ringing'
# Check if any agent has joined
has_agent_joined = participant_has_joined?('agent')
unless has_agent_joined
message_service.update_call_status('missed')
message_service.update_voice_call_status('missed')
end
end
# Handle case where all participants have left but conference is still active
if all_participants_left? &&
conversation.additional_attributes['conference_status'] != 'ended' &&
conversation.additional_attributes['call_status'] == 'active'
# Calculate duration if we can
duration = nil
if conversation.additional_attributes['call_started_at']
duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i
end
message_service.update_call_status('completed', duration)
message_service.update_voice_call_status('ended', duration)
# Track participant leave
track_participant_leave
# Handle missed calls when caller leaves during ringing
check_for_missed_call
# Check if everyone left to end conference
check_if_everyone_left
end
# Helper methods for updating conference status
def update_conference_status(status)
conversation.additional_attributes ||= {}
conversation.additional_attributes['conference_status'] = status
conversation.additional_attributes["conference_#{status}_at"] = Time.now.to_i
# Update metadata
conversation.additional_attributes['meta'] ||= {}
conversation.additional_attributes['meta']["conference_#{status}_at"] = Time.now.to_i
Rails.logger.info("🎧 CONFERENCE #{status.upcase}: conference_sid=#{conference_sid}")
end
# Helper methods for finalizing call status
def finalize_call_status(current_status)
if %w[active in-progress].include?(current_status)
# Call was active, mark as completed with duration
complete_active_call
elsif current_status == 'ringing'
# Call never connected
Rails.logger.info('📞 MARKING RINGING CALL AS MISSED')
call_status_manager.process_status_update('missed')
else
# Default to completed status
Rails.logger.info('📞 MARKING CALL AS COMPLETED (DEFAULT)')
call_status_manager.process_status_update('completed')
end
end
def complete_active_call
# Calculate duration if possible
duration = nil
if conversation.additional_attributes['call_started_at']
duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i
Rails.logger.info("⏱️ CALCULATED CALL DURATION: #{duration} seconds")
end
Rails.logger.info('📞 MARKING ACTIVE CALL AS COMPLETED')
call_status_manager.process_status_update('completed', duration)
end
# Helper methods for participant handling
def track_participant_join
# Update participant tracking
update_participant_info('joined')
Rails.logger.info("👥 PARTICIPANT JOINED: #{participant_label || 'unknown'} (#{participant_sid})")
end
def track_participant_leave
# Update participant tracking
update_participant_info('left')
Rails.logger.info("👥 PARTICIPANT LEFT: #{participant_label || 'unknown'} (#{participant_sid})")
# Record leave time based on participant type
if agent_participant?
conversation.additional_attributes['agent_left_at'] = Time.now.to_i
elsif caller_participant?
conversation.additional_attributes['caller_left_at'] = Time.now.to_i
end
end
# Participant type checks
def agent_participant?
participant_label&.start_with?(AGENT)
end
def caller_participant?
participant_label&.start_with?(CALLER)
end
# Participant join handlers
def handle_agent_join
conversation.additional_attributes['agent_joined_at'] = Time.now.to_i
Rails.logger.info("👤 AGENT JOINED AT: #{Time.now.to_i}")
# If call is ringing when agent joins, mark as active
return unless conversation.additional_attributes['call_status'] == 'ringing'
Rails.logger.info('📞 UPDATING RINGING CALL TO ACTIVE (agent joined)')
call_status_manager.process_status_update('active')
end
def handle_caller_join
conversation.additional_attributes['caller_joined_at'] = Time.now.to_i
Rails.logger.info("👤 CALLER JOINED AT: #{Time.now.to_i}")
# For outbound calls - mark as active when caller joins if still ringing
return unless outbound_call? && ringing_call?
Rails.logger.info('📞 UPDATING RINGING OUTBOUND CALL TO ACTIVE (caller joined)')
call_status_manager.process_status_update('active')
end
def handle_generic_participant_join
Rails.logger.info('👤 GENERIC PARTICIPANT JOINED')
# If we're stuck in ringing for a while, try to move forward
return unless ringing_call? && long_ringing?
Rails.logger.info('📞 UPDATING LONG-RINGING CALL TO ACTIVE (participant joined)')
call_status_manager.process_status_update('active')
end
# Call state checks
def outbound_call?
conversation.additional_attributes['call_direction'] == 'outbound'
end
def ringing_call?
conversation.additional_attributes['call_status'] == 'ringing'
end
def long_ringing?
ringing_at = conversation.additional_attributes.dig('meta', 'ringing_at').to_i
ringing_at > 0 && (Time.now.to_i - ringing_at > 10)
end
# Both parties present check
def check_both_parties_present
both_present = conversation.additional_attributes['agent_joined_at'] &&
conversation.additional_attributes['caller_joined_at']
return unless both_present && conversation.additional_attributes['call_status'] != 'active'
Rails.logger.info('📞 UPDATING CALL STATUS TO ACTIVE (both parties present)')
call_status_manager.process_status_update('active')
end
# Missed call check when caller leaves
def check_for_missed_call
return unless caller_participant? && ringing_call? && !participant_has_joined?(AGENT)
Rails.logger.info('📞 MARKING AS MISSED (caller left during ringing, no agent joined)')
call_status_manager.process_status_update('missed')
end
# Everyone left check
def check_if_everyone_left
call_active = conversation.additional_attributes['call_status'] == 'active'
conference_active = conversation.additional_attributes['conference_status'] != 'ended'
return unless all_participants_left? && conference_active && call_active
# Calculate duration if possible
duration = nil
duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i if conversation.additional_attributes['call_started_at']
Rails.logger.info('📞 MARKING CALL AS COMPLETED (all participants left)')
call_status_manager.process_status_update('completed', duration)
end
# Participant tracking methods
def update_participant_info(status)
# Initialize participants tracking
conversation.additional_attributes ||= {}
conversation.additional_attributes['participants'] ||= {}
# Determine participant type from label
participant_type = participant_label&.start_with?('agent') ? 'agent' : 'caller'
participant_type = agent_participant? ? AGENT : CALLER
if status == 'joined'
# Add or update participant
conversation.additional_attributes['participants'][participant_sid] = {
@@ -234,41 +280,39 @@ module Voice
def participant_has_joined?(type)
participants = conversation.additional_attributes['participants'] || {}
if participants.is_a?(Hash)
return participants.values.any? { |p| p['type'] == type && p['status'] == 'joined' }
end
false
return false unless participants.is_a?(Hash)
participants.values.any? { |p| p['type'] == type && p['status'] == 'joined' }
end
def all_participants_left?
participants = conversation.additional_attributes['participants'] || {}
if participants.is_a?(Hash)
return !participants.values.any? { |p| p['status'] == 'joined' }
end
true # Default to true if no participants structure exists
return true unless participants.is_a?(Hash)
!participants.values.any? { |p| p['status'] == 'joined' }
end
# Activity message creation
def create_activity_message
content = case event
when 'conference-start'
'Conference started'
when 'conference-end'
'Conference ended'
when 'participant-join'
participant_type = participant_label&.start_with?('agent') ? 'Agent' : 'Caller'
"#{participant_type} joined the call"
when 'participant-leave'
participant_type = participant_label&.start_with?('agent') ? 'Agent' : 'Caller'
"#{participant_type} left the call"
else
"Call event: #{event}"
end
message_service.create_activity_message(content)
content = activity_message_content_for_event
call_status_manager.create_activity_message(content)
end
def activity_message_content_for_event
case event
when CONFERENCE_START
'Conference started'
when CONFERENCE_END
'Conference ended'
when PARTICIPANT_JOIN
participant_type = agent_participant? ? 'Agent' : 'Caller'
"#{participant_type} joined the call"
when PARTICIPANT_LEAVE
participant_type = agent_participant? ? 'Agent' : 'Caller'
"#{participant_type} left the call"
else
"Call event: #{event}"
end
end
end
end
end
+135 -164
View File
@@ -1,216 +1,187 @@
module Voice
# Handles Twilio conference status webhook callbacks
# Normalizes events, finds the relevant conversation,
# and delegates processing to ConferenceManagerService
class ConferenceStatusService
pattr_initialize [:account!, :params!]
# Map of Twilio event names to our normalized format
EVENT_MAPPING = {
# Join events
'participant-join' => 'participant-join',
'participantjoin' => 'participant-join',
'participantjoined' => 'participant-join',
'participant_join' => 'participant-join',
# Leave events
'participant-leave' => 'participant-leave',
'participantleave' => 'participant-leave',
'participantleft' => 'participant-leave',
'participant_leave' => 'participant-leave',
# Conference start events
'conference-start' => 'conference-start',
'conferencestart' => 'conference-start',
'conferencestarted' => 'conference-start',
'conference_start' => 'conference-start',
# Conference end events
'conference-end' => 'conference-end',
'conferenceend' => 'conference-end',
'conferenceended' => 'conference-end',
'conference_end' => 'conference-end'
}.freeze
def process
# Log all incoming parameters for debugging
Rails.logger.info("🎤 CONFERENCE STATUS PARAMS: #{params.to_unsafe_h.except('controller', 'action').to_json}")
# Log incoming parameters
Rails.logger.info("🎤 CONFERENCE STATUS: #{params['StatusCallbackEvent']}")
find_conversation
queue_status_processing if @conversation
# Extract status info and find conversation
info = status_info
conversation = find_conversation(info)
return unless conversation
# Update participant tracking info
update_participant_info(conversation, info)
# Process the event with the conference manager
Rails.logger.info("📊 PROCESSING EVENT: #{info[:event]}")
Voice::ConferenceManagerService.new(
conversation: conversation,
event: info[:event],
call_sid: info[:call_sid],
conference_sid: info[:conference_sid],
participant_sid: info[:participant_sid],
participant_label: info[:participant_label]
).process
end
def status_info
# Normalize the event name to match our expected format
# Get and normalize the event name
raw_event = params['StatusCallbackEvent']
# Log the raw event to help with debugging
Rails.logger.info("🎧 RAW EVENT RECEIVED: '#{raw_event}'")
# Convert Twilio's event formats to our standardized kebab-case
normalized_event = if raw_event.present?
# Clean up the string first - convert to lowercase and remove spaces
event_text = raw_event.downcase.gsub(/\s+/, '')
# Handle all possible format variations from Twilio
case event_text
# Participant join events (camelCase, kebab-case, no dash)
when 'participant-join', 'participantjoin', 'participantjoined', 'participantjoin', 'participant_join'
'participant-join'
# Participant leave events (camelCase, kebab-case, no dash)
when 'participant-leave', 'participantleave', 'participantleft', 'participantleave', 'participant_leave'
'participant-leave'
# Conference start events (camelCase, kebab-case, no dash)
when 'conference-start', 'conferencestart', 'conferencestarted', 'conferencestart', 'conference_start'
'conference-start'
# Conference end events (camelCase, kebab-case, no dash)
when 'conference-end', 'conferenceend', 'conferenceended', 'conferenceend', 'conference_end'
'conference-end'
# Add other Twilio event variations if needed
# For any other event, standardize to kebab-case
else
# Convert camelCase to kebab-case
kebab_case = event_text.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
# Convert snake_case to kebab-case
kebab_case = kebab_case.gsub('_', '-')
# Remove any extra dashes
kebab_case = kebab_case.gsub(/--+/, '-')
kebab_case
end
else
# Default if no event is provided
'unknown'
end
Rails.logger.info("🎧 NORMALIZED EVENT: '#{raw_event}' -> '#{normalized_event}'")
normalized_event = normalize_event_name(raw_event)
{
call_sid: params['CallSid'],
conference_sid: params['ConferenceSid'],
event: normalized_event,
participant_sid: params['ParticipantSid'],
participant_label: params['ParticipantLabel'],
call_sid_ending_with: params['CallSidEndingWith'],
audio_level: params['AudioLevel']
participant_label: params['ParticipantLabel']
}
end
private
def normalize_event_name(raw_event)
return 'unknown' unless raw_event.present?
event_text = raw_event.downcase.gsub(/\s+/, '')
# Look up in mapping
EVENT_MAPPING[event_text] || event_text.gsub(/([a-z\d])([A-Z])/, '\1-\2').gsub('_', '-').gsub(/--+/, '-')
end
def find_conversation
@conversation = nil
# Try finding by conference_sid
if status_info[:conference_sid].present?
@conversation = account.conversations
.where("additional_attributes->>'conference_sid' = ?", status_info[:conference_sid])
.first
Rails.logger.info("🔍 SEARCHING BY CONFERENCE_SID: #{status_info[:conference_sid]}") if @conversation.nil?
def find_conversation(info)
# Try by conference_sid first
if info[:conference_sid].present?
conversation = find_by_conference_sid(info[:conference_sid])
return conversation if conversation
end
# If not found and conference_sid looks like our format, extract conversation ID
if @conversation.nil? && status_info[:conference_sid].present? && status_info[:conference_sid].start_with?('conf_account_')
conference_parts = status_info[:conference_sid].match(/conf_account_\d+_conv_(\d+)/)
# Try by call_sid if available
if info[:call_sid].present?
conversation = account.conversations.where("additional_attributes->>'call_sid' = ?", info[:call_sid]).first
return conversation if conversation
end
Rails.logger.error("❌ CONVERSATION NOT FOUND for event: #{info[:event]}")
nil
end
def find_by_conference_sid(conference_sid)
# Direct match by conference_sid
conversation = account.conversations.where("additional_attributes->>'conference_sid' = ?", conference_sid).first
return conversation if conversation
# Try pattern matching if it looks like our format
if conference_sid.start_with?('conf_account_')
conference_parts = conference_sid.match(/conf_account_\d+_conv_(\d+)/)
if conference_parts && conference_parts[1].present?
conversation_display_id = conference_parts[1]
@conversation = account.conversations.find_by(display_id: conversation_display_id)
Rails.logger.info("🎧 Found conversation by display_id=#{conversation_display_id} from conference_sid=#{status_info[:conference_sid]}")
return account.conversations.find_by(display_id: conversation_display_id)
end
end
# If still not found, try by call_sid
if @conversation.nil? && status_info[:call_sid].present?
@conversation = account.conversations
.where("additional_attributes->>'call_sid' = ?", status_info[:call_sid])
.first
Rails.logger.info("🔍 SEARCHING BY CALL_SID: #{status_info[:call_sid]}") if @conversation.nil?
end
if @conversation
Rails.logger.info("✅ FOUND CONVERSATION: id=#{@conversation.id} display_id=#{@conversation.display_id}")
else
Rails.logger.error("❌ CONVERSATION NOT FOUND for call_sid=#{status_info[:call_sid]} conference_sid=#{status_info[:conference_sid]}")
return
end
# Update participant info if conversation found
update_participant_info if @conversation
nil
end
def update_participant_info
# Initialize or get current participants list
@conversation.additional_attributes ||= {}
@conversation.additional_attributes['participants'] ||= {}
def update_participant_info(conversation, info)
# Skip if no participant info
return unless info[:participant_sid].present?
participant_sid = status_info[:participant_sid]
return unless participant_sid.present?
# Initialize tracking
conversation.additional_attributes ||= {}
conversation.additional_attributes['participants'] ||= {}
# Log the received event
Rails.logger.info("👥 PARTICIPANT EVENT: #{status_info[:event]} for #{participant_sid} (#{status_info[:participant_label]})")
participant_sid = info[:participant_sid]
# Update based on event type
case status_info[:event]
case info[:event]
when 'participant-join'
# Add participant
@conversation.additional_attributes['participants'][participant_sid] = {
'joined_at' => Time.now.to_i,
'type' => status_info[:participant_label]&.start_with?('agent') ? 'agent' : 'caller',
'call_sid' => status_info[:call_sid],
'status' => 'joined'
}
# Flag outbound calls that need agent join if this is a caller
if @conversation.additional_attributes['call_direction'] == 'outbound' &&
status_info[:participant_label]&.start_with?('caller-')
# This is the customer joining an outbound call - flag for agent to join immediately
@conversation.additional_attributes['requires_agent_join'] = true
# Broadcast an immediate "incoming call" notification for the agent
broadcast_agent_join_notification
end
track_participant_join(conversation, participant_sid, info)
when 'participant-leave'
# Only update if the participant is in the list
if @conversation.additional_attributes['participants'].key?(participant_sid)
@conversation.additional_attributes['participants'][participant_sid]['status'] = 'left'
@conversation.additional_attributes['participants'][participant_sid]['left_at'] = Time.now.to_i
end
track_participant_leave(conversation, participant_sid)
end
# Save the updated conversation
@conversation.save!
conversation.save!
end
def track_participant_join(conversation, participant_sid, info)
# Add participant
conversation.additional_attributes['participants'][participant_sid] = {
'joined_at' => Time.now.to_i,
'type' => info[:participant_label]&.start_with?('agent') ? 'agent' : 'caller',
'call_sid' => info[:call_sid],
'status' => 'joined'
}
# Handle outbound calls where caller has joined
if conversation.additional_attributes['call_direction'] == 'outbound' &&
info[:participant_label]&.start_with?('caller-')
conversation.additional_attributes['requires_agent_join'] = true
broadcast_agent_notification(conversation, info)
end
end
def track_participant_leave(conversation, participant_sid)
if conversation.additional_attributes['participants'].key?(participant_sid)
conversation.additional_attributes['participants'][participant_sid]['status'] = 'left'
conversation.additional_attributes['participants'][participant_sid]['left_at'] = Time.now.to_i
end
end
def broadcast_agent_join_notification
# Get the contact, ensuring we still have one
contact = @conversation.contact
unless contact
# If contact is missing, try to find or create one based on info available
# This shouldn't normally happen but is a safeguard
phone_numbers = @conversation.messages.where(content_type: 'voice_call')
.map { |m| m.content_attributes.dig('data', 'to_number') }.compact.first
if phone_numbers
contact = account.contacts.find_or_create_by(phone_number: phone_numbers) do |c|
c.name = "Contact from #{phone_numbers}"
end
# Update conversation with the new contact
@conversation.update(contact_id: contact.id)
else
# If we can't find a phone number, create a generic contact
contact = account.contacts.create!(phone_number: "unknown-#{Time.now.to_i}")
@conversation.update(contact_id: contact.id)
end
end
def broadcast_agent_notification(conversation, info)
contact = conversation.contact
ActionCable.server.broadcast(
"account_#{account.id}",
{
event: 'incoming_call',
data: {
call_sid: status_info[:call_sid],
conversation_id: @conversation.id,
inbox_id: @conversation.inbox_id,
inbox_name: @conversation.inbox.name,
contact_name: contact.name || 'Outbound Call',
contact_id: contact.id,
call_sid: info[:call_sid],
conversation_id: conversation.id,
inbox_id: conversation.inbox_id,
inbox_name: conversation.inbox.name,
contact_name: contact&.name || 'Outbound Call',
contact_id: contact&.id,
is_outbound: true,
account_id: account.id,
conference_sid: status_info[:conference_sid]
conference_sid: info[:conference_sid]
}
}
)
Rails.logger.info("📣 BROADCAST: Sent agent join notification")
end
def queue_status_processing
# Process the status update directly using the service
Rails.logger.info("📊 PROCESSING CONFERENCE EVENT: #{status_info[:event]}")
Voice::ConferenceStatusUpdateService.new(
conversation: @conversation,
event: status_info[:event],
call_sid: status_info[:call_sid],
conference_sid: status_info[:conference_sid],
participant_sid: status_info[:participant_sid],
participant_label: status_info[:participant_label]
).process
end
end
end
@@ -1,17 +0,0 @@
module Voice
class ConferenceStatusUpdateService
pattr_initialize [:conversation!, :event!, :call_sid!, :conference_sid, :participant_sid, :participant_label]
def process
# Use the ConferenceManagerService to handle all conference events
Voice::ConferenceManagerService.new(
conversation: conversation,
event: event,
call_sid: call_sid,
conference_sid: conference_sid,
participant_sid: participant_sid,
participant_label: participant_label
).process
end
end
end
@@ -1,54 +1,54 @@
module Voice
class ConversationFinderService
pattr_initialize [:account!, :phone_number!, :inbox, :call_sid, :is_outbound]
def perform
# Ensure we have a phone number
validate_and_normalize_phone_number
# First try to find existing conversation by call_sid if available
conversation = find_by_call_sid if call_sid.present?
return conversation if conversation
# If not found, create a new conversation
create_new_conversation
end
private
def validate_and_normalize_phone_number
# Simple validation to ensure we have something to work with
raise "Phone number cannot be blank" if phone_number.blank?
raise 'Phone number cannot be blank' if phone_number.blank?
# Normalize the phone number (strip any whitespace)
@phone_number = phone_number.strip
end
def find_by_call_sid
account.conversations.where("additional_attributes->>'call_sid' = ?", call_sid).first
end
def find_or_create_contact
# Always find or create a contact based on the phone number
account.contacts.find_or_create_by!(phone_number: phone_number) do |c|
c.name = "Contact from #{phone_number}"
end
end
def create_new_conversation
# First ensure we have a contact
contact = find_or_create_contact
# Find or initialize the contact inbox
contact_inbox = ContactInbox.find_or_initialize_by(
contact_id: contact.id,
contact_id: contact.id,
inbox_id: inbox.id
)
# Set source_id if not set - needed for properly mapping the conversation
contact_inbox.source_id ||= phone_number
contact_inbox.save!
# Create the conversation
conversation = account.conversations.create!(
contact_inbox_id: contact_inbox.id,
@@ -57,37 +57,47 @@ module Voice
status: :open,
additional_attributes: initial_attributes
)
# Add conference_sid to attributes
conference_name = generate_conference_name(conversation)
conversation.additional_attributes['conference_sid'] = conference_name
conversation.save!
conversation
end
def initial_attributes
attributes = {
'call_status' => 'in-progress',
'call_initiated_at' => Time.now.to_i
}
# Add call_sid if available
attributes['call_sid'] = call_sid if call_sid.present?
# Set call direction based on is_outbound flag
if is_outbound
attributes['call_direction'] = 'outbound'
attributes['call_type'] = 'outbound'
# For outbound calls, set the requires_agent_join flag
# This is used by the CallStatusManager to identify outbound calls
attributes['requires_agent_join'] = true
else
attributes['call_direction'] = 'inbound'
attributes['call_type'] = 'inbound'
end
# Add metadata for tracking important timestamps
attributes['meta'] = {
'initiated_at' => Time.now.to_i
}
attributes
end
def generate_conference_name(conversation)
"conf_account_#{account.id}_conv_#{conversation.display_id}"
end
end
end
end
+14 -8
View File
@@ -142,14 +142,20 @@ module Voice
# Create activity message separately after the voice call message
def create_activity_message
activity_message = Messages::MessageBuilder.new(
nil,
@conversation,
{
content: "Incoming call from #{@contact.name.presence || caller_info[:from_number]}",
message_type: :activity
}
).perform
# Use CallStatusManager for consistency
status_manager = Voice::CallStatus::Manager.new(
conversation: @conversation,
call_sid: caller_info[:call_sid],
provider: :twilio
)
# First process ringing status
status_manager.process_status_update('ringing', nil, true)
# Then add a custom message about the incoming call
activity_message = status_manager.create_activity_message(
"Incoming call from #{@contact.name.presence || caller_info[:from_number]}"
)
Rails.logger.info("📝 ACTIVITY MESSAGE: id=#{activity_message.id}")
end
@@ -1,59 +0,0 @@
module Voice
class MessageDeliveryService
# This service will handle delivering messages from agents to active voice calls
# For now we'll store the messages in Redis with the call_sid as the key
# This way the TwiML controller can retrieve and read them out to the caller
attr_reader :message, :conversation
def initialize(message)
@message = message
@conversation = message.conversation
end
def perform
return unless should_deliver_message?
call_sid = conversation.additional_attributes&.dig('call_sid')
return unless call_sid.present?
# Store the message in Redis to be read out in the next TwiML request
redis_key = "voice_message:#{call_sid}"
# Add the message to a Redis list
Redis::Alfred.lpush(redis_key, {
content: message.content,
message_id: message.id,
delivered: false
}.to_json)
# Set expiration so we don't keep messages forever
Redis::Alfred.expire(redis_key, 1.hour.to_i)
# Update the message with delivery status
update_message_delivery_status
end
private
def should_deliver_message?
# Only deliver outgoing messages (from agents)
return false unless message.outgoing?
# Only deliver text messages, not attachments etc
return false unless message.content.present?
# Only deliver to active voice calls
return false unless conversation.additional_attributes&.dig('call_sid').present?
return false unless conversation.additional_attributes&.dig('call_status') == 'in-progress'
true
end
def update_message_delivery_status
additional_attributes = message.additional_attributes || {}
additional_attributes[:voice_delivery_status] = 'queued'
message.update(additional_attributes: additional_attributes)
end
end
end
@@ -1,209 +0,0 @@
module Voice
class MessageUpdateService
pattr_initialize [:conversation!, :call_sid]
def update_voice_call_status(status, duration = nil)
message = find_voice_call_message
return unless message
# Log message found for debugging
Rails.logger.info("📱 UPDATE VOICE CALL STATUS: Found message: #{message.id}, updating status: #{status}")
# Get current content attributes, initialize if needed
content_attributes = message.content_attributes || {}
content_attributes['data'] ||= {}
# Log previous status
previous_status = content_attributes['data']['status']
Rails.logger.info("📱 PREVIOUS STATUS: #{previous_status} -> NEW STATUS: #{status}")
# Update fields
content_attributes['data']['status'] = status
content_attributes['data']['duration'] = duration if duration
content_attributes['data']['meta'] ||= {}
content_attributes['data']['meta']["#{status}_at"] = Time.now.to_i
content_attributes['data']['updated_at'] = Time.now.to_i
# Add a flag to force the UI to refresh
content_attributes['data']['status_updated'] = Time.now.to_i
# Save the message with a rescue to ensure we get error details if it fails
begin
result = message.update(content_attributes: content_attributes)
if result
Rails.logger.info("✅ VOICE CALL STATUS UPDATED: Message #{message.id} status: #{status}")
else
Rails.logger.error("❌ VOICE CALL STATUS UPDATE FAILED: #{message.errors.full_messages.join(', ')}")
end
rescue StandardError => e
Rails.logger.error("❌ VOICE CALL STATUS UPDATE ERROR: #{e.message}")
Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..3].join("\n")}")
end
message
end
def find_voice_call_message
# First try to find by call_sid
message = nil
if call_sid.present?
# Try to find by exact call_sid match
Rails.logger.info("🔍 SEARCHING FOR VOICE CALL MESSAGE BY CALL_SID: #{call_sid}")
message = conversation.messages
.where(content_type: 'voice_call')
.where("content_attributes->'data'->>'call_sid' = ?", call_sid)
.first
end
# If not found, try by looking for a call_sid that contains our call_sid (Twilio sometimes sends partial SIDs)
if message.nil? && call_sid.present?
Rails.logger.info("🔍 SEARCHING FOR VOICE CALL MESSAGE BY PARTIAL CALL_SID MATCH: #{call_sid}")
# Look for messages where call_sid is a substring
last_few_chars = call_sid.last(8)
messages = conversation.messages
.where(content_type: 'voice_call')
.order(created_at: :desc)
# Manually check for partial matches in content_attributes
message = messages.find do |msg|
stored_call_sid = msg.content_attributes.dig('data', 'call_sid')
stored_call_sid.present? && (stored_call_sid.include?(call_sid) || call_sid.include?(stored_call_sid))
end
end
# If still not found, get the most recent voice call message
if message.nil?
Rails.logger.info('🔍 USING MOST RECENT VOICE CALL MESSAGE AS FALLBACK')
message = conversation.messages
.where(content_type: 'voice_call')
.order(created_at: :desc)
.first
end
if message
Rails.logger.info("✅ FOUND VOICE CALL MESSAGE: #{message.id}")
else
Rails.logger.error("❌ NO VOICE CALL MESSAGE FOUND FOR CONVERSATION: #{conversation.id}")
end
message
end
def create_activity_message(content)
# Create a simple activity message without additional attributes
Messages::MessageBuilder.new(
nil,
conversation,
{
content: content,
message_type: :activity
}
).perform
end
def update_call_status(status, duration = nil)
# Update conversation attributes
conversation.additional_attributes ||= {}
# Only update if status is changing
previous_status = conversation.additional_attributes['call_status']
if previous_status == status
Rails.logger.info("🔄 CALL STATUS UNCHANGED: Already in state '#{status}', no update needed")
return
end
# Log the status change
Rails.logger.info("📞 CALL STATUS UPDATE: '#{previous_status}' -> '#{status}'")
# Update the status
conversation.additional_attributes['call_status'] = status
# Add timestamps and metadata based on status
if %w[in-progress active].include?(status)
# Record the start time if not already set
unless conversation.additional_attributes['call_started_at']
conversation.additional_attributes['call_started_at'] = Time.now.to_i
Rails.logger.info("⏱️ CALL STARTED AT: #{Time.now.to_i}")
end
# Ensure we have call meta data
conversation.additional_attributes['meta'] ||= {}
conversation.additional_attributes['meta']['active_at'] = Time.now.to_i
# For active calls, update the UI immediately
notify_call_status_change(status)
elsif call_ended?(status)
# Record end time
conversation.additional_attributes['call_ended_at'] = Time.now.to_i
Rails.logger.info("⏱️ CALL ENDED AT: #{Time.now.to_i}")
# Calculate and record duration
if duration
conversation.additional_attributes['call_duration'] = duration
Rails.logger.info("⏱️ CALL DURATION (provided): #{duration} seconds")
elsif conversation.additional_attributes['call_started_at']
conversation.additional_attributes['call_duration'] =
Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i
Rails.logger.info("⏱️ CALL DURATION (calculated): #{conversation.additional_attributes['call_duration']} seconds")
end
# Add call end metadata
conversation.additional_attributes['meta'] ||= {}
conversation.additional_attributes['meta']["#{status}_at"] = Time.now.to_i
end
# Save the conversation
begin
result = conversation.save!
Rails.logger.info("💾 SAVED CONVERSATION SUCCESSFULLY: conversation_id=#{conversation.id}")
rescue StandardError => e
Rails.logger.error("❌ FAILED TO SAVE CONVERSATION: #{e.message}")
Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..3].join("\n")}")
end
# Broadcast status update for active and ended calls
notify_call_status_change(status) if call_ended?(status) || status == 'active'
end
def call_ended?(status)
%w[completed busy failed no-answer canceled missed].include?(status)
end
def notify_call_status_change(status)
# For consistency, ensure the conversation values match the notification
# Sometimes we might have multiple events coming in and want to ensure the final state
# is reflected correctly in the UI
conversation.reload
# If the conversation has a different status than what we're notifying about,
# use the conversation's status (it may have been updated in another operation)
final_status = status
if conversation.additional_attributes['call_status'] != status
final_status = conversation.additional_attributes['call_status']
Rails.logger.info("⚠️ STATUS MISMATCH: Notifying: '#{status}', Conversation: '#{final_status}', using conversation value")
end
# Construct the notification payload
notification = {
event_name: 'call_status_changed',
data: {
call_sid: call_sid,
status: final_status,
conversation_id: conversation.id,
timestamp: Time.now.to_i
}
}
# Log the notification for debugging
Rails.logger.info("📢 BROADCASTING CALL STATUS: '#{final_status}' for conversation_id=#{conversation.id}")
# Send the notification
ActionCable.server.broadcast(
"#{conversation.account_id}_#{conversation.inbox_id}",
notification
)
end
end
end
+9 -3
View File
@@ -98,12 +98,18 @@ module Voice
# Create the activity message in a separate method
def create_activity_message
message_service = Voice::MessageUpdateService.new(
# Initialize the status manager with provider information
status_manager = Voice::CallStatus::Manager.new(
conversation: @conversation,
call_sid: @call_details[:call_sid]
call_sid: @call_details[:call_sid],
provider: :twilio # Specify the provider for accurate messaging
)
message_service.create_activity_message("Outgoing call to #{contact.name || contact.phone_number}")
# Process first status update and create activity message
status_manager.process_status_update('initiated', nil, true)
# Additional custom message for outgoing calls
status_manager.create_activity_message("Outgoing call to #{contact.name || contact.phone_number}")
end
def broadcast_to_agent
-91
View File
@@ -1,91 +0,0 @@
module Voice
class RecordingService
pattr_initialize [:conversation!, :recording_url!, :recording_sid!, :call_sid]
def process
# Skip if already processed
return if recording_already_processed?
# Create message from the recording
message = create_recording_message
# Download and attach the recording
attach_recording_to_message(message)
message
end
private
def recording_already_processed?
conversation.messages.where('additional_attributes @> ?', { recording_sid: recording_sid }.to_json).exists?
end
def create_recording_message
contact = conversation.contact
return nil unless contact
message_params = {
content: 'Voice Recording',
message_type: :incoming,
additional_attributes: {
call_sid: call_sid,
recording_url: recording_url,
recording_sid: recording_sid
}
}
Messages::MessageBuilder.new(contact, conversation, message_params).perform
end
def attach_recording_to_message(message)
return unless message && valid_recording_url?
begin
# Get authentication details from the inbox channel
config = conversation.inbox.channel.provider_config_hash
account_sid = config['account_sid']
auth_token = config['auth_token']
# Download the MP3 version of the recording
recording_mp3_url = "#{recording_url}.mp3"
download_file = Down.download(
recording_mp3_url,
http_basic_authentication: [account_sid, auth_token]
)
# Create the attachment
attachment = message.attachments.new(
file_type: :audio,
account_id: conversation.account_id,
extension: 'mp3',
fallback_title: 'Voice Recording',
meta: {
recording_sid: recording_sid,
twilio_account_sid: account_sid,
auth_required: true
}
)
# Attach the file
attachment.file.attach(
io: download_file,
filename: "#{recording_sid}.mp3",
content_type: 'audio/mpeg'
)
attachment.save!
rescue StandardError => e
Rails.logger.error("Error attaching recording: #{e.message}")
end
end
def valid_recording_url?
# Validate that the URL is a proper Twilio recording URL
uri = URI.parse(recording_url)
return false unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
recording_url.include?('/Recordings/') && recording_sid.present?
end
end
end
@@ -1,61 +0,0 @@
module Voice
class TwilioCallStatusService
pattr_initialize [:conversation!, :call_sid!, :call_status!, :is_outbound, :duration]
CALL_STATUS_MESSAGES = {
'initiated' => { outbound: 'Outbound call initiated', inbound: 'Initiating call' },
'ringing' => { outbound: 'Phone ringing', inbound: 'Phone ringing' },
'in-progress' => {
outbound: { first: 'Call connected', next: 'Call in progress' },
inbound: { first: 'Call answered', next: 'Call in progress' }
},
'completed' => { outbound: 'Call completed', inbound: 'Call completed' },
'busy' => { outbound: 'Call busy', inbound: 'Call busy' },
'failed' => { outbound: 'Call failed', inbound: 'Call failed' },
'no-answer' => { outbound: 'Call not answered', inbound: 'Call not answered' },
'canceled' => { outbound: 'Call canceled', inbound: 'Call canceled' }
}.freeze
def process(is_first_response = false)
# Skip if no changes needed
prev_status = conversation.additional_attributes&.dig('call_status')
return if !is_first_response && prev_status == call_status
# Update conversation status using shared service
message_service.update_call_status(call_status, duration)
# Create activity message
create_activity_message(is_first_response)
# Update voice call message
message_service.update_voice_call_status(call_status, duration)
end
private
def message_service
@message_service ||= Voice::MessageUpdateService.new(
conversation: conversation,
call_sid: call_sid
)
end
def create_activity_message(is_first_response)
activity_message = activity_message_for_status(is_first_response)
message_service.create_activity_message(activity_message)
end
def activity_message_for_status(is_first_response)
call_direction = is_outbound ? :outbound : :inbound
if call_status == 'in-progress'
message_type = is_first_response ? :first : :next
return CALL_STATUS_MESSAGES[call_status][call_direction][message_type]
elsif CALL_STATUS_MESSAGES.key?(call_status)
return CALL_STATUS_MESSAGES[call_status][call_direction]
else
return "Call status: #{call_status}"
end
end
end
end
+36 -78
View File
@@ -1,115 +1,73 @@
module Voice
# Validates incoming Twilio webhooks to ensure they are legitimate requests
class TwilioValidatorService
pattr_initialize [:account!, :params!, :request!]
def valid?
# Skip for OPTIONS requests
return true if request.method == "OPTIONS"
# Skip validation for these cases:
return true if skip_validation?
# Skip validation for local development
if Rails.env.development?
Rails.logger.info("🔑 TWILIO VALIDATION: Skipping in development environment")
return true
end
# Skip if we're missing account information
if account.blank?
Rails.logger.warn("⚠️ TWILIO VALIDATION: No account provided, allowing request")
return true
end
# Skip if no To param (happens in some callback scenarios)
to_number = params['To']
if to_number.blank?
Rails.logger.warn("⚠️ TWILIO VALIDATION: No 'To' parameter in request, allowing for callbacks")
return true
end
begin
# Find the inbox and get the auth token
to_number = params['To']
inbox = find_voice_inbox(to_number)
# If inbox not found, allow the request for Twilio callbacks
unless inbox
Rails.logger.warn("⚠️ TWILIO VALIDATION: No inbox found for phone number #{to_number}, allowing request")
return true
end
# Get Twilio Auth Token from inbox's channel
channel = inbox.channel
unless channel.is_a?(Channel::Voice)
Rails.logger.warn("⚠️ TWILIO VALIDATION: Channel is not a voice channel, allowing request")
return true
end
provider_config = channel.provider_config_hash
# Check for auth token presence
if provider_config.blank? || provider_config['auth_token'].blank?
Rails.logger.warn("⚠️ TWILIO VALIDATION: No auth token available in provider config, allowing request")
return true
end
# Allow callbacks if we can't find the inbox or auth token
return true unless inbox
return true unless (auth_token = get_auth_token(inbox))
auth_token = provider_config['auth_token']
# Validate incoming request signature if present
# Check if we have a signature to validate
signature = request.headers['X-Twilio-Signature']
return true unless signature.present?
# Allow requests without signature for callbacks
unless signature.present?
Rails.logger.warn("⚠️ TWILIO VALIDATION: No Twilio signature in request, allowing for callbacks")
return true
end
# Validate the signature
validator = Twilio::Security::RequestValidator.new(auth_token)
url = "#{request.protocol}#{request.host_with_port}#{request.fullpath}"
# Log validation attempt
Rails.logger.info("🔐 TWILIO VALIDATION: Validating signature for URL: #{url}")
is_valid = validator.validate(url, params.to_unsafe_h, signature)
# Log validation result
if is_valid
Rails.logger.info("✅ TWILIO VALIDATION: Valid signature confirmed")
else
Rails.logger.error("⚠️ TWILIO VALIDATION: Invalid signature detected")
# For debugging, log details about the validation
Rails.logger.error("📋 TWILIO VALIDATION DETAILS:")
Rails.logger.error("URL: #{url}")
Rails.logger.error("Signature: #{signature}")
Rails.logger.error("Auth Token: #{auth_token[0..3]}...") # Only log first few chars for security
# Still return false for invalid signatures
Rails.logger.error("⚠️ TWILIO VALIDATION: Invalid signature for URL: #{url}")
return false
end
rescue StandardError => e
Rails.logger.error("❌ TWILIO VALIDATION ERROR: #{e.message}")
# Always allow callbacks even if validation fails
return true
return true # Allow on errors for robustness
end
true
end
private
def skip_validation?
# Skip for OPTIONS requests and in development
return true if request.method == "OPTIONS"
return true if Rails.env.development?
return true if account.blank?
false
end
def get_auth_token(inbox)
channel = inbox.channel
return nil unless channel.is_a?(Channel::Voice)
provider_config = channel.provider_config_hash
provider_config['auth_token'] if provider_config.present?
end
def find_voice_inbox(to_number)
return nil if to_number.blank?
inbox = account.inboxes
.where(channel_type: 'Channel::Voice')
.joins('INNER JOIN channel_voice ON channel_voice.id = inboxes.channel_id')
.where('channel_voice.phone_number = ?', to_number)
.first
if inbox
Rails.logger.info("📥 TWILIO VALIDATION: Found inbox id=#{inbox.id} for phone=#{to_number}")
else
Rails.logger.warn("⚠️ TWILIO VALIDATION: No inbox found for phone=#{to_number}")
end
inbox
account.inboxes
.where(channel_type: 'Channel::Voice')
.joins('INNER JOIN channel_voice ON channel_voice.id = inboxes.channel_id')
.where('channel_voice.phone_number = ?', to_number)
.first
end
end
end