diff --git a/app/controllers/api/v1/accounts/voice_controller.rb b/app/controllers/api/v1/accounts/voice_controller.rb index 92fab4270..5127b96e3 100644 --- a/app/controllers/api/v1/accounts/voice_controller.rb +++ b/app/controllers/api/v1/accounts/voice_controller.rb @@ -1,428 +1,215 @@ require 'twilio-ruby' class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController - before_action :fetch_conversation, only: [:end_call, :join_call, :reject_call] - skip_before_action :authenticate_user!, only: [:twiml_for_client] - # Removed skip_before_action :verify_authenticity_token (it's not defined in BaseController) - protect_from_forgery with: :null_session, only: [:twiml_for_client] - before_action :handle_options_request, only: [:twiml_for_client] + before_action :fetch_conversation, only: %i[end_call join_call reject_call] + skip_before_action :authenticate_user!, only: :twiml_for_client + protect_from_forgery with: :null_session, only: :twiml_for_client - # Handle CORS preflight OPTIONS requests - def handle_options_request - if request.method == 'OPTIONS' - set_cors_headers - head :ok - return true - end - false - end + before_action :render_options, if: -> { request.options? } + after_action :set_cors_headers, if: -> { action_name == 'twiml_for_client' } - def set_cors_headers - # Add explicit Content-Type header to ensure browser requests are handled properly - headers['Content-Type'] = 'text/xml; charset=utf-8' unless request.method == 'OPTIONS' - - # Standard CORS headers - headers['Access-Control-Allow-Origin'] = '*' - headers['Access-Control-Allow-Methods'] = 'POST, GET, OPTIONS' - headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Twilio-Signature' - headers['Access-Control-Max-Age'] = '86400' # 24 hours - - # Log headers for debugging - Rails.logger.info("🚨 RESPONSE HEADERS SET: #{headers.to_h.inspect}") - end - - # No hard-coded credentials - we'll fetch them from the channel + # ---------- PUBLIC ACTIONS -------------------------------------------------- def end_call - call_sid = params[:call_sid] || @conversation.additional_attributes&.dig('call_sid') - return render json: { error: 'No active call found' }, status: :not_found unless call_sid + call_sid = params[:call_sid] || convo_attr('call_sid') + return render_not_found('active call') unless call_sid - # Get the channel config - channel = @conversation.inbox.channel - config = channel.provider_config_hash + twilio_client.calls(call_sid).update(status: 'completed') if in_progress?(call_sid) - # Create a Twilio client using credentials from the channel - client = Twilio::REST::Client.new(config['account_sid'], config['auth_token']) - call = client.calls(call_sid).fetch + Voice::CallStatus::Manager.new(conversation: @conversation, + call_sid: call_sid, + provider: :twilio) + .process_status_update('completed', nil, false, "Call ended by #{current_user.name}") - # Only try to end the call if it's still in progress - if call.status == 'in-progress' || call.status == 'ringing' - client.calls(call_sid).update(status: 'completed') - - # Update call status using the unified CallStatusManager - # The CallStatusManager will determine if the call is outbound internally - # and will create an appropriate activity message - custom_message = "Call ended by #{current_user.name}" - - status_manager = Voice::CallStatus::Manager.new( - conversation: @conversation, - call_sid: call_sid, - provider: :twilio - ) - status_manager.process_status_update('completed', nil, false, custom_message) - - # No need to create additional activity messages - the manager handles it - - # Broadcast call status update on the account channel - ActionCable.server.broadcast( - "account_#{@conversation.account_id}", - { - event_name: 'call_status_changed', - data: { - call_sid: call_sid, - status: 'completed', - conversation_id: @conversation.display_id, - inbox_id: @conversation.inbox_id, - timestamp: Time.now.to_i - } - } - ) - - render json: { status: 'success', message: 'Call successfully ended' } - else - render json: { status: 'success', message: "Call already in '#{call.status}' state" } - end + broadcast_status(call_sid, 'completed') + render_success('Call successfully ended') rescue StandardError => e - render json: { error: "Failed to end call: #{e.message}" }, status: :internal_server_error + render_error("Failed to end call: #{e.message}") end def join_call - call_sid = params[:call_sid] || @conversation.additional_attributes&.dig('call_sid') + call_sid = params[:call_sid] || convo_attr('call_sid') + outbound = convo_attr('requires_agent_join') == true - # Check if this is an outbound call that needs to be joined (might not have call_sid yet) - is_outbound_call = @conversation.additional_attributes&.dig('requires_agent_join') == true + return render_not_found('active call') unless call_sid || outbound - return render json: { error: 'No active call found' }, status: :not_found unless call_sid || is_outbound_call + conference_sid = convo_attr('conference_sid') || create_conference_sid! + update_join_metadata!(call_sid) + broadcast_status(call_sid, 'in-progress') - # Get the conference SID from the conversation - conference_sid = @conversation.additional_attributes&.dig('conference_sid') - - # Check conversation record for conference information - - # If not found, create one using account ID and conversation display ID - 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 - # 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! - - # 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 - if is_outbound_call && !@conversation.additional_attributes['call_status'] - @conversation.additional_attributes['call_status'] = 'in-progress' - @conversation.save! - # Set call status for outbound call - end - - # Agent joining call via WebRTC - - # Update conversation to show agent joined - @conversation.additional_attributes['agent_joined'] = true - @conversation.additional_attributes['joined_at'] = Time.now.to_i - @conversation.additional_attributes['joined_by'] = { - id: current_user.id, - name: current_user.name - } - - # Update call status using the unified CallStatusManager with custom message - # The CallStatusManager will determine if the call is outbound internally - custom_message = "#{current_user.name} joined the call" - - status_manager = Voice::CallStatus::Manager.new( - conversation: @conversation, - call_sid: call_sid, - provider: :twilio - ) - status_manager.process_status_update('in-progress', nil, false, custom_message) - - # Save the conversation with agent join details - @conversation.save! - - # Broadcast call status update on the account channel - ActionCable.server.broadcast( - "account_#{@conversation.account_id}", - { - event_name: 'call_status_changed', - data: { - call_sid: call_sid, - status: 'in-progress', - conversation_id: @conversation.display_id, - inbox_id: @conversation.inbox_id, - timestamp: Time.now.to_i - } - } - ) - - # Return conference information for the WebRTC client - response_data = { - status: 'success', - message: 'Agent joining call via WebRTC', + render json: { + status: 'success', + message: 'Agent joining call via WebRTC', conference_sid: conference_sid, - using_webrtc: true, + using_webrtc: true, conversation_id: @conversation.display_id, - account_id: Current.account.id + account_id: Current.account.id } - - # Return response with conference information - - render json: response_data rescue StandardError => e - Rails.logger.error("Error joining call: #{e.message}") - render json: { error: "Failed to join call: #{e.message}" }, status: :internal_server_error + render_error("Failed to join call: #{e.message}") end def reject_call - call_sid = params[:call_sid] || @conversation.additional_attributes&.dig('call_sid') - return render json: { error: 'No active call found' }, status: :not_found unless call_sid + call_sid = params[:call_sid] || convo_attr('call_sid') + return render_not_found('active call') unless call_sid - # Update conversation to show agent rejected call - @conversation.additional_attributes['agent_rejected'] = true - @conversation.additional_attributes['rejected_at'] = Time.now.to_i - @conversation.additional_attributes['rejected_by'] = { - id: current_user.id, - name: current_user.name - } - @conversation.save! + @conversation.update!(additional_attributes: convo_attrs.merge( + 'agent_rejected' => true, + 'rejected_at' => Time.current.to_i, + 'rejected_by' => user_meta + )) - # Update call status and create activity message through the unified manager - custom_message = "#{current_user.name} declined to answer" - - status_manager = Voice::CallStatus::Manager.new( - conversation: @conversation, - call_sid: call_sid, - provider: :twilio - ) - status_manager.create_activity_message(custom_message, { - call_sid: call_sid, - rejected_by: current_user.name, - rejected_at: Time.now.to_i - }) + Voice::CallStatus::Manager.new(conversation: @conversation, + call_sid: call_sid, + provider: :twilio) + .create_activity_message("#{current_user.name} declined to answer", + rejected_by: current_user.name, + rejected_at: Time.current.to_i) - render json: { - status: 'success', - message: 'Call rejected by agent' - } + render_success('Call rejected by agent') end def call_status call_sid = params[:call_sid] - return render json: { error: 'No active call found' }, status: :not_found unless call_sid + return render_not_found('active call') unless call_sid - conversation = Current.account.conversations.where("additional_attributes->>'call_sid' = ?", call_sid).first - return render json: { error: 'Conversation not found' }, status: :not_found unless conversation - - # Get the channel config - channel = conversation.inbox.channel - config = channel.provider_config_hash - - begin - # Create a Twilio client using credentials from the channel - client = Twilio::REST::Client.new(config['account_sid'], config['auth_token']) - call = client.calls(call_sid).fetch - - render json: { - status: call.status, - duration: call.duration, - direction: call.direction, - from: call.from, - to: call.to, - start_time: call.start_time, - end_time: call.end_time - } - rescue StandardError => e - render json: { error: "Failed to fetch call status: #{e.message}" }, status: :internal_server_error - end + call = twilio_client.calls(call_sid).fetch + render json: call.slice(:status, :duration, :direction, :from, :to, :start_time, :end_time) + rescue StandardError => e + render_error("Failed to fetch call status: #{e.message}") end - # TwiML endpoint for Twilio Client browser calls - with ultra-robust error handling + # TwiML for agent WebRTC dial‑in def twiml_for_client - # 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}") + to = params[:To] || params[:to] + return render_twiml_error('Missing conference ID parameter') if to.blank? - # 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}") - - # SUPER DETAILED PARAMETER INSPECTION - Rails.logger.info("πŸ“ž FULL PARAMS INSPECTION:") - params.each do |key, value| - Rails.logger.info(" - #{key.inspect} = #{value.inspect}") - end - - # Check for 'To' parameter in different formats - to = params[:To] || params[:to] || params['To'] || params['to'] - - # 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}") - - # 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? - Rails.logger.error("❌ Missing 'To' parameter in all possible forms") - Rails.logger.error("❌ ALL PARAMS: #{params.inspect}") - - 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 - - # 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| - # Log everything about the request - # Generate TwiML for agent to join conference - - # SIMPLEST POSSIBLE APPROACH - direct conference connection without any extra audio - r.dial do |dial| - # Using this conference name in TwiML - - # 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" - - # Use agent_id directly or default to '1' - agent_id = params['agent_id'].presence || '1' - - # Log connection parameters - is_agent = params['is_agent'] == 'true' - 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 - - # Absolute minimal conference parameters for agent joining - dial.conference( - to, - startConferenceOnEnter: true, # Agent joining starts the conference - endConferenceOnExit: true, # End when agent leaves - muted: false, # Agent can speak - beep: false, # No beep sounds - waitUrl: '', # No hold music - earlyMedia: true, # Enable early media for faster connection - statusCallback: base_callback_url, - statusCallbackEvent: 'start end join leave', - statusCallbackMethod: 'POST', - participantLabel: "agent-#{agent_id}" - ) - end - end - - # Extra logging to help diagnose issues - Rails.logger.info("🎧 TwiML conference parameters for agent: startConferenceOnEnter=true, endConferenceOnExit=true, conference_name=#{to}") - Rails.logger.info("πŸ”Š Generated TwiML length: #{response.to_s.length} bytes") - # Add more detailed debugging about what we're actually doing - Rails.logger.info("πŸ” DEBUG: Agent joining as PARTICIPANT to conference '#{to}' with account_id=#{params[:account_id]}") - - # Set CORS headers to properly respond to Twilio - set_cors_headers - - # Render with proper MIME type - render xml: response.to_s, content_type: 'text/xml' + render xml: build_twiml(to), content_type: 'text/xml' rescue StandardError => e - # Enhanced error logging - Rails.logger.error("πŸ’₯ ERROR IN TWIML GENERATION: #{e.class.name}: #{e.message}") - Rails.logger.error("πŸ’₯ EXCEPTION BACKTRACE: #{e.backtrace.first(10).join("\n")}") - Rails.logger.error("πŸ’₯ PARAMS AT TIME OF ERROR: #{params.inspect}") - - # Generate a super-simple error response that explains the issue - error_response = Twilio::TwiML::VoiceResponse.new - error_response.say(message: 'We apologize, but there was a technical issue connecting your call.') - error_response.pause(length: 1) - error_response.say(message: "The specific error was: #{e.message[0..100]}") - error_response.pause(length: 1) - error_response.say(message: 'The call will now disconnect. Please try again.') - error_response.hangup - - # Set CORS headers - set_cors_headers - - render xml: error_response.to_s, content_type: 'text/xml' + render_twiml_error(e.message) end - # Helper method to render TwiML error response with minimal parameters - def render_twiml_error(message) - response = Twilio::TwiML::VoiceResponse.new do |r| - r.say(message: "Error: #{message}") - r.hangup - end - - render xml: response.to_s, content_type: 'text/xml' - rescue StandardError => e - # Last resort error handling - Rails.logger.error("πŸ’₯ ERROR IN ERROR HANDLER: #{e.message}") - render plain: 'Error occurred', content_type: 'text/xml' - end + # ---------- PRIVATE --------------------------------------------------------- private + # ---- Helpers --------------------------------------------------------------- + + def render_options + head :ok + end + + def set_cors_headers + headers['Content-Type'] ||= 'text/xml; charset=utf-8' + headers['Access-Control-Allow-Origin'] = '*' + headers['Access-Control-Allow-Methods'] = 'POST, GET, OPTIONS' + headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Twilio-Signature' + headers['Access-Control-Max-Age'] = '86400' + end + + def render_success(msg) = render json: { status: 'success', message: msg } + def render_not_found(resource) = render json: { error: "No #{resource} found" }, status: :not_found + def render_error(msg) = render json: { error: msg }, status: :internal_server_error + def fetch_conversation @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 - ENV.fetch('FRONTEND_URL', nil) + def twilio_client + @twilio_client ||= begin + cfg = @conversation.inbox.channel.provider_config_hash + Twilio::REST::Client.new(cfg['account_sid'], cfg['auth_token']) + end end -end + + def in_progress?(call_sid) + %w[in-progress ringing].include?(twilio_client.calls(call_sid).fetch.status) + end + + def convo_attrs + @conversation.additional_attributes || {} + end + + def convo_attr(key) + convo_attrs[key] + end + + def user_meta + { id: current_user.id, name: current_user.name } + end + + def create_conference_sid! + sid = "conf_account_#{Current.account.id}_conv_#{@conversation.display_id}" + @conversation.update!(additional_attributes: convo_attrs.merge('conference_sid' => sid)) + sid + end + + def update_join_metadata!(call_sid) + @conversation.update!(additional_attributes: convo_attrs.merge( + 'agent_joined' => true, + 'joined_at' => Time.current.to_i, + 'joined_by' => user_meta, + 'call_status' => 'in-progress' + )) + + Voice::CallStatus::Manager.new(conversation: @conversation, + call_sid: call_sid, + provider: :twilio) + .process_status_update('in-progress', nil, false, "#{current_user.name} joined the call") + end + + def broadcast_status(call_sid, status) + ActionCable.server.broadcast "account_#{@conversation.account_id}", { + event_name: 'call_status_changed', + data: { + call_sid: call_sid, + status: status, + conversation_id: @conversation.display_id, + inbox_id: @conversation.inbox_id, + timestamp: Time.current.to_i + } + } + end + + # ---- TwiML ----------------------------------------------------------------- + + def build_twiml(conference_name) + Twilio::TwiML::VoiceResponse.new do |r| + r.dial do |dial| + dial.conference( + conference_name, + startConferenceOnEnter: true, + endConferenceOnExit: true, + muted: false, + beep: false, + waitUrl: '', + earlyMedia: true, + statusCallback: conference_callback_url, + statusCallbackEvent: 'start end join leave', + statusCallbackMethod: 'POST', + participantLabel: "agent-#{params[:agent_id] || current_user.id}" + ) + end + end.to_s + end + + def conference_callback_url + account_id = params[:account_id] || Current.account&.id + "#{base_url.chomp('/')}/api/v1/accounts/#{account_id}/channels/voice/webhooks/conference_status" + end + + def base_url + ENV.fetch('FRONTEND_URL', '') + end + + # ---- TwiML Error ----------------------------------------------------------- + + def render_twiml_error(message) + response = Twilio::TwiML::VoiceResponse.new do |r| + r.say(message: "Error: #{message}") + r.hangup + end + set_cors_headers + render xml: response.to_s, content_type: 'text/xml' + end +end \ No newline at end of file diff --git a/app/services/voice/call_status/manager.rb b/app/services/voice/call_status/manager.rb index 93b76010a..241b36fdf 100644 --- a/app/services/voice/call_status/manager.rb +++ b/app/services/voice/call_status/manager.rb @@ -518,10 +518,6 @@ module Voice ) end end - - # NOTE: This method is no longer used - conversation updates are handled - # directly in the broadcast_status_change method for terminal call states. - end end end