chore: fixes
This commit is contained in:
+4
-2
@@ -73,7 +73,7 @@ test/cypress/videos/*
|
||||
|
||||
#ignore files under .vscode directory
|
||||
.vscode
|
||||
.cursor
|
||||
|
||||
|
||||
# yalc for local testing
|
||||
.yalc
|
||||
@@ -92,6 +92,8 @@ yarn-debug.log*
|
||||
# https://vitejs.dev/guide/env-and-mode.html#env-files
|
||||
*.local
|
||||
|
||||
# Claude.ai config file
|
||||
# AI Editor config files
|
||||
CLAUDE.md
|
||||
**/.claude/settings.local.json
|
||||
.cursor
|
||||
.windsurf
|
||||
|
||||
@@ -19,8 +19,12 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
|
||||
# Process the call - this handles all the steps
|
||||
conversation = service.process
|
||||
|
||||
# Return the conversation
|
||||
render json: conversation
|
||||
# Assign to @conversation so jbuilder template can access it
|
||||
@conversation = conversation
|
||||
|
||||
# Use the conversation jbuilder template to ensure consistent representation
|
||||
# This will ensure only display_id is used as the id, not the internal database id
|
||||
render 'api/v1/accounts/conversations/show'
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Error initiating call: #{e.message}")
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
|
||||
@@ -71,7 +71,7 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
|
||||
data: {
|
||||
call_sid: call_sid,
|
||||
status: 'completed',
|
||||
conversation_id: @conversation.id,
|
||||
conversation_id: @conversation.display_id,
|
||||
inbox_id: @conversation.inbox_id,
|
||||
timestamp: Time.now.to_i
|
||||
}
|
||||
@@ -103,15 +103,39 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
|
||||
if conference_sid
|
||||
# Using existing conference
|
||||
else
|
||||
# Make sure we have a valid account ID
|
||||
account_id = Current.account&.id || params[:account_id]
|
||||
|
||||
# Make sure the conversation is fully loaded with display_id
|
||||
if @conversation.display_id.blank?
|
||||
@conversation.reload
|
||||
Rails.logger.info("🔄 Reloaded conversation to get display_id")
|
||||
end
|
||||
|
||||
# Extra logging for debugging
|
||||
Rails.logger.info("🔍 Creating conference with account_id=#{account_id}, conversation.display_id=#{@conversation.display_id}")
|
||||
|
||||
# Use the same format as in webhooks_controller for consistency
|
||||
conference_sid = "conf_account_#{Current.account.id}_conv_#{@conversation.display_id}"
|
||||
# Ensure all parts of the conference ID are present
|
||||
if account_id.present? && @conversation.display_id.present?
|
||||
conference_sid = "conf_account_#{account_id}_conv_#{@conversation.display_id}"
|
||||
else
|
||||
# Fallback with more diagnostic information
|
||||
Rails.logger.error("❌ Missing account ID or conversation display ID for conference creation")
|
||||
Rails.logger.error("❌ account_id=#{account_id}, conversation.display_id=#{@conversation.display_id}")
|
||||
# Create a valid conference ID with as much information as we have
|
||||
account_id ||= "unknown"
|
||||
conversation_id = @conversation.display_id || @conversation.id || "unknown"
|
||||
conference_sid = "conf_account_#{account_id}_conv_#{conversation_id}"
|
||||
end
|
||||
|
||||
# Save it for future use
|
||||
@conversation.additional_attributes ||= {}
|
||||
@conversation.additional_attributes['conference_sid'] = conference_sid
|
||||
@conversation.save!
|
||||
|
||||
# Created new conference
|
||||
# Log the created conference
|
||||
Rails.logger.info("🎧 Created new conference: #{conference_sid}")
|
||||
end
|
||||
|
||||
# For outbound calls, ensure we also update call_status if not already set
|
||||
@@ -153,24 +177,21 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
|
||||
data: {
|
||||
call_sid: call_sid,
|
||||
status: 'in-progress',
|
||||
conversation_id: @conversation.id,
|
||||
conversation_id: @conversation.display_id,
|
||||
inbox_id: @conversation.inbox_id,
|
||||
timestamp: Time.now.to_i
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Return conference information for the WebRTC client with detailed logging
|
||||
# Return conference information for the WebRTC client
|
||||
response_data = {
|
||||
status: 'success',
|
||||
message: 'Agent joining call via WebRTC',
|
||||
conference_sid: conference_sid,
|
||||
using_webrtc: true,
|
||||
# Add useful debugging info
|
||||
conversation_id: @conversation.display_id,
|
||||
account_id: Current.account.id,
|
||||
# Add even more debug info
|
||||
conference_name_debug: "#{conference_sid}"
|
||||
account_id: Current.account.id
|
||||
}
|
||||
|
||||
# Return response with conference information
|
||||
@@ -246,129 +267,65 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
|
||||
|
||||
# TwiML endpoint for Twilio Client browser calls - with ultra-robust error handling
|
||||
def twiml_for_client
|
||||
# Log everything for debugging
|
||||
Rails.logger.info("TwiML_FOR_CLIENT CALLED with params: #{params.inspect}")
|
||||
# Extended debugging to trace the request
|
||||
Rails.logger.info("🔄 TwiML_FOR_CLIENT CALLED with params: #{params.inspect}")
|
||||
Rails.logger.info("🔄 Headers: #{request.headers.to_h.select {|k,v| k.start_with?('HTTP_')}.inspect}")
|
||||
Rails.logger.info("🔄 Content-Type: #{request.content_type}")
|
||||
Rails.logger.info("🔄 Raw POST data: #{request.raw_post}")
|
||||
|
||||
# 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]
|
||||
# Check what account we're using
|
||||
account_id_value = params[:account_id]
|
||||
current_account_id = Current.account&.id
|
||||
Rails.logger.info("📞 TwiML account context - params[:account_id]: #{account_id_value}, Current.account.id: #{current_account_id}")
|
||||
|
||||
# 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}")
|
||||
# SUPER DETAILED PARAMETER INSPECTION
|
||||
Rails.logger.info("📞 FULL PARAMS INSPECTION:")
|
||||
params.each do |key, value|
|
||||
Rails.logger.info(" - #{key.inspect} = #{value.inspect}")
|
||||
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_')}")
|
||||
# Check for 'To' parameter in different formats
|
||||
to = params[:To] || params[:to] || params['To'] || params['to']
|
||||
|
||||
# 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}")
|
||||
# IMPORTANT DEBUG: Log all possible parameters that might contain the conference ID
|
||||
Rails.logger.info("📞 Trying to find conference ID in params:")
|
||||
Rails.logger.info(" - params[:To] = #{params[:To].inspect}")
|
||||
Rails.logger.info(" - params[:to] = #{params[:to].inspect}")
|
||||
Rails.logger.info(" - params['To'] = #{params['To'].inspect}")
|
||||
Rails.logger.info(" - params['to'] = #{params['to'].inspect}")
|
||||
|
||||
# Log missing to parameter and try to find the correct conference ID
|
||||
# Also try the Twilio default params format
|
||||
Rails.logger.info(" - params['Twilio-Parameters'] = #{params['Twilio-Parameters'].inspect}")
|
||||
|
||||
# Parse raw POST body for Twilio params
|
||||
if request.post? && request.raw_post.present?
|
||||
begin
|
||||
post_params = Rack::Utils.parse_nested_query(request.raw_post)
|
||||
Rails.logger.info(" - POST body parsed: #{post_params.inspect}")
|
||||
if post_params['To'].present?
|
||||
to ||= post_params['To']
|
||||
Rails.logger.info(" - Found 'To' in POST body: #{post_params['To']}")
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error(" - Error parsing POST body: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
# Now check if we have a valid 'To' parameter
|
||||
if to.blank?
|
||||
# Log the issue clearly
|
||||
Rails.logger.error("🚨 Missing 'To' parameter in request! Trying to find the correct conference ID")
|
||||
Rails.logger.error("❌ Missing 'To' parameter in all possible forms")
|
||||
Rails.logger.error("❌ ALL PARAMS: #{params.inspect}")
|
||||
|
||||
# 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
|
||||
error_response = Twilio::TwiML::VoiceResponse.new
|
||||
error_response.say(message: "Error: Missing conference ID parameter.")
|
||||
error_response.hangup
|
||||
set_cors_headers
|
||||
render xml: error_response.to_s, content_type: 'text/xml'
|
||||
return
|
||||
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
|
||||
# Log the conference ID
|
||||
Rails.logger.info("📞 Using conference ID: '#{to}'")
|
||||
|
||||
# Make the TwiML response generation as simple as possible
|
||||
response = Twilio::TwiML::VoiceResponse.new do |r|
|
||||
@@ -379,19 +336,16 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
|
||||
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
|
||||
# Simple callback URL construction with safe fallback for Current.account
|
||||
account_id = params[:account_id] || (Current.account&.id) || '2' # Use URL param, Current.account, or fallback
|
||||
base_callback_url = "#{base_url.gsub(%r{/$}, '')}/api/v1/accounts/#{account_id}/channels/voice/webhooks/conference_status"
|
||||
|
||||
# Agent ID for participant label
|
||||
agent_id = current_user.present? ? current_user.id.to_s : 'unknown-user'
|
||||
# Use agent_id directly or default to '1'
|
||||
agent_id = params['agent_id'].presence || '1'
|
||||
|
||||
# Log connection parameters to help debug outbound call issues
|
||||
# Log connection parameters
|
||||
is_agent = params['is_agent'] == 'true'
|
||||
Rails.logger.info("🔥🔥🔥 AGENT CONNECTING TO CONFERENCE: #{to}, agent_id=#{agent_id}, is_agent=#{is_agent}")
|
||||
Rails.logger.info("🔥 AGENT CONNECTING: conf=#{to}, agent_id=#{agent_id}")
|
||||
|
||||
# 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
|
||||
@@ -417,7 +371,7 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
|
||||
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}")
|
||||
Rails.logger.info("🔍 DEBUG: Agent joining as PARTICIPANT to conference '#{to}' with account_id=#{params[:account_id]}")
|
||||
|
||||
# Set CORS headers to properly respond to Twilio
|
||||
set_cors_headers
|
||||
@@ -462,100 +416,13 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
|
||||
private
|
||||
|
||||
def fetch_conversation
|
||||
@conversation = Current.account.conversations.find(params[:id] || params[:conversation_id])
|
||||
@conversation = Current.account.conversations.find_by(display_id: params[:conversation_id])
|
||||
end
|
||||
|
||||
# Voice call message related functionality is now handled by Voice::CallStatus::Manager
|
||||
|
||||
# Helper method to get base URL with extra resilience
|
||||
def base_url
|
||||
# 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?
|
||||
base = request.base_url
|
||||
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
|
||||
if options && options[:host].present?
|
||||
protocol = options[:protocol] || 'http'
|
||||
port = options[:port].present? ? ":#{options[:port]}" : ''
|
||||
base = "#{protocol}://#{options[:host]}#{port}"
|
||||
source = 'Rails.application.routes.default_url_options'
|
||||
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)
|
||||
if frontend_url.present?
|
||||
base = frontend_url
|
||||
source = 'FRONTEND_URL env var'
|
||||
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(%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
|
||||
base = "http://#{request.host}:#{port}"
|
||||
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')
|
||||
base = "https://#{request.host}"
|
||||
source = 'request.host fallback for chatwoot domain'
|
||||
else
|
||||
base = 'http://localhost:3000'
|
||||
source = 'localhost hardcoded fallback'
|
||||
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
|
||||
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 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
|
||||
ENV.fetch('FRONTEND_URL', nil)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -195,14 +195,47 @@ class Twilio::VoiceController < ActionController::Base
|
||||
).process_status_update('in-progress', nil, true)
|
||||
|
||||
# IMPORTANT: Use the provided conference_name if available, otherwise use the one from conversation
|
||||
# Make sure conversation has display_id in all cases - we'll need it for validation
|
||||
conversation.reload if conversation.display_id.blank?
|
||||
Rails.logger.info("📝 Conversation details - ID: #{conversation.id}, Display ID: #{conversation.display_id}")
|
||||
|
||||
if conference_name_param.present?
|
||||
# Use the provided conference name
|
||||
# Use the provided conference name from URL parameter
|
||||
conference_name = conference_name_param
|
||||
Rails.logger.info("🚨 USING PROVIDED CONFERENCE NAME: '#{conference_name}'")
|
||||
Rails.logger.info("🔍 USING PROVIDED CONFERENCE NAME FROM PARAM: '#{conference_name}'")
|
||||
|
||||
# Validate format - it should be like 'conf_account_123_conv_456'
|
||||
if !conference_name.match?(/^conf_account_\d+_conv_\d+$/)
|
||||
Rails.logger.warn("⚠️ INCOMING PARAM HAS INVALID CONFERENCE NAME FORMAT: '#{conference_name}'")
|
||||
|
||||
# Generate proper conference name
|
||||
fixed_conference_name = "conf_account_#{inbox.account_id}_conv_#{conversation.display_id}"
|
||||
Rails.logger.info("🔧 CORRECTING CONFERENCE NAME TO: '#{fixed_conference_name}'")
|
||||
conference_name = fixed_conference_name
|
||||
end
|
||||
else
|
||||
# Use the conference name from the conversation
|
||||
conference_name = conversation.additional_attributes['conference_sid']
|
||||
Rails.logger.info("🚨 USING EXISTING CONFERENCE NAME: '#{conference_name}'")
|
||||
# No parameter provided, check conversation data
|
||||
conference_name = conversation.additional_attributes['conference_sid'] ||
|
||||
conversation.additional_attributes['conference_name']
|
||||
|
||||
Rails.logger.info("🔍 CHECKING CONVERSATION FOR CONFERENCE NAME: '#{conference_name}'")
|
||||
|
||||
# If still not found or invalid format, generate it
|
||||
if conference_name.blank? || !conference_name.match?(/^conf_account_\d+_conv_\d+$/)
|
||||
conference_name = "conf_account_#{inbox.account_id}_conv_#{conversation.display_id}"
|
||||
Rails.logger.info("🔧 GENERATING NEW CONFERENCE NAME: '#{conference_name}'")
|
||||
else
|
||||
Rails.logger.info("✅ USING EXISTING CONFERENCE NAME: '#{conference_name}'")
|
||||
end
|
||||
end
|
||||
|
||||
# Double check the conference name format one last time
|
||||
if !conference_name.match?(/^conf_account_\d+_conv_\d+$/)
|
||||
Rails.logger.error("‼️ CRITICAL: Conference name still has invalid format: '#{conference_name}'")
|
||||
|
||||
# Force correct format as last resort
|
||||
conference_name = "conf_account_#{inbox.account_id}_conv_#{conversation.display_id}"
|
||||
Rails.logger.info("🚨 EMERGENCY FIX: Setting conference name to: '#{conference_name}'")
|
||||
end
|
||||
|
||||
# Store the conference name and other required attributes
|
||||
|
||||
@@ -65,7 +65,7 @@ class VoiceAPI extends ApiClient {
|
||||
const conversationId = params.conversation_id || params.conversationId;
|
||||
const callSid = params.call_sid || params.callSid;
|
||||
const accountId = params.account_id;
|
||||
|
||||
|
||||
if (!conversationId) {
|
||||
throw new Error('Conversation ID is required to join a call');
|
||||
}
|
||||
@@ -79,12 +79,12 @@ class VoiceAPI extends ApiClient {
|
||||
call_sid: callSid,
|
||||
conversation_id: conversationId,
|
||||
};
|
||||
|
||||
|
||||
// Add account_id if provided
|
||||
if (accountId) {
|
||||
payload.account_id = accountId;
|
||||
}
|
||||
|
||||
|
||||
console.log('Calling join_call API endpoint with payload:', payload);
|
||||
|
||||
return axios.post(`${this.url}/join_call`, payload);
|
||||
@@ -286,14 +286,24 @@ class VoiceAPI extends ApiClient {
|
||||
throw new Error('Voice is not enabled for this inbox. Check your Twilio configuration.');
|
||||
}
|
||||
|
||||
// Step 2: Create Twilio Device
|
||||
// Store the TwiML endpoint URL for later use
|
||||
this.twimlEndpoint = response.data.twiml_endpoint;
|
||||
|
||||
// Step 2: Create Twilio Device with better options
|
||||
const deviceOptions = {
|
||||
// Use absolute minimal options - less is more for audio compatibility
|
||||
allowIncomingWhileBusy: true, // Allow incoming calls while already on a call
|
||||
debug: true, // Enable debug logging
|
||||
warnings: true, // Show warnings in console
|
||||
// The prebuilt hold music usually interrupts the actual call
|
||||
disableAudioContextSounds: true, // Disable browser audio context for sounds
|
||||
// Add explicit edge parameter - this helps avoid connectivity issues
|
||||
edge: ['ashburn', 'sydney', 'roaming'],
|
||||
// Explicitly set codec preferences
|
||||
codecPreferences: ['opus', 'pcmu'],
|
||||
// Add the account ID to any calls made by this device
|
||||
appParams: {
|
||||
account_id: response.data.account_id,
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Creating Twilio Device with options:', deviceOptions);
|
||||
@@ -467,19 +477,6 @@ class VoiceAPI extends ApiClient {
|
||||
customMessage: error.customMessage,
|
||||
originalError: error.originalError ? JSON.stringify(error.originalError) : 'None'
|
||||
});
|
||||
|
||||
// Make a test HTTP request to the TwiML endpoint to check if it's accessible
|
||||
fetch('/api/v1/accounts/' + (this.activeConnection?.parameters?.account_id || 'current') + '/voice/twiml_for_client')
|
||||
.then(response => {
|
||||
console.log('TwiML endpoint accessibility test result:', {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
statusText: response.statusText
|
||||
});
|
||||
})
|
||||
.catch(fetchError => {
|
||||
console.error('Failed to reach TwiML endpoint:', fetchError);
|
||||
});
|
||||
break;
|
||||
case 31008:
|
||||
console.error('⚠️ Error 31008: Connection Error. The call could not be established.');
|
||||
@@ -703,165 +700,73 @@ class VoiceAPI extends ApiClient {
|
||||
if (!this.device || !this.initialized) {
|
||||
throw new Error('Twilio Device not initialized');
|
||||
}
|
||||
|
||||
// Log the exact conference ID received
|
||||
console.log('Connecting to conference with params:', conferenceParams);
|
||||
console.log('⭐ CONFERENCE ID VALUE:', conferenceParams.To);
|
||||
console.log('⭐ CONFERENCE ID FORMAT CHECK:',
|
||||
conferenceParams.To &&
|
||||
conferenceParams.To.startsWith('conf_account_') &&
|
||||
conferenceParams.To.includes('_conv_') ?
|
||||
'CORRECT ✅' : 'INCORRECT ❌');
|
||||
|
||||
|
||||
try {
|
||||
// IMPORTANT: Do NOT try to register if already registered
|
||||
// Only check state is ready
|
||||
if (this.device.state !== 'ready' && this.device.state !== 'registered') {
|
||||
console.warn('Twilio device not in ready state:', this.device.state);
|
||||
// Don't try to register again if already registered
|
||||
}
|
||||
|
||||
// SUPER MINIMAL PARAMETER APPROACH - explicitly construct the parameters
|
||||
// using exactly the format expected by Twilio
|
||||
const params = {};
|
||||
|
||||
// The 'To' parameter MUST be capitalized for Twilio and is required
|
||||
params.To = conferenceParams.To;
|
||||
|
||||
// The account_id is needed for server-side routing
|
||||
params.account_id = conferenceParams.account_id;
|
||||
|
||||
// ENSURE the conference ID is exactly in the format we expect
|
||||
// conf_account_{account_id}_conv_{conversationId}
|
||||
if (!params.To || !params.To.startsWith('conf_account_') || !params.To.includes('_conv_')) {
|
||||
console.error(`CRITICAL ERROR: Conference ID format is incorrect: '${params.To}'`);
|
||||
console.error('Expected format: conf_account_{account_id}_conv_{conversationId}');
|
||||
throw new Error('Invalid conference ID format. Expected conf_account_{account_id}_conv_{conversationId}');
|
||||
|
||||
// This is CRITICAL for Twilio - params must be formatted exactly right
|
||||
// and passed directly in the format Twilio expects
|
||||
const params = {
|
||||
// REQUIRED: Twilio Voice JS SDK expects 'To' parameter to be a properly formatted string
|
||||
To: `${conferenceParams.To}`,
|
||||
|
||||
// Additional params for our server
|
||||
account_id: conferenceParams.account_id,
|
||||
is_agent: 'true'
|
||||
};
|
||||
|
||||
// Check To parameter exists - fail if missing
|
||||
if (!params.To) {
|
||||
throw new Error('Missing To parameter for conference');
|
||||
}
|
||||
|
||||
// MOST CRITICAL DEBUG OUTPUT - this is exactly what we're sending to Twilio
|
||||
console.log(`⭐⭐⭐ CONNECTING TO CONFERENCE: Conference name='${params.To}', account_id=${params.account_id}`);
|
||||
|
||||
// IMPORTANT: Do NOT modify the conference name - use exactly what was passed
|
||||
// This ensures we use the exact same conference name as created on the server side
|
||||
|
||||
// Connect to the conference - different Twilio SDK versions return different types
|
||||
try {
|
||||
// SIMPLIFIED APPROACH - Just use standard params with capitalized 'To'
|
||||
// No extra URL parameters or fancy options
|
||||
console.log(`⭐⭐⭐ Connecting to conference '${params.To}' with params:`, params);
|
||||
|
||||
const connection = this.device.connect(params);
|
||||
|
||||
// Save the connection to our instance
|
||||
this.activeConnection = connection;
|
||||
|
||||
// Check what kind of connection object we have (Promise vs older non-Promise style)
|
||||
if (connection && typeof connection.then === 'function') {
|
||||
// It's a Promise - newer Twilio SDK version
|
||||
console.log('Using Promise-based Twilio connection - handling async');
|
||||
|
||||
// Return the connection object but also set up Promise handling
|
||||
connection.then(resolvedConnection => {
|
||||
console.log('WebRTC Promise connection resolved successfully');
|
||||
this.activeConnection = resolvedConnection;
|
||||
|
||||
// Try to add listeners if this version supports it
|
||||
try {
|
||||
if (typeof resolvedConnection.on === 'function') {
|
||||
resolvedConnection.on('accept', () => {
|
||||
console.log('✅ Conference connection accepted via Promise');
|
||||
});
|
||||
}
|
||||
} catch (listenerError) {
|
||||
console.warn('Could not add listeners to Promise connection:', listenerError);
|
||||
|
||||
// Make sure 'To' is explicitly a string
|
||||
const stringifiedTo = String(params.To);
|
||||
console.log('🎯 CRITICAL CONFERENCE CONNECTION: Connecting agent to conference with To=', stringifiedTo);
|
||||
|
||||
// Follow Twilio documentation format - params should be nested under 'params' property
|
||||
console.log('🎯 TRYING CONNECTION: Using documented format with params property');
|
||||
|
||||
// Just use the minimal required parameters
|
||||
const connection = this.device.connect({
|
||||
params: {
|
||||
To: stringifiedTo, // Conference ID
|
||||
is_agent: 'true' // Flag to indicate agent is joining
|
||||
}
|
||||
});
|
||||
|
||||
console.log('🎯 CONFERENCE CONNECTION RESULT:', connection ? 'Success' : 'Failed');
|
||||
this.activeConnection = connection;
|
||||
|
||||
if (connection && typeof connection.then === 'function') {
|
||||
// It's a Promise - newer Twilio SDK version
|
||||
connection.then(resolvedConnection => {
|
||||
this.activeConnection = resolvedConnection;
|
||||
try {
|
||||
if (typeof resolvedConnection.on === 'function') {
|
||||
resolvedConnection.on('accept', () => {
|
||||
// Connection accepted
|
||||
});
|
||||
}
|
||||
}).catch(connError => {
|
||||
console.error('WebRTC Promise connection error:', connError);
|
||||
});
|
||||
} else {
|
||||
// It's a synchronous connection - older Twilio SDK
|
||||
console.log('Successfully initiated synchronous connection to conference');
|
||||
}
|
||||
|
||||
return connection;
|
||||
} catch (connectError) {
|
||||
console.error('Error during device.connect():', connectError);
|
||||
throw connectError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error connecting to conference:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// End a client call
|
||||
endClientCall() {
|
||||
console.log('Attempting to end WebRTC call');
|
||||
|
||||
// Check if we have an active connection
|
||||
if (this.activeConnection) {
|
||||
try {
|
||||
// Try to disconnect - handle both Promise and non-Promise interfaces
|
||||
if (typeof this.activeConnection.disconnect === 'function') {
|
||||
console.log('Using Connection.disconnect() method');
|
||||
this.activeConnection.disconnect();
|
||||
} else {
|
||||
// In modern Twilio SDK, might need to use the device
|
||||
console.log('Connection.disconnect not available, using Device');
|
||||
if (this.device && typeof this.device.disconnectAll === 'function') {
|
||||
this.device.disconnectAll();
|
||||
} catch (listenerError) {
|
||||
// Could not add listeners to Promise connection
|
||||
}
|
||||
}
|
||||
|
||||
this.activeConnection = null;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting WebRTC call:', error);
|
||||
// Reset connection anyway
|
||||
this.activeConnection = null;
|
||||
return false;
|
||||
}
|
||||
} else if (this.device) {
|
||||
// Try disconnecting all calls from the device even if no active connection
|
||||
try {
|
||||
if (typeof this.device.disconnectAll === 'function') {
|
||||
this.device.disconnectAll();
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting device calls:', error);
|
||||
}).catch(connError => {
|
||||
// WebRTC Promise connection error
|
||||
});
|
||||
} else {
|
||||
// It's a synchronous connection - older Twilio SDK
|
||||
}
|
||||
return connection;
|
||||
} catch (error) {
|
||||
// Error joining conference
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mute/unmute a client call
|
||||
setMute(isMuted) {
|
||||
console.log(`Attempting to ${isMuted ? 'mute' : 'unmute'} WebRTC call`);
|
||||
|
||||
if (this.activeConnection) {
|
||||
try {
|
||||
// Check if the mute function exists
|
||||
if (typeof this.activeConnection.mute === 'function') {
|
||||
this.activeConnection.mute(isMuted);
|
||||
console.log(`Call ${isMuted ? 'muted' : 'unmuted'} successfully`);
|
||||
return true;
|
||||
} else {
|
||||
console.warn('Connection.mute method not available');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error muting/unmuting WebRTC call:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('No active connection to mute/unmute');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Get the status of the device with additional diagnostic info
|
||||
getDeviceStatus() {
|
||||
if (!this.device) {
|
||||
|
||||
@@ -1044,6 +1044,7 @@ export default {
|
||||
|
||||
// Join a call using the Twilio Client - this is the only option for agents now
|
||||
const joinCallWithWebRTC = async () => {
|
||||
// This is the critical method where an agent joins an incoming call
|
||||
try {
|
||||
// 1. Ensure Twilio device is initialized
|
||||
if (!isWebRTCInitialized.value) {
|
||||
@@ -1075,7 +1076,7 @@ export default {
|
||||
};
|
||||
let accountId = extractAccountId();
|
||||
|
||||
// --- Step 4: Inform server agent is joining (non-blocking, but store response) ---
|
||||
// --- Step 4: Inform server agent is joining and get conference_sid ---
|
||||
let serverResponse = null;
|
||||
try {
|
||||
const response = await VoiceAPI.joinCall({
|
||||
@@ -1084,41 +1085,49 @@ export default {
|
||||
account_id: accountId,
|
||||
});
|
||||
serverResponse = response.data;
|
||||
|
||||
// Process the server response to get the conference_sid
|
||||
if (serverResponse && serverResponse.conference_sid) {
|
||||
// Save the conference_sid in the incomingCall data
|
||||
if (!incomingCall.value.conference_sid) {
|
||||
// Save the conference_sid in only the key places needed
|
||||
incomingCall.value.conference_sid = serverResponse.conference_sid;
|
||||
|
||||
// Also set the 'To' parameter required by Twilio
|
||||
incomingCall.value.To = serverResponse.conference_sid;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} catch (apiError) {
|
||||
// Continue anyway, as we might still be able to join the conference
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5. Proactively fix audio issues
|
||||
await fixAudioBeforeCall();
|
||||
|
||||
// --- Conference ID extraction helper ---
|
||||
// Simple conference ID extraction - using ONE source of truth
|
||||
const extractConferenceId = () => {
|
||||
// Priority: incomingCall.conference_sid > serverResponse.conference_sid > alt server fields > generated
|
||||
let confId = incomingCall.value?.conference_sid;
|
||||
if (!confId && serverResponse) {
|
||||
confId =
|
||||
serverResponse.conference_sid ||
|
||||
serverResponse.conferenceId ||
|
||||
serverResponse.conference_name;
|
||||
}
|
||||
if (!confId && incomingCall.value) {
|
||||
const isOutbound = incomingCall.value.isOutbound === true;
|
||||
if (isOutbound && incomingCall.value.conference_sid) {
|
||||
confId = incomingCall.value.conference_sid;
|
||||
}
|
||||
if (!confId && accountId && conversationId) {
|
||||
confId = `conf_account_${accountId}_conv_${conversationId}`;
|
||||
}
|
||||
// Get conference_sid from incoming call data - this is the SINGLE source of truth
|
||||
const confId = incomingCall.value?.conference_sid;
|
||||
|
||||
if (!confId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return confId;
|
||||
};
|
||||
const conferenceId = extractConferenceId();
|
||||
if (!conferenceId) return false;
|
||||
|
||||
// --- Twilio requires 'To' (capital T) and lowercase account_id ---
|
||||
// Ensure conferenceId is a string
|
||||
const conferenceIdString = String(conferenceId);
|
||||
|
||||
// Simple params object with the required fields in the correct format
|
||||
const enhancedParams = {
|
||||
To: conferenceId,
|
||||
To: conferenceIdString, // CAPITAL T is required for Twilio and MUST be a string
|
||||
account_id: accountId,
|
||||
is_agent: 'true' // Flag that this is an agent joining
|
||||
};
|
||||
|
||||
// 6. Re-initialize device if needed
|
||||
@@ -1142,7 +1151,7 @@ export default {
|
||||
if (window.activeAudioStream) {
|
||||
window.activeAudioStream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
|
||||
// Request a new stream with HIGH-QUALITY audio
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
@@ -1155,15 +1164,15 @@ export default {
|
||||
sampleSize: { ideal: 16 }
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Save the stream globally
|
||||
window.activeAudioStream = stream;
|
||||
|
||||
|
||||
// Ensure tracks are active and enabled
|
||||
stream.getAudioTracks().forEach(track => {
|
||||
track.enabled = true;
|
||||
});
|
||||
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Fall back to basic audio to ensure we at least have something
|
||||
|
||||
@@ -203,17 +203,20 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
conversationId: data.conversation_id,
|
||||
inboxId: data.inbox_id,
|
||||
inboxName: data.inbox_name,
|
||||
inboxAvatarUrl: data.inbox_avatar_url, // Inbox avatar URL
|
||||
inboxPhoneNumber: data.inbox_phone_number, // Inbox phone number
|
||||
contactName: data.contact_name || 'Unknown Caller', // Add fallback name
|
||||
inboxAvatarUrl: data.inbox_avatar_url,
|
||||
inboxPhoneNumber: data.inbox_phone_number,
|
||||
contactName: data.contact_name || 'Unknown Caller',
|
||||
contactId: data.contact_id,
|
||||
accountId: data.account_id,
|
||||
isOutbound: data.is_outbound || false, // Check if this is an outbound call requiring agent join
|
||||
conference_sid: data.conference_sid, // Pass the conference_sid directly to the floating widget
|
||||
requiresAgentJoin: data.requires_agent_join || false, // Flag for calls needing immediate agent join
|
||||
callDirection: data.call_direction, // Add call direction for additional context
|
||||
phoneNumber: data.phone_number, // Include phone number for display in the UI
|
||||
avatarUrl: data.avatar_url // Include avatar URL for display in the UI
|
||||
isOutbound: data.is_outbound || false,
|
||||
// CRITICAL: Use 'conference_sid' in camelCase format to match field names
|
||||
conference_sid: data.conference_sid,
|
||||
conferenceId: data.conference_sid, // Add aliases for consistency
|
||||
conferenceSid: data.conference_sid, // Add aliases for consistency
|
||||
requiresAgentJoin: data.requires_agent_join || false,
|
||||
callDirection: data.call_direction,
|
||||
phoneNumber: data.phone_number,
|
||||
avatarUrl: data.avatar_url
|
||||
};
|
||||
|
||||
// Update store
|
||||
@@ -234,37 +237,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
inboxId: data.inbox_id,
|
||||
timestamp: data.timestamp || Date.now()
|
||||
};
|
||||
|
||||
// Update store with call status change
|
||||
// Only dispatch to Vuex; Vuex handles widget and call state
|
||||
this.app.$store.dispatch('calls/handleCallStatusChanged', normalizedPayload);
|
||||
|
||||
// For terminal statuses, clear the active call to close the widget
|
||||
if (['ended', 'missed', 'completed', 'failed', 'busy', 'no_answer'].includes(data.status)) {
|
||||
// Clear active call for terminal statuses
|
||||
this.app.$store.dispatch('calls/clearActiveCall');
|
||||
|
||||
// Ensure window.app.$data exists before modifying it
|
||||
if (window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
|
||||
// Update conversation list to show current status
|
||||
if (data.conversation_id) {
|
||||
this.app.$store.dispatch('updateConversationLastActivity', {
|
||||
conversationId: data.conversation_id,
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Also ensure that the conversation gets refreshed
|
||||
this.app.$store.dispatch('fetchConversation', {
|
||||
id: data.conversation_id
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Update active call for non-terminal statuses
|
||||
this.app.$store.dispatch('calls/setActiveCall', normalizedPayload);
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -12,36 +12,41 @@ const getters = {
|
||||
|
||||
const actions = {
|
||||
// This action will handle both message updates and direct call status changes
|
||||
/**
|
||||
* Handles all call status changes from ActionCable.
|
||||
* Closes the widget and clears state for terminal statuses.
|
||||
* Only this action should manipulate widget visibility for call end.
|
||||
*/
|
||||
handleCallStatusChanged({ state, dispatch }, { callSid, status }) {
|
||||
// Check if this is the active call
|
||||
// Debug logging for conference call widget close issue
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[CALL DEBUG] handleCallStatusChanged invoked', { callSid, status, activeCall: state.activeCall });
|
||||
const isActiveCall = callSid === state.activeCall?.callSid;
|
||||
const isOutboundCall = state.activeCall?.isOutbound === true;
|
||||
|
||||
// If this is the active call and it has ended or was missed, close the widget
|
||||
if (isActiveCall &&
|
||||
(status === 'ended' || status === 'missed' || status === 'completed')) {
|
||||
console.log('Call status changed to:', status, 'isOutbound:', isOutboundCall);
|
||||
|
||||
// Clear the active call
|
||||
const terminalStatuses = [
|
||||
'ended',
|
||||
'missed',
|
||||
'completed',
|
||||
'failed',
|
||||
'busy',
|
||||
'no_answer',
|
||||
];
|
||||
if (isActiveCall && terminalStatuses.includes(status)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[CALL DEBUG] Terminal status match. Closing widget.', { callSid, status, activeCall: state.activeCall });
|
||||
// Clean up active call state
|
||||
dispatch('clearActiveCall');
|
||||
|
||||
// Force update app state to hide widget
|
||||
// Hide floating widget reactively
|
||||
if (window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
|
||||
// Emit event to notify components
|
||||
// Emit event for any listeners
|
||||
if (window.app) {
|
||||
window.app.$emit('callEnded');
|
||||
}
|
||||
|
||||
// For outbound calls, also clear any pending state
|
||||
if (isOutboundCall) {
|
||||
// Additional cleanup for outbound calls
|
||||
if (window.globalCallStatus) {
|
||||
window.globalCallStatus.active = false;
|
||||
window.globalCallStatus.incoming = false;
|
||||
}
|
||||
// Outbound call cleanup
|
||||
if (state.activeCall?.isOutbound && window.globalCallStatus) {
|
||||
window.globalCallStatus.active = false;
|
||||
window.globalCallStatus.incoming = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -42,8 +42,34 @@ class Channel::Voice < ApplicationRecord
|
||||
# Start building query parameters
|
||||
query_params = []
|
||||
|
||||
# Add conference name as a parameter if provided
|
||||
query_params << "conference_name=#{CGI.escape(conference_name)}" if conference_name.present?
|
||||
# Make sure conference_name is URL-safe and correctly formatted
|
||||
if conference_name.present?
|
||||
# Check format - it should be like 'conf_account_123_conv_456'
|
||||
if !conference_name.match?(/^conf_account_\d+_conv_\d+$/)
|
||||
# If format is wrong, log an error and try to fix it
|
||||
Rails.logger.error("🚨 MALFORMED CONFERENCE NAME: '#{conference_name}'")
|
||||
|
||||
# Try to extract account_id and conversation_id from the string if possible
|
||||
if conference_name.include?('_account_') && conference_name.include?('_conv_')
|
||||
# It has the parts but wrong format, let's try to keep it
|
||||
Rails.logger.info("🔄 Using conference name as-is: '#{conference_name}'")
|
||||
else
|
||||
# Can't salvage it, generate a placeholder with timestamp to avoid collisions
|
||||
timestamp = Time.now.to_i
|
||||
Rails.logger.warn("🚨 GENERATING PLACEHOLDER CONFERENCE NAME with timestamp #{timestamp}")
|
||||
conference_name = "conf_placeholder_#{timestamp}"
|
||||
end
|
||||
else
|
||||
# Format looks good, continue
|
||||
Rails.logger.info("✅ VALIDATED CONFERENCE NAME: '#{conference_name}'")
|
||||
end
|
||||
|
||||
# Add URL-encoded conference name as a parameter
|
||||
query_params << "conference_name=#{CGI.escape(conference_name)}"
|
||||
else
|
||||
# No conference name provided, log this as a warning
|
||||
Rails.logger.warn("⚠️ NO CONFERENCE NAME PROVIDED for outgoing call to #{to}")
|
||||
end
|
||||
|
||||
# Add agent ID as a parameter if provided
|
||||
query_params << "agent_id=#{agent_id}" if agent_id.present?
|
||||
@@ -51,7 +77,7 @@ class Channel::Voice < ApplicationRecord
|
||||
# Append query parameters to URL if any exist
|
||||
if query_params.any?
|
||||
callback_url += "?#{query_params.join('&')}"
|
||||
Rails.logger.info("🚨 OUTBOUND CALL: Using callback URL with params: #{callback_url}")
|
||||
Rails.logger.info("📞 OUTBOUND CALL: Using callback URL with params: #{callback_url}")
|
||||
end
|
||||
|
||||
# Parameters including status callbacks for call progress tracking
|
||||
@@ -64,6 +90,9 @@ class Channel::Voice < ApplicationRecord
|
||||
status_callback_method: 'POST'
|
||||
}
|
||||
|
||||
# Log the full parameters for debugging
|
||||
Rails.logger.info("📞 OUTBOUND CALL PARAMS: to=#{to}, from=#{phone_number}, conference=#{conference_name}")
|
||||
|
||||
# Create the call
|
||||
call = twilio_client(config).calls.create(**params)
|
||||
|
||||
@@ -74,7 +103,8 @@ class Channel::Voice < ApplicationRecord
|
||||
status: call.status,
|
||||
call_direction: 'outbound', # CRITICAL: Tag as outbound so webhooks know to prompt agent
|
||||
requires_agent_join: true, # Flag that agent should join immediately
|
||||
agent_id: agent_id # Include agent_id for tracking who initiated the call
|
||||
agent_id: agent_id, # Include agent_id for tracking who initiated the call
|
||||
conference_name: conference_name # Include the conference name in the return value for debugging
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ module Voice
|
||||
# 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}'")
|
||||
Rails.logger.info("🔄 [CallStatusManager] Updating call status for conversation #{conversation.display_id}: '#{internal_status}'")
|
||||
|
||||
# Validate status
|
||||
unless VALID_STATUSES.include?(internal_status)
|
||||
@@ -489,7 +489,7 @@ module Voice
|
||||
# This ensures the conversation list shows consistent status texts
|
||||
ui_status = normalized_ui_status(status)
|
||||
|
||||
Rails.logger.info("📢 [CallStatusManager] Broadcasting UI status: '#{ui_status}' for conversation_id=#{conversation.id}")
|
||||
Rails.logger.info("📢 [CallStatusManager] Broadcasting UI status: '#{ui_status}' for conversation_id=#{conversation.display_id}")
|
||||
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{conversation.account_id}",
|
||||
@@ -498,7 +498,7 @@ module Voice
|
||||
data: {
|
||||
call_sid: call_sid,
|
||||
status: ui_status, # Send UI-friendly status
|
||||
conversation_id: conversation.id,
|
||||
conversation_id: conversation.display_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
timestamp: Time.now.to_i
|
||||
}
|
||||
|
||||
@@ -34,6 +34,29 @@ module Voice
|
||||
|
||||
# Save conversation changes
|
||||
conversation.save!
|
||||
|
||||
# Ensure ActionCable broadcast for terminal call states (for debugging and reliability)
|
||||
if %w[conference-end participant-leave].include?(event)
|
||||
current_status = conversation.additional_attributes['call_status']
|
||||
if %w[completed ended missed busy failed no-answer canceled].include?(current_status)
|
||||
# Defensive: broadcast call_status_changed event to ensure frontend is notified
|
||||
ui_status = Voice::CallStatus::Manager.new(conversation: conversation, call_sid: call_sid, provider: :twilio).normalized_ui_status(current_status)
|
||||
Rails.logger.info("📢 [ConferenceManagerService] Forcing ActionCable broadcast: call_status_changed (call_sid=#{call_sid}, status=#{ui_status}) for conversation_id=#{conversation.display_id}")
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{conversation.account_id}",
|
||||
{
|
||||
event_name: 'call_status_changed',
|
||||
data: {
|
||||
call_sid: call_sid,
|
||||
status: ui_status,
|
||||
conversation_id: conversation.display_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
timestamp: Time.now.to_i
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
@@ -269,6 +292,22 @@ module Voice
|
||||
|
||||
Rails.logger.info('📞 MARKING CALL AS COMPLETED (all participants left)')
|
||||
call_status_manager.process_status_update('completed', duration, false, 'Call ended')
|
||||
# Defensive: Immediately broadcast ActionCable event for reliability
|
||||
ui_status = call_status_manager.normalized_ui_status('completed')
|
||||
Rails.logger.info("📢 [ConferenceManagerService] Broadcasting call_status_changed (call_sid=#{call_sid}, status=#{ui_status}) after all participants left for conversation_id=#{conversation.display_id}")
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{conversation.account_id}",
|
||||
{
|
||||
event_name: 'call_status_changed',
|
||||
data: {
|
||||
call_sid: call_sid,
|
||||
status: ui_status,
|
||||
conversation_id: conversation.display_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
timestamp: Time.now.to_i
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
# Participant tracking methods
|
||||
@@ -309,7 +348,22 @@ module Voice
|
||||
!participants.values.any? { |p| p['status'] == 'joined' }
|
||||
end
|
||||
|
||||
# Activity messages are now handled by the call_status_manager through the
|
||||
# process_status_update method, which takes a custom_message parameter.
|
||||
def broadcast_call_status_changed
|
||||
ui_status = call_status_manager.normalized_ui_status('completed')
|
||||
Rails.logger.info("📢 [ConferenceManagerService] Broadcasting call_status_changed (call_sid=#{call_sid}, status=#{ui_status}) after all participants left for conversation_id=#{conversation.display_id}")
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{conversation.account_id}",
|
||||
{
|
||||
event_name: 'call_status_changed',
|
||||
data: {
|
||||
call_sid: call_sid,
|
||||
status: ui_status,
|
||||
conversation_id: conversation.display_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
timestamp: Time.now.to_i
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -168,7 +168,7 @@ module Voice
|
||||
# Create the data payload
|
||||
broadcast_data = {
|
||||
call_sid: info[:call_sid],
|
||||
conversation_id: conversation.id,
|
||||
conversation_id: conversation.display_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
inbox_name: conversation.inbox.name,
|
||||
inbox_avatar_url: inbox.avatar_url, # Include inbox avatar
|
||||
|
||||
@@ -58,6 +58,9 @@ module Voice
|
||||
additional_attributes: initial_attributes
|
||||
)
|
||||
|
||||
# Need to reload conversation to get the display_id populated by the database
|
||||
conversation.reload
|
||||
|
||||
# Add conference_sid to attributes
|
||||
conference_name = generate_conference_name(conversation)
|
||||
conversation.additional_attributes['conference_sid'] = conference_name
|
||||
|
||||
@@ -88,6 +88,9 @@ module Voice
|
||||
}
|
||||
)
|
||||
|
||||
# Need to reload conversation to get the display_id populated by the database
|
||||
@conversation.reload
|
||||
|
||||
# Set up conference name
|
||||
conference_name = "conf_account_#{account.id}_conv_#{@conversation.display_id}"
|
||||
@conversation.additional_attributes['conference_sid'] = conference_name
|
||||
@@ -115,7 +118,7 @@ module Voice
|
||||
data: {
|
||||
call_sid: caller_info[:call_sid],
|
||||
status: ui_status, # Use normalized UI status
|
||||
conversation_id: @conversation.id,
|
||||
conversation_id: @conversation.display_id,
|
||||
call_direction: 'inbound',
|
||||
conference_sid: @conversation.additional_attributes['conference_sid'],
|
||||
from_number: caller_info[:from_number],
|
||||
@@ -162,24 +165,25 @@ module Voice
|
||||
def broadcast_call_status
|
||||
# Get contact name, ensuring we have a valid value
|
||||
contact_name_value = @contact.name.presence || caller_info[:from_number]
|
||||
|
||||
|
||||
# Create the data payload
|
||||
broadcast_data = {
|
||||
call_sid: caller_info[:call_sid],
|
||||
conversation_id: @conversation.id,
|
||||
conversation_id: @conversation.display_id,
|
||||
inbox_id: @inbox.id,
|
||||
inbox_name: @inbox.name,
|
||||
inbox_avatar_url: @inbox.avatar_url, # Include inbox avatar
|
||||
inbox_phone_number: @inbox.channel.phone_number, # Include inbox phone number
|
||||
inbox_avatar_url: @inbox.avatar_url,
|
||||
inbox_phone_number: @inbox.channel.phone_number,
|
||||
contact_name: contact_name_value,
|
||||
contact_id: @contact.id,
|
||||
account_id: account.id,
|
||||
phone_number: @contact.phone_number, # Include phone number for display in UI
|
||||
avatar_url: @contact.avatar_url, # Include avatar URL for display in UI
|
||||
call_direction: 'inbound' # Add call direction for context
|
||||
phone_number: @contact.phone_number,
|
||||
avatar_url: @contact.avatar_url,
|
||||
call_direction: 'inbound',
|
||||
# CRITICAL: Include the conference_sid
|
||||
conference_sid: @conversation.additional_attributes['conference_sid']
|
||||
}
|
||||
|
||||
|
||||
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{account.id}",
|
||||
{
|
||||
|
||||
@@ -39,11 +39,49 @@ module Voice
|
||||
call_sid: nil # This will be set after call is initiated
|
||||
).perform
|
||||
|
||||
# Create conference name for outbound call
|
||||
# Need to reload conversation to get the display_id populated by the database
|
||||
@conversation.reload
|
||||
|
||||
# Log the conversation ID and display_id for debugging
|
||||
Rails.logger.info("🔍 OUTGOING CALL: Created conversation with ID=#{@conversation.id}, display_id=#{@conversation.display_id}")
|
||||
|
||||
# The conference_sid should be set by the ConversationFinderService, but we double-check
|
||||
@conference_name = @conversation.additional_attributes['conference_sid']
|
||||
|
||||
# Verify conference name is valid, if not, fix it
|
||||
if @conference_name.blank? || !@conference_name.match?(/^conf_account_\d+_conv_\d+$/)
|
||||
# Generate proper conference name
|
||||
@conference_name = "conf_account_#{account.id}_conv_#{@conversation.display_id}"
|
||||
|
||||
# Store it in the conversation
|
||||
@conversation.additional_attributes['conference_sid'] = @conference_name
|
||||
@conversation.save!
|
||||
|
||||
Rails.logger.info("🔧 OUTGOING CALL: Fixed conference name to #{@conference_name}")
|
||||
else
|
||||
Rails.logger.info("✅ OUTGOING CALL: Using existing conference name #{@conference_name}")
|
||||
end
|
||||
end
|
||||
|
||||
def initiate_call
|
||||
# Double-check that we have a valid conference name before calling
|
||||
if @conference_name.blank? || !@conference_name.match?(/^conf_account_\d+_conv_\d+$/)
|
||||
Rails.logger.error("❌ OUTGOING CALL: Invalid conference name before initiating call: #{@conference_name}")
|
||||
|
||||
# Re-generate the conference name as a last resort
|
||||
@conference_name = "conf_account_#{account.id}_conv_#{@conversation.display_id}"
|
||||
Rails.logger.info("🔧 OUTGOING CALL: Re-generated conference name: #{@conference_name}")
|
||||
|
||||
# Update the conversation with the new conference name
|
||||
@conversation.additional_attributes['conference_sid'] = @conference_name
|
||||
@conversation.save!
|
||||
else
|
||||
Rails.logger.info("✅ OUTGOING CALL: Valid conference name: #{@conference_name}")
|
||||
end
|
||||
|
||||
# Log that we're about to initiate the call
|
||||
Rails.logger.info("📞 OUTGOING CALL: Initiating call to #{contact.phone_number} with conference #{@conference_name}")
|
||||
|
||||
# Initiate the call using the channel's implementation
|
||||
@call_details = @voice_inbox.channel.initiate_call(
|
||||
to: contact.phone_number,
|
||||
@@ -51,15 +89,27 @@ module Voice
|
||||
agent_id: user.id # Pass the agent ID to track who initiated the call
|
||||
)
|
||||
|
||||
# Update conversation with call details, but don't set status
|
||||
# Status will be properly set by CallStatusManager
|
||||
updated_attributes = @conversation.additional_attributes.merge({
|
||||
'call_sid' => @call_details[:call_sid],
|
||||
'requires_agent_join' => true,
|
||||
'agent_id' => user.id # Store the agent ID who initiated the call
|
||||
})
|
||||
# Log the returned call details for debugging
|
||||
Rails.logger.info("📞 OUTGOING CALL: Call initiated with details: #{@call_details.inspect}")
|
||||
|
||||
# Update conversation with call details, but don't set status
|
||||
# Status will be properly set by CallStatusManager
|
||||
updated_attributes = @conversation.additional_attributes.merge({
|
||||
'call_sid' => @call_details[:call_sid],
|
||||
'requires_agent_join' => true,
|
||||
'agent_id' => user.id, # Store the agent ID who initiated the call
|
||||
'conference_sid' => @conference_name, # Ensure conference_sid is set correctly
|
||||
'conference_name' => @conference_name, # Add an additional field for backwards compatibility
|
||||
})
|
||||
|
||||
# Ensure the call is marked as outbound
|
||||
updated_attributes['call_direction'] = 'outbound'
|
||||
|
||||
# Save the updated attributes
|
||||
@conversation.update!(additional_attributes: updated_attributes)
|
||||
|
||||
# Log the final conversation state
|
||||
Rails.logger.info("📞 OUTGOING CALL: Conversation updated with call_sid=#{@call_details[:call_sid]}, conference_sid=#{@conference_name}")
|
||||
end
|
||||
|
||||
def create_voice_call_message
|
||||
@@ -82,7 +132,7 @@ module Voice
|
||||
data: {
|
||||
call_sid: @call_details[:call_sid],
|
||||
status: ui_status, # Set the normalized UI status
|
||||
conversation_id: @conversation.id,
|
||||
conversation_id: @conversation.display_id,
|
||||
call_direction: 'outbound',
|
||||
conference_sid: @conference_name,
|
||||
from_number: @voice_inbox.channel.phone_number,
|
||||
@@ -129,7 +179,7 @@ module Voice
|
||||
# Create the data payload
|
||||
broadcast_data = {
|
||||
call_sid: @call_details[:call_sid],
|
||||
conversation_id: @conversation.id,
|
||||
conversation_id: @conversation.display_id,
|
||||
inbox_id: @voice_inbox.id,
|
||||
inbox_name: @voice_inbox.name,
|
||||
inbox_avatar_url: @voice_inbox.avatar_url, # Include inbox avatar
|
||||
|
||||
Reference in New Issue
Block a user