diff --git a/.gitignore b/.gitignore index 7ebd3a8b4..52e8e8d24 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/controllers/api/v1/accounts/contacts/calls_controller.rb b/app/controllers/api/v1/accounts/contacts/calls_controller.rb index 9f39ab568..36cf77d26 100644 --- a/app/controllers/api/v1/accounts/contacts/calls_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/calls_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/voice_controller.rb b/app/controllers/api/v1/accounts/voice_controller.rb index 0b61d893d..92fab4270 100644 --- a/app/controllers/api/v1/accounts/voice_controller.rb +++ b/app/controllers/api/v1/accounts/voice_controller.rb @@ -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 diff --git a/app/controllers/twilio/voice_controller.rb b/app/controllers/twilio/voice_controller.rb index ec9d14efa..069e3c5d2 100644 --- a/app/controllers/twilio/voice_controller.rb +++ b/app/controllers/twilio/voice_controller.rb @@ -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 diff --git a/app/javascript/dashboard/api/channels/voice.js b/app/javascript/dashboard/api/channels/voice.js index 6ccab7080..9a6f20975 100644 --- a/app/javascript/dashboard/api/channels/voice.js +++ b/app/javascript/dashboard/api/channels/voice.js @@ -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) { diff --git a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue index 0a3e5abd8..e87e127c3 100644 --- a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue +++ b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue @@ -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 diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index 842f33d1e..298bfd45b 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -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 { diff --git a/app/javascript/dashboard/store/modules/calls.js b/app/javascript/dashboard/store/modules/calls.js index 5f53f980b..a1d5a662f 100644 --- a/app/javascript/dashboard/store/modules/calls.js +++ b/app/javascript/dashboard/store/modules/calls.js @@ -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; } } }, diff --git a/app/models/channel/voice.rb b/app/models/channel/voice.rb index b3ecaa5b6..d57b07bf3 100644 --- a/app/models/channel/voice.rb +++ b/app/models/channel/voice.rb @@ -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 diff --git a/app/services/voice/call_status/manager.rb b/app/services/voice/call_status/manager.rb index 6c6d58d3c..93b76010a 100644 --- a/app/services/voice/call_status/manager.rb +++ b/app/services/voice/call_status/manager.rb @@ -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 } diff --git a/app/services/voice/conference_manager_service.rb b/app/services/voice/conference_manager_service.rb index fcaaaba1c..0f981ba4a 100644 --- a/app/services/voice/conference_manager_service.rb +++ b/app/services/voice/conference_manager_service.rb @@ -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 \ No newline at end of file diff --git a/app/services/voice/conference_status_service.rb b/app/services/voice/conference_status_service.rb index 77432efbb..92e36b2ce 100644 --- a/app/services/voice/conference_status_service.rb +++ b/app/services/voice/conference_status_service.rb @@ -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 diff --git a/app/services/voice/conversation_finder_service.rb b/app/services/voice/conversation_finder_service.rb index 1ec8548e9..de465aa4e 100644 --- a/app/services/voice/conversation_finder_service.rb +++ b/app/services/voice/conversation_finder_service.rb @@ -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 diff --git a/app/services/voice/incoming_call_service.rb b/app/services/voice/incoming_call_service.rb index 628bd8a81..3b66a78c0 100644 --- a/app/services/voice/incoming_call_service.rb +++ b/app/services/voice/incoming_call_service.rb @@ -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}", { diff --git a/app/services/voice/outgoing_call_service.rb b/app/services/voice/outgoing_call_service.rb index 40d83de9e..1a2467187 100644 --- a/app/services/voice/outgoing_call_service.rb +++ b/app/services/voice/outgoing_call_service.rb @@ -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