diff --git a/app/controllers/api/v1/accounts/voice_controller.rb b/app/controllers/api/v1/accounts/voice_controller.rb
index c2345189c..b31222ffb 100644
--- a/app/controllers/api/v1/accounts/voice_controller.rb
+++ b/app/controllers/api/v1/accounts/voice_controller.rb
@@ -3,64 +3,61 @@ require 'twilio-ruby'
class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:end_call, :join_call, :reject_call]
skip_before_action :authenticate_user!, only: [:twiml_for_client]
- # Removed skip_before_action :verify_authenticity_token (it's not defined in BaseController)
+ # Removed skip_before_action :verify_authenticity_token (it's not defined in BaseController)
protect_from_forgery with: :null_session, only: [:twiml_for_client]
before_action :handle_options_request, only: [:twiml_for_client]
-
+
# Handle CORS preflight OPTIONS requests
def handle_options_request
- if request.method == "OPTIONS"
+ if request.method == 'OPTIONS'
set_cors_headers
head :ok
return true
end
false
end
-
+
def set_cors_headers
# Add explicit Content-Type header to ensure browser requests are handled properly
headers['Content-Type'] = 'text/xml; charset=utf-8' unless request.method == 'OPTIONS'
-
+
# Standard CORS headers
headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, GET, OPTIONS'
headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Twilio-Signature'
headers['Access-Control-Max-Age'] = '86400' # 24 hours
-
+
# Log headers for debugging
Rails.logger.info("🚨 RESPONSE HEADERS SET: #{headers.to_h.inspect}")
end
-
+
# No hard-coded credentials - we'll fetch them from the channel
-
+
def end_call
call_sid = params[:call_sid] || @conversation.additional_attributes&.dig('call_sid')
return render json: { error: 'No active call found' }, status: :not_found unless call_sid
-
+
# Get the channel config
channel = @conversation.inbox.channel
config = channel.provider_config_hash
-
+
# Create a Twilio client using credentials from the channel
client = Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
call = client.calls(call_sid).fetch
-
+
# Only try to end the call if it's still in progress
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
-
+
# 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
-
- # Mark conversation as resolved
- @conversation.status = :resolved
-
+
# Update the voice call message status
if call_message = find_voice_call_message
content_attributes = call_message.content_attributes || {}
@@ -70,79 +67,77 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
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!
-
+
# Create an activity message noting the call has ended
Messages::MessageBuilder.new(
- nil,
- @conversation,
- {
- content: 'Call ended by agent',
+ nil,
+ @conversation,
+ {
+ content: 'Call ended by agent',
message_type: :activity,
- additional_attributes: {
+ additional_attributes: {
call_sid: call_sid,
call_status: 'completed',
ended_by: current_user.name
}
}
).perform
-
+
render json: { status: 'success', message: 'Call successfully ended' }
else
- render json: { status: 'success', message: "Call already in '#{call.status}' state" }
+ render json: { status: 'success', message: "Call already in '#{call.status}' state" }
end
- rescue => e
+ rescue StandardError => e
render json: { error: "Failed to end call: #{e.message}" }, status: :internal_server_error
end
-
+
def join_call
call_sid = params[:call_sid] || @conversation.additional_attributes&.dig('call_sid')
-
+
# Check if this is an outbound call that needs to be joined (might not have call_sid yet)
is_outbound_call = @conversation.additional_attributes&.dig('requires_agent_join') == true
-
- unless call_sid || is_outbound_call
- return render json: { error: 'No active call found' }, status: :not_found
- end
-
+
+ return render json: { error: 'No active call found' }, status: :not_found unless call_sid || is_outbound_call
+
# Get the conference SID from the conversation
conference_sid = @conversation.additional_attributes&.dig('conference_sid')
-
+
# Check conversation record for conference information
-
+
# If not found, create one using account ID and conversation display ID
- unless conference_sid
+ if conference_sid
+ # Using existing conference
+ else
# Use the same format as in webhooks_controller for consistency
conference_sid = "conf_account_#{Current.account.id}_conv_#{@conversation.display_id}"
-
+
# Save it for future use
@conversation.additional_attributes ||= {}
@conversation.additional_attributes['conference_sid'] = conference_sid
@conversation.save!
-
+
# Created new conference
- else
- # Using existing conference
end
-
+
# For outbound calls, ensure we also update call_status if not already set
if is_outbound_call && !@conversation.additional_attributes['call_status']
@conversation.additional_attributes['call_status'] = 'in-progress'
@conversation.save!
# Set call status for outbound call
end
-
+
# Agent joining call via WebRTC
-
+
# Update conversation to show agent joined and set call status to active
@conversation.additional_attributes['agent_joined'] = true
@conversation.additional_attributes['joined_at'] = Time.now.to_i
@@ -150,14 +145,14 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
id: current_user.id,
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 || {}
@@ -168,17 +163,17 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
content_attributes['data']['meta']['active_at'] = Time.now.to_i
call_message.update(content_attributes: content_attributes)
end
-
+
@conversation.save!
-
+
# Create an activity message
Messages::MessageBuilder.new(
- nil,
- @conversation,
- {
- content: "#{current_user.name} joined the call",
+ nil,
+ @conversation,
+ {
+ content: "#{current_user.name} joined the call",
message_type: :activity,
- additional_attributes: {
+ additional_attributes: {
call_sid: call_sid,
conference_sid: conference_sid,
joined_by: current_user.name,
@@ -186,10 +181,10 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
}
}
).perform
-
+
# Return conference information for the WebRTC client with detailed logging
- response_data = {
- status: 'success',
+ response_data = {
+ status: 'success',
message: 'Agent joining call via WebRTC',
conference_sid: conference_sid,
using_webrtc: true,
@@ -199,19 +194,19 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
# Add even more debug info
conference_name_debug: "#{conference_sid}"
}
-
+
# Return response with conference information
-
+
render json: response_data
- rescue => e
+ rescue StandardError => e
Rails.logger.error("Error joining call: #{e.message}")
render json: { error: "Failed to join call: #{e.message}" }, status: :internal_server_error
end
-
+
def reject_call
call_sid = params[:call_sid] || @conversation.additional_attributes&.dig('call_sid')
return render json: { error: 'No active call found' }, status: :not_found unless call_sid
-
+
# Update conversation to show agent rejected call
@conversation.additional_attributes['agent_rejected'] = true
@conversation.additional_attributes['rejected_at'] = Time.now.to_i
@@ -220,24 +215,24 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
name: current_user.name
}
@conversation.save!
-
+
# Create an activity message noting the agent rejected the call
Messages::MessageBuilder.new(
- nil,
- @conversation,
- {
- content: "#{current_user.name} declined to answer",
+ nil,
+ @conversation,
+ {
+ content: "#{current_user.name} declined to answer",
message_type: :activity,
- additional_attributes: {
+ additional_attributes: {
call_sid: call_sid,
rejected_by: current_user.name,
rejected_at: Time.now.to_i
}
}
).perform
-
- render json: {
- status: 'success',
+
+ render json: {
+ status: 'success',
message: 'Call rejected by agent'
}
end
@@ -252,13 +247,13 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
# Get the channel config
channel = conversation.inbox.channel
config = channel.provider_config_hash
-
+
begin
# Create a Twilio client using credentials from the channel
client = Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
call = client.calls(call_sid).fetch
-
- render json: {
+
+ render json: {
status: call.status,
duration: call.duration,
direction: call.direction,
@@ -267,269 +262,261 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
start_time: call.start_time,
end_time: call.end_time
}
- rescue => e
+ rescue StandardError => e
render json: { error: "Failed to fetch call status: #{e.message}" }, status: :internal_server_error
end
end
-
+
# TwiML endpoint for Twilio Client browser calls - with ultra-robust error handling
def twiml_for_client
- begin
- # Log everything for debugging
- Rails.logger.info("TwiML_FOR_CLIENT CALLED with params: #{params.inspect}")
-
- # Extract just what we need - the To parameter (conference name)
- # Check for the To parameter which should be sent by the Twilio client
- to = params[:To]
-
- # Simple debug log of what we received
- Rails.logger.info("📞 Received request for TwiML with To parameter: '#{to}'")
-
- # Verify the format is what we expect
- if to && to.match?(/^conf_account_\d+_conv_\d+$/)
- Rails.logger.info("✅ Conference ID is in the expected format: #{to}")
- elsif to
- Rails.logger.info("⚠️ Conference ID does not match expected format: #{to}")
- end
-
- # SUPER CRITICAL DEBUGGING - We need to know EXACTLY what is coming in as the To parameter
- Rails.logger.info("🚨 PARAMS INSPECTION: #{params.to_json}")
- Rails.logger.info("🚨 TO PARAMETER (CAPS) EXISTS?: #{params.key?(:To)}")
- Rails.logger.info("🚨 TO PARAMETER (LOWERCASE) EXISTS?: #{params.key?(:to)}")
- Rails.logger.info("🚨 TO PARAMETER FINAL VALUE: '#{to}'")
- Rails.logger.info("🚨 TO PARAMETER TYPE: #{to.class}")
- Rails.logger.info("🚨 TO PARAMETER EMPTY?: #{to.blank?}")
- Rails.logger.info("🚨 TO PARAMETER STARTS WITH 'conf_'?: #{to.to_s.start_with?('conf_')}")
-
- # Critical debugging for troubleshooting
- Rails.logger.info("PARAMS RECEIVED: #{params.inspect}")
- Rails.logger.info("REQUEST HEADERS: #{request.headers.to_h.select { |k, _| k.start_with?('HTTP_') }.inspect}")
- Rails.logger.info("CLIENT IP: #{request.remote_ip}")
-
- # Log missing to parameter and try to find the correct conference ID
- if to.blank?
- # Log the issue clearly
- Rails.logger.error("🚨 Missing 'To' parameter in request! Trying to find the correct conference ID")
-
- # Get account ID from params
- account_id = params[:account_id]
-
- if account_id.present?
- # Find the latest active call for this account
- Rails.logger.info("🔍 Looking for latest active call for account #{account_id}")
-
- begin
- # Find the most recent conversation with an active call
- conversation = Conversation.joins(:inbox)
- .where(account_id: account_id)
- .where("additional_attributes->>'call_status' IN ('ringing', 'in-progress')")
- .where("additional_attributes ? 'conference_sid'")
- .order(created_at: :desc)
- .first
-
- if conversation
- # Use the exact conference ID from the conversation
- to = conversation.additional_attributes['conference_sid']
-
- if to && to.start_with?('conf_account_') && to.include?('_conv_')
- Rails.logger.info("✅ Found active call with conference ID: #{to}")
- else
- Rails.logger.error("❌ Found conversation but conference ID format is invalid: #{to}")
- end
- else
- Rails.logger.error("❌ No active calls found for account #{account_id}")
- end
- rescue => e
- Rails.logger.error("❌ Error finding active call: #{e.message}")
- end
- end
-
- # If we still don't have a valid To parameter, use a well-known format that will fail predictably
- if to.blank?
- to = "MISSING_TO_PARAMETER"
- Rails.logger.error("❌ Could not find a valid conference ID - call will fail")
- end
- end
-
- # CRITICAL: Do NOT modify the conference name - simply ensure it's a string
- # This was the source of the issue - we were adding an extra prefix even when it already had one
- to = to.to_s
-
- # Log the final conference name
- Rails.logger.info("FINAL CONFERENCE NAME: #{to}")
-
- # IMPROVED Account handling - more resilient with better logging
- account_id = params[:account_id].presence
- Rails.logger.info("ACCOUNT_ID FROM PARAMS: #{account_id.inspect}")
-
- # Safer account ID validation
- begin
- account = nil
- if account_id.present?
- # Try to parse as integer for safer lookup
- safe_account_id = account_id.to_i
- account = Account.find_by(id: safe_account_id)
-
- if account
- Rails.logger.info("✅ Found account with ID: #{safe_account_id}")
- else
- Rails.logger.warn("⚠️ No account found with ID: #{safe_account_id}")
- end
- end
-
- # Fallback chain - try multiple ways to find an account
- if account.nil?
- Rails.logger.info("Looking for first account as fallback")
- account = Account.first
-
- if account
- Rails.logger.info("✅ Found fallback account with ID: #{account.id}")
- else
- Rails.logger.error("❌ No accounts exist in the system!")
- end
- end
-
- # Set current account if found
- if account
- Current.account = account
- Rails.logger.info("✅ Current.account set to ID: #{account.id}")
- end
- rescue => account_error
- Rails.logger.error("❌ Error setting Current.account: #{account_error.message}")
- Rails.logger.error(account_error.backtrace.first(3).join("\n"))
- end
-
- # Make the TwiML response generation as simple as possible
- response = Twilio::TwiML::VoiceResponse.new do |r|
- # Log everything about the request
- # Generate TwiML for agent to join conference
-
- # SIMPLEST POSSIBLE APPROACH - direct conference connection without any extra audio
- r.dial do |dial|
- # Using this conference name in TwiML
-
- # Get a safe callback URL
- base_callback_url = begin
- if Current.account&.id
- "#{base_url.gsub(/\/$/, '')}/api/v1/accounts/#{Current.account.id}/channels/voice/webhooks/conference_status"
- else
- "#{base_url.gsub(/\/$/, '')}/api/v1/accounts/1/channels/voice/webhooks/conference_status"
- end
- end
-
- # Agent ID for participant label
- agent_id = current_user.present? ? current_user.id.to_s : 'unknown-user'
-
- # Log connection parameters to help debug outbound call issues
- is_agent = params['is_agent'] == 'true'
- Rails.logger.info("🔥🔥🔥 AGENT CONNECTING TO CONFERENCE: #{to}, agent_id=#{agent_id}, is_agent=#{is_agent}")
-
- # CRITICAL: Look for outbound call indicators in URL parameters
- if params['is_outbound'] == 'true' || is_agent
- Rails.logger.info("🚨🚨🚨 DETECTED OUTBOUND CALL OR AGENT CONNECTING")
- end
-
- # Absolute minimal conference parameters for agent joining
- dial.conference(
- to,
- startConferenceOnEnter: true, # Agent joining starts the conference
- endConferenceOnExit: true, # End when agent leaves
- muted: false, # Agent can speak
- beep: false, # No beep sounds
- waitUrl: '', # No hold music
- earlyMedia: true, # Enable early media for faster connection
- statusCallback: base_callback_url,
- statusCallbackEvent: 'start end join leave',
- statusCallbackMethod: 'POST',
- participantLabel: "agent-#{agent_id}"
- )
- end
- end
-
- # Extra logging to help diagnose issues
- Rails.logger.info("🎧 TwiML conference parameters for agent: startConferenceOnEnter=true, endConferenceOnExit=true, conference_name=#{to}")
- Rails.logger.info("🔊 Generated TwiML length: #{response.to_s.length} bytes")
- # Add more detailed debugging about what we're actually doing
- Rails.logger.info("🔍 DEBUG: Agent joining as PARTICIPANT to conference '#{to}' with account_id=#{account_id}")
-
- # Set CORS headers to properly respond to Twilio
- set_cors_headers
-
- # Render with proper MIME type
- render xml: response.to_s, content_type: 'text/xml'
- rescue => e
- # Enhanced error logging
- Rails.logger.error("💥 ERROR IN TWIML GENERATION: #{e.class.name}: #{e.message}")
- Rails.logger.error("💥 EXCEPTION BACKTRACE: #{e.backtrace.first(10).join("\n")}")
- Rails.logger.error("💥 PARAMS AT TIME OF ERROR: #{params.inspect}")
-
- # Generate a super-simple error response that explains the issue
- error_response = Twilio::TwiML::VoiceResponse.new
- error_response.say(message: "We apologize, but there was a technical issue connecting your call.")
- error_response.pause(length: 1)
- error_response.say(message: "The specific error was: #{e.message[0..100]}")
- error_response.pause(length: 1)
- error_response.say(message: "The call will now disconnect. Please try again.")
- error_response.hangup
-
- # Set CORS headers
- set_cors_headers
-
- render xml: error_response.to_s, content_type: 'text/xml'
+ # Log everything for debugging
+ Rails.logger.info("TwiML_FOR_CLIENT CALLED with params: #{params.inspect}")
+
+ # Extract just what we need - the To parameter (conference name)
+ # Check for the To parameter which should be sent by the Twilio client
+ to = params[:To]
+
+ # Simple debug log of what we received
+ Rails.logger.info("📞 Received request for TwiML with To parameter: '#{to}'")
+
+ # Verify the format is what we expect
+ if to && to.match?(/^conf_account_\d+_conv_\d+$/)
+ Rails.logger.info("✅ Conference ID is in the expected format: #{to}")
+ elsif to
+ Rails.logger.info("⚠️ Conference ID does not match expected format: #{to}")
end
+
+ # SUPER CRITICAL DEBUGGING - We need to know EXACTLY what is coming in as the To parameter
+ Rails.logger.info("🚨 PARAMS INSPECTION: #{params.to_json}")
+ Rails.logger.info("🚨 TO PARAMETER (CAPS) EXISTS?: #{params.key?(:To)}")
+ Rails.logger.info("🚨 TO PARAMETER (LOWERCASE) EXISTS?: #{params.key?(:to)}")
+ Rails.logger.info("🚨 TO PARAMETER FINAL VALUE: '#{to}'")
+ Rails.logger.info("🚨 TO PARAMETER TYPE: #{to.class}")
+ Rails.logger.info("🚨 TO PARAMETER EMPTY?: #{to.blank?}")
+ Rails.logger.info("🚨 TO PARAMETER STARTS WITH 'conf_'?: #{to.to_s.start_with?('conf_')}")
+
+ # Critical debugging for troubleshooting
+ Rails.logger.info("PARAMS RECEIVED: #{params.inspect}")
+ Rails.logger.info("REQUEST HEADERS: #{request.headers.to_h.select { |k, _| k.start_with?('HTTP_') }.inspect}")
+ Rails.logger.info("CLIENT IP: #{request.remote_ip}")
+
+ # Log missing to parameter and try to find the correct conference ID
+ if to.blank?
+ # Log the issue clearly
+ Rails.logger.error("🚨 Missing 'To' parameter in request! Trying to find the correct conference ID")
+
+ # Get account ID from params
+ account_id = params[:account_id]
+
+ if account_id.present?
+ # Find the latest active call for this account
+ Rails.logger.info("🔍 Looking for latest active call for account #{account_id}")
+
+ begin
+ # Find the most recent conversation with an active call
+ conversation = Conversation.joins(:inbox)
+ .where(account_id: account_id)
+ .where("additional_attributes->>'call_status' IN ('ringing', 'in-progress')")
+ .where("additional_attributes ? 'conference_sid'")
+ .order(created_at: :desc)
+ .first
+
+ if conversation
+ # Use the exact conference ID from the conversation
+ to = conversation.additional_attributes['conference_sid']
+
+ if to && to.start_with?('conf_account_') && to.include?('_conv_')
+ Rails.logger.info("✅ Found active call with conference ID: #{to}")
+ else
+ Rails.logger.error("❌ Found conversation but conference ID format is invalid: #{to}")
+ end
+ else
+ Rails.logger.error("❌ No active calls found for account #{account_id}")
+ end
+ rescue StandardError => e
+ Rails.logger.error("❌ Error finding active call: #{e.message}")
+ end
+ end
+
+ # If we still don't have a valid To parameter, use a well-known format that will fail predictably
+ if to.blank?
+ to = 'MISSING_TO_PARAMETER'
+ Rails.logger.error('❌ Could not find a valid conference ID - call will fail')
+ end
+ end
+
+ # CRITICAL: Do NOT modify the conference name - simply ensure it's a string
+ # This was the source of the issue - we were adding an extra prefix even when it already had one
+ to = to.to_s
+
+ # Log the final conference name
+ Rails.logger.info("FINAL CONFERENCE NAME: #{to}")
+
+ # IMPROVED Account handling - more resilient with better logging
+ account_id = params[:account_id].presence
+ Rails.logger.info("ACCOUNT_ID FROM PARAMS: #{account_id.inspect}")
+
+ # Safer account ID validation
+ begin
+ account = nil
+ if account_id.present?
+ # Try to parse as integer for safer lookup
+ safe_account_id = account_id.to_i
+ account = Account.find_by(id: safe_account_id)
+
+ if account
+ Rails.logger.info("✅ Found account with ID: #{safe_account_id}")
+ else
+ Rails.logger.warn("⚠️ No account found with ID: #{safe_account_id}")
+ end
+ end
+
+ # Fallback chain - try multiple ways to find an account
+ if account.nil?
+ Rails.logger.info('Looking for first account as fallback')
+ account = Account.first
+
+ if account
+ Rails.logger.info("✅ Found fallback account with ID: #{account.id}")
+ else
+ Rails.logger.error('❌ No accounts exist in the system!')
+ end
+ end
+
+ # Set current account if found
+ if account
+ Current.account = account
+ Rails.logger.info("✅ Current.account set to ID: #{account.id}")
+ end
+ rescue StandardError => e
+ Rails.logger.error("❌ Error setting Current.account: #{e.message}")
+ Rails.logger.error(e.backtrace.first(3).join("\n"))
+ end
+
+ # Make the TwiML response generation as simple as possible
+ response = Twilio::TwiML::VoiceResponse.new do |r|
+ # Log everything about the request
+ # Generate TwiML for agent to join conference
+
+ # SIMPLEST POSSIBLE APPROACH - direct conference connection without any extra audio
+ r.dial do |dial|
+ # Using this conference name in TwiML
+
+ # Get a safe callback URL
+ base_callback_url = if Current.account&.id
+ "#{base_url.gsub(%r{/$}, '')}/api/v1/accounts/#{Current.account.id}/channels/voice/webhooks/conference_status"
+ else
+ "#{base_url.gsub(%r{/$}, '')}/api/v1/accounts/1/channels/voice/webhooks/conference_status"
+ end
+
+ # Agent ID for participant label
+ agent_id = current_user.present? ? current_user.id.to_s : 'unknown-user'
+
+ # Log connection parameters to help debug outbound call issues
+ is_agent = params['is_agent'] == 'true'
+ Rails.logger.info("🔥🔥🔥 AGENT CONNECTING TO CONFERENCE: #{to}, agent_id=#{agent_id}, is_agent=#{is_agent}")
+
+ # CRITICAL: Look for outbound call indicators in URL parameters
+ Rails.logger.info('🚨🚨🚨 DETECTED OUTBOUND CALL OR AGENT CONNECTING') if params['is_outbound'] == 'true' || is_agent
+
+ # Absolute minimal conference parameters for agent joining
+ dial.conference(
+ to,
+ startConferenceOnEnter: true, # Agent joining starts the conference
+ endConferenceOnExit: true, # End when agent leaves
+ muted: false, # Agent can speak
+ beep: false, # No beep sounds
+ waitUrl: '', # No hold music
+ earlyMedia: true, # Enable early media for faster connection
+ statusCallback: base_callback_url,
+ statusCallbackEvent: 'start end join leave',
+ statusCallbackMethod: 'POST',
+ participantLabel: "agent-#{agent_id}"
+ )
+ end
+ end
+
+ # Extra logging to help diagnose issues
+ Rails.logger.info("🎧 TwiML conference parameters for agent: startConferenceOnEnter=true, endConferenceOnExit=true, conference_name=#{to}")
+ Rails.logger.info("🔊 Generated TwiML length: #{response.to_s.length} bytes")
+ # Add more detailed debugging about what we're actually doing
+ Rails.logger.info("🔍 DEBUG: Agent joining as PARTICIPANT to conference '#{to}' with account_id=#{account_id}")
+
+ # Set CORS headers to properly respond to Twilio
+ set_cors_headers
+
+ # Render with proper MIME type
+ render xml: response.to_s, content_type: 'text/xml'
+ rescue StandardError => e
+ # Enhanced error logging
+ Rails.logger.error("💥 ERROR IN TWIML GENERATION: #{e.class.name}: #{e.message}")
+ Rails.logger.error("💥 EXCEPTION BACKTRACE: #{e.backtrace.first(10).join("\n")}")
+ Rails.logger.error("💥 PARAMS AT TIME OF ERROR: #{params.inspect}")
+
+ # Generate a super-simple error response that explains the issue
+ error_response = Twilio::TwiML::VoiceResponse.new
+ error_response.say(message: 'We apologize, but there was a technical issue connecting your call.')
+ error_response.pause(length: 1)
+ error_response.say(message: "The specific error was: #{e.message[0..100]}")
+ error_response.pause(length: 1)
+ error_response.say(message: 'The call will now disconnect. Please try again.')
+ error_response.hangup
+
+ # Set CORS headers
+ set_cors_headers
+
+ render xml: error_response.to_s, content_type: 'text/xml'
end
-
+
# Helper method to render TwiML error response with minimal parameters
def render_twiml_error(message)
- begin
- response = Twilio::TwiML::VoiceResponse.new do |r|
- r.say(message: "Error: #{message}")
- r.hangup
- end
-
- render xml: response.to_s, content_type: 'text/xml'
- rescue => e
- # Last resort error handling
- Rails.logger.error("💥 ERROR IN ERROR HANDLER: #{e.message}")
- render plain: "Error occurred", content_type: 'text/xml'
+ response = Twilio::TwiML::VoiceResponse.new do |r|
+ r.say(message: "Error: #{message}")
+ r.hangup
end
+
+ render xml: response.to_s, content_type: 'text/xml'
+ rescue StandardError => e
+ # Last resort error handling
+ Rails.logger.error("💥 ERROR IN ERROR HANDLER: #{e.message}")
+ render plain: 'Error occurred', content_type: 'text/xml'
end
private
-
+
def fetch_conversation
@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
-
+
# Helper method to get base URL with extra resilience
def base_url
# Try several methods to determine the base URL, with detailed logging
base = nil
source = nil
-
+
begin
# First, try request.base_url if available
if defined?(request) && request&.respond_to?(:base_url) && request.base_url.present?
@@ -537,7 +524,7 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
source = 'request.base_url'
Rails.logger.info("✅ Got base_url from request: #{base}")
end
-
+
# If not available, check for Rails.application.routes.default_url_options
if base.nil? && defined?(Rails) && Rails.application&.routes&.respond_to?(:default_url_options)
options = Rails.application.routes.default_url_options
@@ -549,7 +536,7 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
Rails.logger.info("✅ Got base_url from Rails routes: #{base}")
end
end
-
+
# Check for specific Chatwoot ENV variables
if base.nil?
frontend_url = ENV.fetch('FRONTEND_URL', nil)
@@ -559,24 +546,24 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
Rails.logger.info("✅ Got base_url from FRONTEND_URL env var: #{base}")
end
end
-
+
# Check for additional Chatwoot ENV variables
if base.nil?
api_url = ENV.fetch('API_URL', nil)
if api_url.present?
- base = api_url.to_s.gsub(/\/api\/v\d+\/?$/, '') # Remove API version path if present
+ base = api_url.to_s.gsub(%r{/api/v\d+/?$}, '') # Remove API version path if present
source = 'API_URL env var'
Rails.logger.info("✅ Got base_url from API_URL env var: #{base}")
end
end
-
+
# Try to use Current account domain
if base.nil? && Current.account&.domain.present?
base = "https://#{Current.account.domain}"
source = 'Current.account.domain'
Rails.logger.info("✅ Got base_url from Current.account.domain: #{base}")
end
-
+
# Detect local development environments
if base.nil? && (request&.host == 'localhost' || request&.host&.include?('.local'))
port = request&.port || 3000
@@ -584,7 +571,7 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
source = 'localhost detection'
Rails.logger.info("✅ Detected localhost development: #{base}")
end
-
+
# Ultimate fallback - use either a sojan-local.chatwoot.dev pattern or localhost
if base.nil?
if request&.host.present? && request.host.include?('chatwoot')
@@ -596,23 +583,23 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
end
Rails.logger.info("⚠️ Using fallback base_url: #{base} (source: #{source})")
end
-
+
# Ensure base URL doesn't have a trailing slash
base = base.chomp('/') if base
-
+
# Additional safeguard
- if !base.to_s.match?(/^https?:\/\//)
+ unless base.to_s.match?(%r{^https?://})
base = "http://#{base}"
Rails.logger.warn("⚠️ Added missing protocol to base_url: #{base}")
end
-
+
Rails.logger.info("🌐 FINAL base_url: #{base} (source: #{source})")
base
- rescue => e
+ rescue StandardError => e
# If all else fails, return localhost but log the error
Rails.logger.error("❌ Error determining base URL: #{e.message}")
Rails.logger.error(e.backtrace.first(3).join("\n"))
'http://localhost:3000'
end
end
-end
\ No newline at end of file
+end
diff --git a/app/services/voice/message_update_service.rb b/app/services/voice/message_update_service.rb
index ccc5a8347..6b6ef0ae1 100644
--- a/app/services/voice/message_update_service.rb
+++ b/app/services/voice/message_update_service.rb
@@ -5,28 +5,28 @@ module Voice
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)
@@ -35,18 +35,18 @@ module Voice
else
Rails.logger.error("❌ VOICE CALL STATUS UPDATE FAILED: #{message.errors.full_messages.join(', ')}")
end
- rescue => e
+ 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}")
@@ -55,41 +55,41 @@ module Voice
.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)
-
+ .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")
+ 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(
@@ -101,85 +101,82 @@ module Voice
}
).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 status == 'in-progress' || status == 'active'
+ if %w[in-progress active].include?(status)
# Record the start time if not already set
- if !conversation.additional_attributes['call_started_at']
+ 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'] =
+ 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
-
- # Mark conversation as resolved for ended calls
- conversation.status = :resolved
- Rails.logger.info("✅ MARKING CONVERSATION AS RESOLVED: conversation_id=#{conversation.id}")
+
end
-
+
# Save the conversation
begin
result = conversation.save!
Rails.logger.info("💾 SAVED CONVERSATION SUCCESSFULLY: conversation_id=#{conversation.id}")
- rescue => e
+ 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
@@ -187,7 +184,7 @@ module Voice
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',
@@ -198,10 +195,10 @@ module Voice
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}",
@@ -209,4 +206,4 @@ module Voice
)
end
end
-end
\ No newline at end of file
+end