mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-11-03 20:48:07 +00:00 
			
		
		
		
	chore: clean up
This commit is contained in:
		@@ -1,19 +1,19 @@
 | 
				
			|||||||
class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts::BaseController
 | 
					class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts::BaseController
 | 
				
			||||||
  skip_before_action :authenticate_user!, :set_current_user, only: [:incoming, :conference_status]
 | 
					  skip_before_action :authenticate_user!, :set_current_user, only: [:incoming, :conference_status, :call_status]
 | 
				
			||||||
  protect_from_forgery with: :null_session, only: [:incoming, :conference_status]
 | 
					  protect_from_forgery with: :null_session, only: [:incoming, :conference_status, :call_status]
 | 
				
			||||||
  before_action :validate_twilio_signature, only: [:incoming]
 | 
					  before_action :validate_twilio_signature, only: [:incoming, :call_status]
 | 
				
			||||||
  before_action :handle_options_request, only: [:incoming, :conference_status]
 | 
					  before_action :handle_options_request, only: [:incoming, :conference_status, :call_status]
 | 
				
			||||||
  
 | 
					
 | 
				
			||||||
  # Handle CORS preflight OPTIONS requests
 | 
					  # Handle CORS preflight OPTIONS requests
 | 
				
			||||||
  def handle_options_request
 | 
					  def handle_options_request
 | 
				
			||||||
    if request.method == "OPTIONS"
 | 
					    if request.method == 'OPTIONS'
 | 
				
			||||||
      set_cors_headers
 | 
					      set_cors_headers
 | 
				
			||||||
      head :ok
 | 
					      head :ok
 | 
				
			||||||
      return true
 | 
					      return true
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
    false
 | 
					    false
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
  
 | 
					
 | 
				
			||||||
  def set_cors_headers
 | 
					  def set_cors_headers
 | 
				
			||||||
    headers['Access-Control-Allow-Origin'] = '*'
 | 
					    headers['Access-Control-Allow-Origin'] = '*'
 | 
				
			||||||
    headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS'
 | 
					    headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS'
 | 
				
			||||||
@@ -25,78 +25,121 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts
 | 
				
			|||||||
  def incoming
 | 
					  def incoming
 | 
				
			||||||
    # Set CORS headers first to ensure they're included
 | 
					    # Set CORS headers first to ensure they're included
 | 
				
			||||||
    set_cors_headers
 | 
					    set_cors_headers
 | 
				
			||||||
    
 | 
					
 | 
				
			||||||
    # Log basic request info
 | 
					 | 
				
			||||||
    Rails.logger.info("🔔 INCOMING CALL WEBHOOK: CallSid=#{params['CallSid']} From=#{params['From']} To=#{params['To']}")
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Process incoming call using service
 | 
					    # Process incoming call using service
 | 
				
			||||||
    begin
 | 
					    begin
 | 
				
			||||||
      # Ensure account is set properly
 | 
					      # Ensure account is set properly
 | 
				
			||||||
      if !Current.account && params[:account_id].present?
 | 
					      Current.account = Account.find(params[:account_id]) if !Current.account && params[:account_id].present?
 | 
				
			||||||
        Current.account = Account.find(params[:account_id])
 | 
					
 | 
				
			||||||
        Rails.logger.info("👑 Set Current.account to #{Current.account.id}")
 | 
					 | 
				
			||||||
      end
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      # Validate required parameters
 | 
					      # Validate required parameters
 | 
				
			||||||
      validate_incoming_params
 | 
					      validate_incoming_params
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
      # Process the call
 | 
					      # Process the call
 | 
				
			||||||
      service = Voice::IncomingCallService.new(
 | 
					      service = Voice::IncomingCallService.new(
 | 
				
			||||||
        account: Current.account, 
 | 
					        account: Current.account,
 | 
				
			||||||
        params: params.to_unsafe_h.merge(host_with_port: request.host_with_port)
 | 
					        params: params.to_unsafe_h.merge(host_with_port: request.host_with_port)
 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
      twiml_response = service.process
 | 
					      twiml_response = service.process
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
      # Return TwiML response
 | 
					      # Return TwiML response
 | 
				
			||||||
      Rails.logger.info("✅ INCOMING CALL: Successfully processed")
 | 
					 | 
				
			||||||
      render xml: twiml_response
 | 
					      render xml: twiml_response
 | 
				
			||||||
    rescue StandardError => e
 | 
					    rescue StandardError => e
 | 
				
			||||||
      # Log the error with detailed information
 | 
					      # Log the error with detailed information
 | 
				
			||||||
      Rails.logger.error("❌ INCOMING CALL ERROR: #{e.message}")
 | 
					      Rails.logger.error("Incoming call error: #{e.message}")
 | 
				
			||||||
      Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}")
 | 
					
 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      # Return friendly error message to caller
 | 
					      # Return friendly error message to caller
 | 
				
			||||||
      render_error("We're sorry, but we're experiencing technical difficulties. Please try your call again later.")
 | 
					      render_error("We're sorry, but we're experiencing technical difficulties. Please try your call again later.")
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  # Handle individual call status updates
 | 
				
			||||||
 | 
					  def call_status
 | 
				
			||||||
 | 
					    # Set CORS headers first to ensure they're always included
 | 
				
			||||||
 | 
					    set_cors_headers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Return immediately for OPTIONS requests
 | 
				
			||||||
 | 
					    return head :ok if request.method == 'OPTIONS'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Process call status updates
 | 
				
			||||||
 | 
					    begin
 | 
				
			||||||
 | 
					      # Set account for local development if needed
 | 
				
			||||||
 | 
					      Current.account = Account.find(params[:account_id]) if !Current.account && params[:account_id].present?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      # Find conversation by CallSid
 | 
				
			||||||
 | 
					      call_sid = params['CallSid']
 | 
				
			||||||
 | 
					      # For dial action callbacks, use DialCallStatus; fallback to CallStatus for other types
 | 
				
			||||||
 | 
					      call_status = params['DialCallStatus'] || params['CallStatus']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if call_sid.present? && call_status.present?
 | 
				
			||||||
 | 
					        conversation = Current.account.conversations.where("additional_attributes->>'call_sid' = ?", call_sid).first
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if conversation
 | 
				
			||||||
 | 
					          # Use CallStatusManager to handle the status update
 | 
				
			||||||
 | 
					          status_manager = Voice::CallStatus::Manager.new(
 | 
				
			||||||
 | 
					            conversation: conversation,
 | 
				
			||||||
 | 
					            call_sid: call_sid,
 | 
				
			||||||
 | 
					            provider: :twilio
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          # Map Twilio call/dial statuses to our statuses and update
 | 
				
			||||||
 | 
					          case call_status.downcase
 | 
				
			||||||
 | 
					          when 'completed', 'busy', 'failed', 'no-answer', 'canceled'
 | 
				
			||||||
 | 
					            # Standard call status values
 | 
				
			||||||
 | 
					            if conversation.additional_attributes['call_status'] == 'ringing'
 | 
				
			||||||
 | 
					              status_manager.process_status_update('no_answer')
 | 
				
			||||||
 | 
					            else
 | 
				
			||||||
 | 
					              status_manager.process_status_update('ended')
 | 
				
			||||||
 | 
					            end
 | 
				
			||||||
 | 
					          when 'answered'
 | 
				
			||||||
 | 
					            # DialCallStatus: conference calls return 'answered' when successful
 | 
				
			||||||
 | 
					            # No action needed - call continues in conference
 | 
				
			||||||
 | 
					          else
 | 
				
			||||||
 | 
					            # Handle any other dial statuses (busy, no-answer, failed from dial action)
 | 
				
			||||||
 | 
					            if conversation.additional_attributes['call_status'] == 'ringing'
 | 
				
			||||||
 | 
					              status_manager.process_status_update('no_answer')
 | 
				
			||||||
 | 
					            else
 | 
				
			||||||
 | 
					              status_manager.process_status_update('ended')
 | 
				
			||||||
 | 
					            end
 | 
				
			||||||
 | 
					          end
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      # Call status processed successfully
 | 
				
			||||||
 | 
					    rescue StandardError => e
 | 
				
			||||||
 | 
					      # Log errors but don't affect the response
 | 
				
			||||||
 | 
					      Rails.logger.error("Call status error: #{e.message}")
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Always return a successful response for Twilio
 | 
				
			||||||
 | 
					    head :ok
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  # Handle conference status updates
 | 
					  # Handle conference status updates
 | 
				
			||||||
  def conference_status
 | 
					  def conference_status
 | 
				
			||||||
    # Set CORS headers first to ensure they're always included
 | 
					    # Set CORS headers first to ensure they're always included
 | 
				
			||||||
    set_cors_headers
 | 
					    set_cors_headers
 | 
				
			||||||
    
 | 
					
 | 
				
			||||||
    # Return immediately for OPTIONS requests
 | 
					    # Return immediately for OPTIONS requests
 | 
				
			||||||
    if request.method == "OPTIONS"
 | 
					    return head :ok if request.method == 'OPTIONS'
 | 
				
			||||||
      return head :ok
 | 
					
 | 
				
			||||||
    end
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Log basic request info
 | 
					 | 
				
			||||||
    Rails.logger.info("🎧 CONFERENCE STATUS WEBHOOK: ConferenceSid=#{params['ConferenceSid']} Event=#{params['StatusCallbackEvent']}")
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Process conference status updates using service
 | 
					    # Process conference status updates using service
 | 
				
			||||||
    begin
 | 
					    begin
 | 
				
			||||||
      # Set account for local development if needed
 | 
					      # Set account for local development if needed
 | 
				
			||||||
      if !Current.account && params[:account_id].present?
 | 
					      Current.account = Account.find(params[:account_id]) if !Current.account && params[:account_id].present?
 | 
				
			||||||
        Current.account = Account.find(params[:account_id])
 | 
					
 | 
				
			||||||
        Rails.logger.info("👑 Set Current.account to #{Current.account.id}")
 | 
					      # Validate required parameters - need either ConferenceSid or CallSid
 | 
				
			||||||
      end
 | 
					      return head :ok if params['ConferenceSid'].blank? && params['CallSid'].blank?
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
      # Validate required parameters
 | 
					 | 
				
			||||||
      if params['ConferenceSid'].blank? && params['CallSid'].blank?
 | 
					 | 
				
			||||||
        Rails.logger.error("❌ MISSING REQUIRED PARAMS: Need either ConferenceSid or CallSid")
 | 
					 | 
				
			||||||
      end
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      # Use service to process conference status
 | 
					      # Use service to process conference status
 | 
				
			||||||
      service = Voice::ConferenceStatusService.new(account: Current.account, params: params)
 | 
					      service = Voice::ConferenceStatusService.new(account: Current.account, params: params)
 | 
				
			||||||
      service.process
 | 
					      service.process
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
      Rails.logger.info("✅ CONFERENCE STATUS: Successfully processed")
 | 
					      # Conference status processed successfully
 | 
				
			||||||
    rescue StandardError => e
 | 
					    rescue StandardError => e
 | 
				
			||||||
      # Log errors but don't affect the response
 | 
					      # Log errors but don't affect the response
 | 
				
			||||||
      Rails.logger.error("❌ CONFERENCE STATUS ERROR: #{e.message}")
 | 
					      Rails.logger.error("Conference status error: #{e.message}")
 | 
				
			||||||
      Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}")
 | 
					 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
    
 | 
					
 | 
				
			||||||
    # Always return a successful response for Twilio
 | 
					    # Always return a successful response for Twilio
 | 
				
			||||||
    head :ok
 | 
					    head :ok
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
@@ -104,43 +147,34 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts
 | 
				
			|||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def validate_incoming_params
 | 
					  def validate_incoming_params
 | 
				
			||||||
    if params['CallSid'].blank?
 | 
					    raise 'Missing required parameter: CallSid' if params['CallSid'].blank?
 | 
				
			||||||
      raise "Missing required parameter: CallSid"
 | 
					
 | 
				
			||||||
    end
 | 
					    raise 'Missing required parameter: From' if params['From'].blank?
 | 
				
			||||||
    
 | 
					
 | 
				
			||||||
    if params['From'].blank?
 | 
					    raise 'Missing required parameter: To' if params['To'].blank?
 | 
				
			||||||
      raise "Missing required parameter: From"
 | 
					
 | 
				
			||||||
    end
 | 
					    return unless Current.account.nil?
 | 
				
			||||||
    
 | 
					
 | 
				
			||||||
    if params['To'].blank?
 | 
					    raise 'Current account not set'
 | 
				
			||||||
      raise "Missing required parameter: To"
 | 
					 | 
				
			||||||
    end
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    if Current.account.nil?
 | 
					 | 
				
			||||||
      raise "Current account not set"
 | 
					 | 
				
			||||||
    end
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def validate_twilio_signature
 | 
					  def validate_twilio_signature
 | 
				
			||||||
    begin
 | 
					    validator = Voice::TwilioValidatorService.new(
 | 
				
			||||||
      validator = Voice::TwilioValidatorService.new(
 | 
					      account: Current.account,
 | 
				
			||||||
        account: Current.account,
 | 
					      params: params,
 | 
				
			||||||
        params: params,
 | 
					      request: request
 | 
				
			||||||
        request: request
 | 
					    )
 | 
				
			||||||
      )
 | 
					
 | 
				
			||||||
      
 | 
					    unless validator.valid?
 | 
				
			||||||
      if !validator.valid?
 | 
					      render_error('Invalid Twilio signature')
 | 
				
			||||||
        Rails.logger.error("❌ INVALID TWILIO SIGNATURE")
 | 
					 | 
				
			||||||
        render_error('Invalid Twilio signature')
 | 
					 | 
				
			||||||
        return false
 | 
					 | 
				
			||||||
      end
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      return true
 | 
					 | 
				
			||||||
    rescue StandardError => e
 | 
					 | 
				
			||||||
      Rails.logger.error("❌ TWILIO VALIDATION ERROR: #{e.message}")
 | 
					 | 
				
			||||||
      render_error('Error validating Twilio request')
 | 
					 | 
				
			||||||
      return false
 | 
					      return false
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return true
 | 
				
			||||||
 | 
					  rescue StandardError => e
 | 
				
			||||||
 | 
					    Rails.logger.error("Twilio validation error: #{e.message}")
 | 
				
			||||||
 | 
					    render_error('Error validating Twilio request')
 | 
				
			||||||
 | 
					    return false
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def render_error(message)
 | 
					  def render_error(message)
 | 
				
			||||||
@@ -149,4 +183,4 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts
 | 
				
			|||||||
    response.hangup
 | 
					    response.hangup
 | 
				
			||||||
    render xml: response.to_s
 | 
					    render xml: response.to_s
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -21,7 +21,6 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
 | 
				
			|||||||
                                   provider:    :twilio)
 | 
					                                   provider:    :twilio)
 | 
				
			||||||
                              .process_status_update('completed', nil, false, "Call ended by #{current_user.name}")
 | 
					                              .process_status_update('completed', nil, false, "Call ended by #{current_user.name}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    broadcast_status(call_sid, 'completed')
 | 
					 | 
				
			||||||
    render_success('Call successfully ended')
 | 
					    render_success('Call successfully ended')
 | 
				
			||||||
  rescue StandardError => e
 | 
					  rescue StandardError => e
 | 
				
			||||||
    render_error("Failed to end call: #{e.message}")
 | 
					    render_error("Failed to end call: #{e.message}")
 | 
				
			||||||
@@ -35,7 +34,6 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    conference_sid = convo_attr('conference_sid') || create_conference_sid!
 | 
					    conference_sid = convo_attr('conference_sid') || create_conference_sid!
 | 
				
			||||||
    update_join_metadata!(call_sid)
 | 
					    update_join_metadata!(call_sid)
 | 
				
			||||||
    broadcast_status(call_sid, 'in-progress')
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    render json: {
 | 
					    render json: {
 | 
				
			||||||
      status:         'success',
 | 
					      status:         'success',
 | 
				
			||||||
@@ -158,18 +156,6 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
 | 
				
			|||||||
                              .process_status_update('in_progress', nil, false, "#{current_user.name} joined the call")
 | 
					                              .process_status_update('in_progress', nil, false, "#{current_user.name} joined the call")
 | 
				
			||||||
  end
 | 
					  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 -----------------------------------------------------------------
 | 
					  # ---- TwiML -----------------------------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -49,6 +49,26 @@ class VoiceAPI extends ApiClient {
 | 
				
			|||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // End the client-side WebRTC call connection
 | 
				
			||||||
 | 
					  endClientCall() {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      if (this.activeConnection) {
 | 
				
			||||||
 | 
					        console.log('📞 Ending client WebRTC call connection');
 | 
				
			||||||
 | 
					        this.activeConnection.disconnect();
 | 
				
			||||||
 | 
					        this.activeConnection = null;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      if (this.device && this.device.state === 'busy') {
 | 
				
			||||||
 | 
					        console.log('📞 Disconnecting all device connections');
 | 
				
			||||||
 | 
					        this.device.disconnectAll();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.warn('⚠️ Error ending client call:', error);
 | 
				
			||||||
 | 
					      // Clear the connection reference even if disconnect failed
 | 
				
			||||||
 | 
					      this.activeConnection = null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Get call status
 | 
					  // Get call status
 | 
				
			||||||
  getCallStatus(callSid) {
 | 
					  getCallStatus(callSid) {
 | 
				
			||||||
    if (!callSid) {
 | 
					    if (!callSid) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,7 +20,6 @@ const {
 | 
				
			|||||||
  isAWhatsAppChannel,
 | 
					  isAWhatsAppChannel,
 | 
				
			||||||
  isAnEmailChannel,
 | 
					  isAnEmailChannel,
 | 
				
			||||||
  isAnInstagramChannel,
 | 
					  isAnInstagramChannel,
 | 
				
			||||||
  isAVoiceChannel,
 | 
					 | 
				
			||||||
} = useInbox();
 | 
					} = useInbox();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const {
 | 
					const {
 | 
				
			||||||
@@ -42,8 +41,6 @@ const showStatusIndicator = computed(() => {
 | 
				
			|||||||
  if (status.value === MESSAGE_STATUS.FAILED) return false;
 | 
					  if (status.value === MESSAGE_STATUS.FAILED) return false;
 | 
				
			||||||
  // Don't show status for deleted messages
 | 
					  // Don't show status for deleted messages
 | 
				
			||||||
  if (contentAttributes.value?.deleted) return false;
 | 
					  if (contentAttributes.value?.deleted) return false;
 | 
				
			||||||
  // Don't show status for transcription messages
 | 
					 | 
				
			||||||
  if (isAVoiceChannel.value) return false;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (messageType.value === MESSAGE_TYPES.OUTGOING) return true;
 | 
					  if (messageType.value === MESSAGE_TYPES.OUTGOING) return true;
 | 
				
			||||||
  if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true;
 | 
					  if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1528,16 +1528,16 @@ export default {
 | 
				
			|||||||
        <div class="flex items-center">
 | 
					        <div class="flex items-center">
 | 
				
			||||||
          <!-- Left side with inbox avatar and call info -->
 | 
					          <!-- Left side with inbox avatar and call info -->
 | 
				
			||||||
          <div class="inbox-avatar">
 | 
					          <div class="inbox-avatar">
 | 
				
			||||||
            <!-- Use the inbox avatar if available -->
 | 
					            <!-- Use the inbox avatar if available and valid -->
 | 
				
			||||||
            <img
 | 
					            <img
 | 
				
			||||||
              v-if="inboxAvatarUrl"
 | 
					              v-if="inboxAvatarUrl && inboxAvatarUrl.startsWith('http')"
 | 
				
			||||||
              :src="inboxAvatarUrl"
 | 
					              :src="inboxAvatarUrl"
 | 
				
			||||||
              :alt="inboxDisplayName"
 | 
					              :alt="inboxDisplayName"
 | 
				
			||||||
              class="avatar-image"
 | 
					              class="avatar-image"
 | 
				
			||||||
              @error="handleAvatarError"
 | 
					              @error="handleAvatarError"
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
            <!-- Fallback to initial if no avatar -->
 | 
					            <!-- Fallback to phone icon for voice channels -->
 | 
				
			||||||
            <span v-else>{{ inboxDisplayName.charAt(0).toUpperCase() }}</span>
 | 
					            <i v-else class="i-ri-phone-fill text-white text-lg"></i>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <div class="header-info">
 | 
					          <div class="header-info">
 | 
				
			||||||
            <div class="voice-label">{{ inboxDisplayName }}</div>
 | 
					            <div class="voice-label">{{ inboxDisplayName }}</div>
 | 
				
			||||||
@@ -1707,7 +1707,7 @@ export default {
 | 
				
			|||||||
        <span class="inbox-name">
 | 
					        <span class="inbox-name">
 | 
				
			||||||
          <!-- Add inbox avatar to the header -->
 | 
					          <!-- Add inbox avatar to the header -->
 | 
				
			||||||
          <img
 | 
					          <img
 | 
				
			||||||
            v-if="inboxAvatarUrl"
 | 
					            v-if="inboxAvatarUrl && inboxAvatarUrl.startsWith('http')"
 | 
				
			||||||
            :src="inboxAvatarUrl"
 | 
					            :src="inboxAvatarUrl"
 | 
				
			||||||
            :alt="inboxDisplayName"
 | 
					            :alt="inboxDisplayName"
 | 
				
			||||||
            class="inline-avatar"
 | 
					            class="inline-avatar"
 | 
				
			||||||
@@ -1718,6 +1718,19 @@ export default {
 | 
				
			|||||||
              margin-right: 6px;
 | 
					              margin-right: 6px;
 | 
				
			||||||
            "
 | 
					            "
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
 | 
					          <!-- Fallback to phone icon for voice channels -->
 | 
				
			||||||
 | 
					          <i
 | 
				
			||||||
 | 
					            v-else
 | 
				
			||||||
 | 
					            class="i-ri-phone-fill text-white mr-1.5"
 | 
				
			||||||
 | 
					            style="
 | 
				
			||||||
 | 
					              width: 24px;
 | 
				
			||||||
 | 
					              height: 24px;
 | 
				
			||||||
 | 
					              font-size: 16px;
 | 
				
			||||||
 | 
					              display: flex;
 | 
				
			||||||
 | 
					              align-items: center;
 | 
				
			||||||
 | 
					              justify-content: center;
 | 
				
			||||||
 | 
					            "
 | 
				
			||||||
 | 
					          ></i>
 | 
				
			||||||
          {{ inboxDisplayName }}
 | 
					          {{ inboxDisplayName }}
 | 
				
			||||||
        </span>
 | 
					        </span>
 | 
				
			||||||
        <span class="incoming-call-text">Incoming call</span>
 | 
					        <span class="incoming-call-text">Incoming call</span>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -402,7 +402,10 @@ export default {
 | 
				
			|||||||
        return;
 | 
					        return;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (canReply || this.isAWhatsAppChannel) {
 | 
					      // Voice channels only allow private notes  
 | 
				
			||||||
 | 
					      if (this.isAVoiceChannel) {
 | 
				
			||||||
 | 
					        this.replyType = REPLY_EDITOR_MODES.NOTE;
 | 
				
			||||||
 | 
					      } else if (canReply || this.isAWhatsAppChannel) {
 | 
				
			||||||
        this.replyType = REPLY_EDITOR_MODES.REPLY;
 | 
					        this.replyType = REPLY_EDITOR_MODES.REPLY;
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        this.replyType = REPLY_EDITOR_MODES.NOTE;
 | 
					        this.replyType = REPLY_EDITOR_MODES.NOTE;
 | 
				
			||||||
@@ -797,7 +800,12 @@ export default {
 | 
				
			|||||||
      this.$store.dispatch('draftMessages/setReplyEditorMode', {
 | 
					      this.$store.dispatch('draftMessages/setReplyEditorMode', {
 | 
				
			||||||
        mode,
 | 
					        mode,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
      if (canReply || this.isAWhatsAppChannel) this.replyType = mode;
 | 
					      // Voice channels are restricted to private notes only
 | 
				
			||||||
 | 
					      if (this.isAVoiceChannel) {
 | 
				
			||||||
 | 
					        this.replyType = REPLY_EDITOR_MODES.NOTE;
 | 
				
			||||||
 | 
					      } else if (canReply || this.isAWhatsAppChannel) {
 | 
				
			||||||
 | 
					        this.replyType = mode;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
      if (this.showRichContentEditor) {
 | 
					      if (this.showRichContentEditor) {
 | 
				
			||||||
        if (this.isRecordingAudio) {
 | 
					        if (this.isRecordingAudio) {
 | 
				
			||||||
          this.toggleAudioRecorder();
 | 
					          this.toggleAudioRecorder();
 | 
				
			||||||
@@ -1225,7 +1233,7 @@ export default {
 | 
				
			|||||||
      :recording-audio-state="recordingAudioState"
 | 
					      :recording-audio-state="recordingAudioState"
 | 
				
			||||||
      :send-button-text="replyButtonLabel"
 | 
					      :send-button-text="replyButtonLabel"
 | 
				
			||||||
      :show-audio-recorder="showAudioRecorder"
 | 
					      :show-audio-recorder="showAudioRecorder"
 | 
				
			||||||
      :show-editor-toggle="isAPIInbox && !isOnPrivateNote"
 | 
					      :show-editor-toggle="isAPIInbox && !isOnPrivateNote && !isAVoiceChannel"
 | 
				
			||||||
      :show-emoji-picker="showEmojiPicker"
 | 
					      :show-emoji-picker="showEmojiPicker"
 | 
				
			||||||
      :show-file-upload="showFileUpload"
 | 
					      :show-file-upload="showFileUpload"
 | 
				
			||||||
      :toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
 | 
					      :toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -33,10 +33,6 @@ class ActionCableConnector extends BaseActionCableConnector {
 | 
				
			|||||||
      'conversation.updated': this.onConversationUpdated,
 | 
					      'conversation.updated': this.onConversationUpdated,
 | 
				
			||||||
      'account.cache_invalidated': this.onCacheInvalidate,
 | 
					      'account.cache_invalidated': this.onCacheInvalidate,
 | 
				
			||||||
      'copilot.message.created': this.onCopilotMessageCreated,
 | 
					      'copilot.message.created': this.onCopilotMessageCreated,
 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Call events
 | 
					 | 
				
			||||||
      incoming_call: this.onIncomingCall,
 | 
					 | 
				
			||||||
      call_status_changed: this.onCallStatusChanged,
 | 
					 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -86,6 +82,40 @@ class ActionCableConnector extends BaseActionCableConnector {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  onConversationCreated = data => {
 | 
					  onConversationCreated = data => {
 | 
				
			||||||
    this.app.$store.dispatch('addConversation', data);
 | 
					    this.app.$store.dispatch('addConversation', data);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Check if this is a voice channel conversation (incoming call)
 | 
				
			||||||
 | 
					    if (data.meta?.inbox?.channel_type === 'Channel::Voice' || data.channel === 'Channel::Voice') {
 | 
				
			||||||
 | 
					      if (data.additional_attributes?.call_status === 'ringing' &&
 | 
				
			||||||
 | 
					          data.additional_attributes?.call_sid) {
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        const normalizedPayload = {
 | 
				
			||||||
 | 
					          callSid: data.additional_attributes.call_sid,
 | 
				
			||||||
 | 
					          conversationId: data.display_id || data.id,
 | 
				
			||||||
 | 
					          inboxId: data.inbox_id,
 | 
				
			||||||
 | 
					          inboxName: data.meta?.inbox?.name,
 | 
				
			||||||
 | 
					          inboxAvatarUrl: data.meta?.inbox?.avatar_url,
 | 
				
			||||||
 | 
					          inboxPhoneNumber: data.meta?.inbox?.phone_number,
 | 
				
			||||||
 | 
					          contactName: data.meta?.sender?.name || 'Unknown Caller',
 | 
				
			||||||
 | 
					          contactId: data.meta?.sender?.id,
 | 
				
			||||||
 | 
					          accountId: data.account_id,
 | 
				
			||||||
 | 
					          isOutbound: data.additional_attributes?.call_direction === 'outbound',
 | 
				
			||||||
 | 
					          conference_sid: data.additional_attributes?.conference_sid,
 | 
				
			||||||
 | 
					          conferenceId: data.additional_attributes?.conference_sid,
 | 
				
			||||||
 | 
					          conferenceSid: data.additional_attributes?.conference_sid,
 | 
				
			||||||
 | 
					          requiresAgentJoin: data.additional_attributes?.requires_agent_join || false,
 | 
				
			||||||
 | 
					          callDirection: data.additional_attributes?.call_direction,
 | 
				
			||||||
 | 
					          phoneNumber: data.meta?.sender?.phone_number,
 | 
				
			||||||
 | 
					          avatarUrl: data.meta?.sender?.avatar_url,
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        this.app.$store.dispatch('calls/setIncomingCall', normalizedPayload);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (window.app && window.app.$data) {
 | 
				
			||||||
 | 
					          window.app.$data.showCallWidget = true;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
    this.fetchConversationStats();
 | 
					    this.fetchConversationStats();
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -118,6 +148,17 @@ class ActionCableConnector extends BaseActionCableConnector {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  onConversationUpdated = data => {
 | 
					  onConversationUpdated = data => {
 | 
				
			||||||
    this.app.$store.dispatch('updateConversation', data);
 | 
					    this.app.$store.dispatch('updateConversation', data);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Check if this conversation update includes call status changes
 | 
				
			||||||
 | 
					    if (data.additional_attributes?.call_status && data.additional_attributes?.call_sid) {
 | 
				
			||||||
 | 
					      this.app.$store.dispatch('calls/handleCallStatusChanged', {
 | 
				
			||||||
 | 
					        callSid: data.additional_attributes.call_sid,
 | 
				
			||||||
 | 
					        status: data.additional_attributes.call_status,
 | 
				
			||||||
 | 
					        conversationId: data.display_id,
 | 
				
			||||||
 | 
					        inboxId: data.inbox_id,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
    this.fetchConversationStats();
 | 
					    this.fetchConversationStats();
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -203,53 +244,7 @@ class ActionCableConnector extends BaseActionCableConnector {
 | 
				
			|||||||
    this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
 | 
					    this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  onIncomingCall = data => {
 | 
					 | 
				
			||||||
    // Normalize snake_case to camelCase for consistency with frontend code
 | 
					 | 
				
			||||||
    const normalizedPayload = {
 | 
					 | 
				
			||||||
      callSid: data.call_sid,
 | 
					 | 
				
			||||||
      conversationId: data.conversation_id,
 | 
					 | 
				
			||||||
      inboxId: data.inbox_id,
 | 
					 | 
				
			||||||
      inboxName: data.inbox_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,
 | 
					 | 
				
			||||||
      // 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
 | 
					 | 
				
			||||||
    this.app.$store.dispatch('calls/setIncomingCall', normalizedPayload);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Also update App.vue showCallWidget directly for immediate UI feedback
 | 
					 | 
				
			||||||
    if (window.app && window.app.$data) {
 | 
					 | 
				
			||||||
      window.app.$data.showCallWidget = true;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  onCallStatusChanged = data => {
 | 
					 | 
				
			||||||
    // Normalize snake_case to camelCase for consistency with frontend code
 | 
					 | 
				
			||||||
    const normalizedPayload = {
 | 
					 | 
				
			||||||
      callSid: data.call_sid,
 | 
					 | 
				
			||||||
      status: data.status,
 | 
					 | 
				
			||||||
      conversationId: data.conversation_id,
 | 
					 | 
				
			||||||
      inboxId: data.inbox_id,
 | 
					 | 
				
			||||||
      timestamp: data.timestamp || Date.now(),
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
    // Only dispatch to Vuex; Vuex handles widget and call state
 | 
					 | 
				
			||||||
    this.app.$store.dispatch(
 | 
					 | 
				
			||||||
      'calls/handleCallStatusChanged',
 | 
					 | 
				
			||||||
      normalizedPayload
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default {
 | 
					export default {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,5 @@
 | 
				
			|||||||
 | 
					import VoiceAPI from 'dashboard/api/channels/voice';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const state = {
 | 
					const state = {
 | 
				
			||||||
  activeCall: null,
 | 
					  activeCall: null,
 | 
				
			||||||
  incomingCall: null,
 | 
					  incomingCall: null,
 | 
				
			||||||
@@ -13,20 +15,21 @@ const getters = {
 | 
				
			|||||||
const actions = {
 | 
					const actions = {
 | 
				
			||||||
  handleCallStatusChanged({ state, dispatch }, { callSid, status, conversationId }) {
 | 
					  handleCallStatusChanged({ state, dispatch }, { callSid, status, conversationId }) {
 | 
				
			||||||
    const isActiveCall = callSid === state.activeCall?.callSid;
 | 
					    const isActiveCall = callSid === state.activeCall?.callSid;
 | 
				
			||||||
 | 
					    const isIncomingCall = callSid === state.incomingCall?.callSid;
 | 
				
			||||||
    const terminalStatuses = ['ended', 'missed', 'completed', 'failed', 'busy', 'no_answer'];
 | 
					    const terminalStatuses = ['ended', 'missed', 'completed', 'failed', 'busy', 'no_answer'];
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    // Update conversation status in the conversation list
 | 
					    if (terminalStatuses.includes(status)) {
 | 
				
			||||||
    if (conversationId) {
 | 
					      if (isActiveCall) {
 | 
				
			||||||
      dispatch('conversations/updateConversationCallStatus', { 
 | 
					        dispatch('clearActiveCall');
 | 
				
			||||||
        conversationId, 
 | 
					      } else if (isIncomingCall) {
 | 
				
			||||||
        callStatus: status 
 | 
					        dispatch('clearIncomingCall');
 | 
				
			||||||
      }, { root: true });
 | 
					      }
 | 
				
			||||||
    }
 | 
					      
 | 
				
			||||||
    
 | 
					      // Hide widget for any terminal status if it matches our call
 | 
				
			||||||
    if (isActiveCall && terminalStatuses.includes(status)) {
 | 
					      if (isActiveCall || isIncomingCall) {
 | 
				
			||||||
      dispatch('clearActiveCall');
 | 
					        if (window.app?.$data) {
 | 
				
			||||||
      if (window.app?.$data) {
 | 
					          window.app.$data.showCallWidget = false;
 | 
				
			||||||
        window.app.$data.showCallWidget = false;
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
@@ -48,6 +51,13 @@ const actions = {
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  clearActiveCall({ commit }) {
 | 
					  clearActiveCall({ commit }) {
 | 
				
			||||||
 | 
					    // End the WebRTC connection before clearing the call state
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      VoiceAPI.endClientCall();
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.warn('Error ending client call during clearActiveCall:', error);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
    commit('CLEAR_ACTIVE_CALL');
 | 
					    commit('CLEAR_ACTIVE_CALL');
 | 
				
			||||||
    if (window.app?.$data) {
 | 
					    if (window.app?.$data) {
 | 
				
			||||||
      window.app.$data.showCallWidget = false;
 | 
					      window.app.$data.showCallWidget = false;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -32,27 +32,27 @@ module Voice
 | 
				
			|||||||
        end
 | 
					        end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        update_status(normalized_status, duration)
 | 
					        update_status(normalized_status, duration)
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        if custom_message.present?
 | 
					        if custom_message.present?
 | 
				
			||||||
          create_activity_message(custom_message)
 | 
					          create_activity_message(custom_message)
 | 
				
			||||||
        elsif call_ended?(normalized_status)
 | 
					        elsif call_ended?(normalized_status)
 | 
				
			||||||
          create_activity_message(activity_message_for_status(normalized_status))
 | 
					          create_activity_message(activity_message_for_status(normalized_status))
 | 
				
			||||||
        end
 | 
					        end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        broadcast_status_change(normalized_status)
 | 
					 | 
				
			||||||
        true
 | 
					        true
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      def is_outbound?
 | 
					      def is_outbound?
 | 
				
			||||||
        direction = conversation.additional_attributes['call_direction']
 | 
					        direction = conversation.additional_attributes['call_direction']
 | 
				
			||||||
        return direction == 'outbound' if direction.present?
 | 
					        return direction == 'outbound' if direction.present?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        conversation.additional_attributes['requires_agent_join'] == true
 | 
					        conversation.additional_attributes['requires_agent_join'] == true
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      def normalized_ui_status(status)
 | 
					      def normalized_ui_status(status)
 | 
				
			||||||
        # Apply STATUS_MAPPING first
 | 
					        # Apply STATUS_MAPPING first
 | 
				
			||||||
        mapped_status = STATUS_MAPPING[status] || status
 | 
					        mapped_status = STATUS_MAPPING[status] || status
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        # Handle missed calls for incoming calls
 | 
					        # Handle missed calls for incoming calls
 | 
				
			||||||
        if mapped_status == 'no_answer' && !is_outbound?
 | 
					        if mapped_status == 'no_answer' && !is_outbound?
 | 
				
			||||||
          'missed'
 | 
					          'missed'
 | 
				
			||||||
@@ -78,7 +78,11 @@ module Voice
 | 
				
			|||||||
          conversation.additional_attributes['call_duration'] = duration if duration
 | 
					          conversation.additional_attributes['call_duration'] = duration if duration
 | 
				
			||||||
        end
 | 
					        end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        conversation.update!(last_activity_at: Time.current)
 | 
					        # Save both additional_attributes changes and last_activity_at
 | 
				
			||||||
 | 
					        conversation.update!(
 | 
				
			||||||
 | 
					          additional_attributes: conversation.additional_attributes,
 | 
				
			||||||
 | 
					          last_activity_at: Time.current
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        update_message_status(status, duration)
 | 
					        update_message_status(status, duration)
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -96,15 +100,16 @@ module Voice
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      def find_voice_call_message
 | 
					      def find_voice_call_message
 | 
				
			||||||
        conversation.messages
 | 
					        conversation.messages
 | 
				
			||||||
                   .where(content_type: 'voice_call')
 | 
					                    .where(content_type: 'voice_call')
 | 
				
			||||||
                   .order(created_at: :desc)
 | 
					                    .order(created_at: :desc)
 | 
				
			||||||
                   .first
 | 
					                    .first
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      def activity_message_for_status(status)
 | 
					      def activity_message_for_status(status)
 | 
				
			||||||
        return 'Call ended' if status == 'ended'
 | 
					        return 'Call ended' if status == 'ended'
 | 
				
			||||||
        return 'Missed call' if status == 'missed'
 | 
					        return 'Missed call' if status == 'missed'
 | 
				
			||||||
        return 'No answer' if status == 'no_answer'
 | 
					        return 'No answer' if status == 'no_answer'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        'Call ended'
 | 
					        'Call ended'
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -120,24 +125,6 @@ module Voice
 | 
				
			|||||||
          additional_attributes: additional_attributes
 | 
					          additional_attributes: additional_attributes
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 | 
					 | 
				
			||||||
      def broadcast_status_change(status)
 | 
					 | 
				
			||||||
        ui_status = normalized_ui_status(status)
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        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
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -36,7 +36,7 @@ module Voice
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def handle_conference_end
 | 
					    def handle_conference_end
 | 
				
			||||||
      current_status = conversation.additional_attributes['call_status']
 | 
					      current_status = conversation.additional_attributes['call_status']
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
      if current_status == 'in_progress'
 | 
					      if current_status == 'in_progress'
 | 
				
			||||||
        call_status_manager.process_status_update('ended')
 | 
					        call_status_manager.process_status_update('ended')
 | 
				
			||||||
      elsif current_status == 'ringing'
 | 
					      elsif current_status == 'ringing'
 | 
				
			||||||
@@ -64,18 +64,18 @@ module Voice
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def handle_agent_join
 | 
					    def handle_agent_join
 | 
				
			||||||
      conversation.additional_attributes['agent_joined_at'] = Time.now.to_i
 | 
					      conversation.additional_attributes['agent_joined_at'] = Time.now.to_i
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
      if ringing_call?
 | 
					      return unless ringing_call?
 | 
				
			||||||
        call_status_manager.process_status_update('in_progress')
 | 
					
 | 
				
			||||||
      end
 | 
					      call_status_manager.process_status_update('in_progress')
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def handle_caller_join
 | 
					    def handle_caller_join
 | 
				
			||||||
      conversation.additional_attributes['caller_joined_at'] = Time.now.to_i
 | 
					      conversation.additional_attributes['caller_joined_at'] = Time.now.to_i
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
      if outbound_call? && ringing_call?
 | 
					      return unless outbound_call? && ringing_call?
 | 
				
			||||||
        call_status_manager.process_status_update('in_progress')
 | 
					
 | 
				
			||||||
      end
 | 
					      call_status_manager.process_status_update('in_progress')
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def agent_participant?
 | 
					    def agent_participant?
 | 
				
			||||||
@@ -102,4 +102,4 @@ module Voice
 | 
				
			|||||||
      conversation.additional_attributes['agent_joined_at'].present?
 | 
					      conversation.additional_attributes['agent_joined_at'].present?
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -78,31 +78,8 @@ module Voice
 | 
				
			|||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def broadcast_agent_notification(conversation, info)
 | 
					    def broadcast_agent_notification(conversation, info)
 | 
				
			||||||
      contact = conversation.contact
 | 
					      # This method is no longer needed since conversation.created events
 | 
				
			||||||
      inbox = conversation.inbox
 | 
					      # will handle incoming call notifications
 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      ActionCable.server.broadcast(
 | 
					 | 
				
			||||||
        "account_#{account.id}",
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
          event: 'incoming_call',
 | 
					 | 
				
			||||||
          data: {
 | 
					 | 
				
			||||||
            call_sid: info[:call_sid],
 | 
					 | 
				
			||||||
            conversation_id: conversation.display_id,
 | 
					 | 
				
			||||||
            inbox_id: conversation.inbox_id,
 | 
					 | 
				
			||||||
            inbox_name: inbox.name,
 | 
					 | 
				
			||||||
            inbox_avatar_url: inbox.avatar_url,
 | 
					 | 
				
			||||||
            inbox_phone_number: inbox.channel.phone_number,
 | 
					 | 
				
			||||||
            contact_name: contact&.name.presence || contact&.phone_number || 'Outbound Call',
 | 
					 | 
				
			||||||
            contact_id: contact&.id,
 | 
					 | 
				
			||||||
            is_outbound: true,
 | 
					 | 
				
			||||||
            account_id: account.id,
 | 
					 | 
				
			||||||
            conference_sid: info[:conference_sid],
 | 
					 | 
				
			||||||
            phone_number: contact&.phone_number,
 | 
					 | 
				
			||||||
            avatar_url: contact&.avatar_url,
 | 
					 | 
				
			||||||
            call_direction: 'outbound'
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      )
 | 
					 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
@@ -6,16 +6,14 @@ module Voice
 | 
				
			|||||||
      find_inbox
 | 
					      find_inbox
 | 
				
			||||||
      create_contact
 | 
					      create_contact
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      # Use a transaction to ensure the conversation and voice call message are created together
 | 
					      # Use a transaction to ensure conversation, message, and call status are all set together
 | 
				
			||||||
      # This ensures the voice call message is created before any auto-assignment activity messages
 | 
					      # This ensures only one conversation.created event with complete call data
 | 
				
			||||||
      ActiveRecord::Base.transaction do
 | 
					      ActiveRecord::Base.transaction do
 | 
				
			||||||
        create_conversation
 | 
					        create_conversation
 | 
				
			||||||
        create_voice_call_message
 | 
					        create_voice_call_message
 | 
				
			||||||
 | 
					        set_initial_call_status
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      # Create activity message separately, after the voice call message
 | 
					 | 
				
			||||||
      create_activity_message
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      generate_twiml_response
 | 
					      generate_twiml_response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    rescue StandardError => e
 | 
					    rescue StandardError => e
 | 
				
			||||||
@@ -132,68 +130,42 @@ module Voice
 | 
				
			|||||||
        message_params
 | 
					        message_params
 | 
				
			||||||
      ).perform
 | 
					      ).perform
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      # Broadcast call notification
 | 
					 | 
				
			||||||
      broadcast_call_status
 | 
					 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Create activity message separately after the voice call message
 | 
					    # Set initial call status within the transaction
 | 
				
			||||||
    def create_activity_message
 | 
					    def set_initial_call_status
 | 
				
			||||||
      # Use CallStatusManager for consistency
 | 
					      # Set call status directly on conversation to avoid separate broadcast
 | 
				
			||||||
      status_manager = Voice::CallStatus::Manager.new(
 | 
					      @conversation.additional_attributes['call_status'] = 'ringing'
 | 
				
			||||||
        conversation: @conversation,
 | 
					      @conversation.additional_attributes['call_started_at'] = Time.now.to_i
 | 
				
			||||||
        call_sid: caller_info[:call_sid],
 | 
					      @conversation.save!
 | 
				
			||||||
        provider: :twilio
 | 
					      
 | 
				
			||||||
      )
 | 
					      # Create activity message directly without CallStatusManager broadcast
 | 
				
			||||||
 | 
					 | 
				
			||||||
      # Process ringing status with custom activity message
 | 
					 | 
				
			||||||
      custom_message = "Incoming call from #{@contact.name.presence || caller_info[:from_number]}"
 | 
					      custom_message = "Incoming call from #{@contact.name.presence || caller_info[:from_number]}"
 | 
				
			||||||
      status_manager.process_status_update('ringing', nil, true, custom_message)
 | 
					      @conversation.messages.create!(
 | 
				
			||||||
    end
 | 
					        account_id: @conversation.account_id,
 | 
				
			||||||
 | 
					        inbox_id: @conversation.inbox_id,
 | 
				
			||||||
    def broadcast_call_status
 | 
					        message_type: :activity,
 | 
				
			||||||
      # Get contact name, ensuring we have a valid value
 | 
					        content: custom_message,
 | 
				
			||||||
      contact_name_value = @contact.name.presence || caller_info[:from_number]
 | 
					        sender: nil
 | 
				
			||||||
 | 
					 | 
				
			||||||
      # Create the data payload
 | 
					 | 
				
			||||||
      broadcast_data = {
 | 
					 | 
				
			||||||
        call_sid: caller_info[:call_sid],
 | 
					 | 
				
			||||||
        conversation_id: @conversation.display_id,
 | 
					 | 
				
			||||||
        inbox_id: @inbox.id,
 | 
					 | 
				
			||||||
        inbox_name: @inbox.name,
 | 
					 | 
				
			||||||
        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,
 | 
					 | 
				
			||||||
        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}",
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
          event: 'incoming_call',
 | 
					 | 
				
			||||||
          data: broadcast_data
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def generate_twiml_response
 | 
					    def generate_twiml_response
 | 
				
			||||||
      conference_name = @conversation.additional_attributes['conference_sid']
 | 
					      conference_name = @conversation.additional_attributes['conference_sid']
 | 
				
			||||||
      Rails.logger.info("📞 IncomingCallService: Generating TwiML with conference name: #{conference_name}")
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      response = Twilio::TwiML::VoiceResponse.new
 | 
					      response = Twilio::TwiML::VoiceResponse.new
 | 
				
			||||||
      response.say(message: 'Thank you for calling. Please wait while we connect you with an agent.')
 | 
					      response.say(message: 'Thank you for calling. Please wait while we connect you with an agent.')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      # Setup callback URLs
 | 
					      # Setup callback URLs
 | 
				
			||||||
      conference_callback_url = "#{base_url}/api/v1/accounts/#{account.id}/channels/voice/webhooks/conference_status"
 | 
					      conference_callback_url = "#{base_url}/api/v1/accounts/#{account.id}/channels/voice/webhooks/conference_status"
 | 
				
			||||||
      Rails.logger.info("📞 IncomingCallService: Setting conference callback to: #{conference_callback_url}")
 | 
					      call_status_callback_url = "#{base_url}/api/v1/accounts/#{account.id}/channels/voice/webhooks/call_status"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      # Now add the caller to the conference
 | 
					      # Now add the caller to the conference with call status callback
 | 
				
			||||||
      response.dial do |dial|
 | 
					      response.dial(
 | 
				
			||||||
 | 
					        action: call_status_callback_url,
 | 
				
			||||||
 | 
					        method: 'POST'
 | 
				
			||||||
 | 
					      ) do |dial|
 | 
				
			||||||
        dial.conference(
 | 
					        dial.conference(
 | 
				
			||||||
          conference_name,
 | 
					          conference_name,
 | 
				
			||||||
          startConferenceOnEnter: false,
 | 
					          startConferenceOnEnter: false,
 | 
				
			||||||
@@ -208,9 +180,7 @@ module Voice
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      result = response.to_s
 | 
					      response.to_s
 | 
				
			||||||
      Rails.logger.info("📞 IncomingCallService: Generated TwiML: #{result}")
 | 
					 | 
				
			||||||
      result
 | 
					 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def error_twiml(_message)
 | 
					    def error_twiml(_message)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,7 +16,6 @@ module Voice
 | 
				
			|||||||
      # Add the activity message separately, after the voice call message
 | 
					      # Add the activity message separately, after the voice call message
 | 
				
			||||||
      create_activity_message
 | 
					      create_activity_message
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      broadcast_to_agent
 | 
					 | 
				
			||||||
      @conversation
 | 
					      @conversation
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -43,7 +42,7 @@ module Voice
 | 
				
			|||||||
      @conversation.reload
 | 
					      @conversation.reload
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      # Log the conversation ID and display_id for debugging
 | 
					      # 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}")
 | 
					      # Conversation created for outgoing call
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      # The conference_sid should be set by the ConversationFinderService, but we double-check
 | 
					      # The conference_sid should be set by the ConversationFinderService, but we double-check
 | 
				
			||||||
      @conference_name = @conversation.additional_attributes['conference_sid']
 | 
					      @conference_name = @conversation.additional_attributes['conference_sid']
 | 
				
			||||||
@@ -57,30 +56,30 @@ module Voice
 | 
				
			|||||||
        @conversation.additional_attributes['conference_sid'] = @conference_name
 | 
					        @conversation.additional_attributes['conference_sid'] = @conference_name
 | 
				
			||||||
        @conversation.save!
 | 
					        @conversation.save!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Rails.logger.info("🔧 OUTGOING CALL: Fixed conference name to #{@conference_name}")
 | 
					        # Logging removed
 | 
				
			||||||
      else
 | 
					      else
 | 
				
			||||||
        Rails.logger.info("✅ OUTGOING CALL: Using existing conference name #{@conference_name}")
 | 
					        # Logging removed
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def initiate_call
 | 
					    def initiate_call
 | 
				
			||||||
      # Double-check that we have a valid conference name before calling
 | 
					      # Double-check that we have a valid conference name before calling
 | 
				
			||||||
      if @conference_name.blank? || !@conference_name.match?(/^conf_account_\d+_conv_\d+$/)
 | 
					      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}")
 | 
					        # Logging removed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Re-generate the conference name as a last resort
 | 
					        # Re-generate the conference name as a last resort
 | 
				
			||||||
        @conference_name = "conf_account_#{account.id}_conv_#{@conversation.display_id}"
 | 
					        @conference_name = "conf_account_#{account.id}_conv_#{@conversation.display_id}"
 | 
				
			||||||
        Rails.logger.info("🔧 OUTGOING CALL: Re-generated conference name: #{@conference_name}")
 | 
					        # Logging removed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Update the conversation with the new conference name
 | 
					        # Update the conversation with the new conference name
 | 
				
			||||||
        @conversation.additional_attributes['conference_sid'] = @conference_name
 | 
					        @conversation.additional_attributes['conference_sid'] = @conference_name
 | 
				
			||||||
        @conversation.save!
 | 
					        @conversation.save!
 | 
				
			||||||
      else
 | 
					      else
 | 
				
			||||||
        Rails.logger.info("✅ OUTGOING CALL: Valid conference name: #{@conference_name}")
 | 
					        # Logging removed
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      # Log that we're about to initiate the call
 | 
					      # Log that we're about to initiate the call
 | 
				
			||||||
      Rails.logger.info("📞 OUTGOING CALL: Initiating call to #{contact.phone_number} with conference #{@conference_name}")
 | 
					        # Logging removed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      # Initiate the call using the channel's implementation
 | 
					      # Initiate the call using the channel's implementation
 | 
				
			||||||
      @call_details = @voice_inbox.channel.initiate_call(
 | 
					      @call_details = @voice_inbox.channel.initiate_call(
 | 
				
			||||||
@@ -90,7 +89,7 @@ module Voice
 | 
				
			|||||||
      )
 | 
					      )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      # Log the returned call details for debugging
 | 
					      # Log the returned call details for debugging
 | 
				
			||||||
      Rails.logger.info("📞 OUTGOING CALL: Call initiated with details: #{@call_details.inspect}")
 | 
					        # Logging removed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      # Update conversation with call details, but don't set status
 | 
					      # Update conversation with call details, but don't set status
 | 
				
			||||||
      # Status will be properly set by CallStatusManager
 | 
					      # Status will be properly set by CallStatusManager
 | 
				
			||||||
@@ -109,7 +108,7 @@ module Voice
 | 
				
			|||||||
      @conversation.update!(additional_attributes: updated_attributes)
 | 
					      @conversation.update!(additional_attributes: updated_attributes)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      # Log the final conversation state
 | 
					      # Log the final conversation state
 | 
				
			||||||
      Rails.logger.info("📞 OUTGOING CALL: Conversation updated with call_sid=#{@call_details[:call_sid]}, conference_sid=#{@conference_name}")
 | 
					        # Logging removed
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def create_voice_call_message
 | 
					    def create_voice_call_message
 | 
				
			||||||
@@ -172,37 +171,5 @@ module Voice
 | 
				
			|||||||
      status_manager.process_status_update('initiated', nil, true, custom_message)
 | 
					      status_manager.process_status_update('initiated', nil, true, custom_message)
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def broadcast_to_agent
 | 
					 | 
				
			||||||
      # Get contact name, ensuring we have a valid value
 | 
					 | 
				
			||||||
      contact_name_value = contact.name.presence || contact.phone_number
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      # Create the data payload
 | 
					 | 
				
			||||||
      broadcast_data = {
 | 
					 | 
				
			||||||
        call_sid: @call_details[:call_sid],
 | 
					 | 
				
			||||||
        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
 | 
					 | 
				
			||||||
        inbox_phone_number: @voice_inbox.channel.phone_number, # Include inbox phone number
 | 
					 | 
				
			||||||
        contact_name: contact_name_value,
 | 
					 | 
				
			||||||
        contact_id: contact.id,
 | 
					 | 
				
			||||||
        account_id: account.id,
 | 
					 | 
				
			||||||
        is_outbound: true,
 | 
					 | 
				
			||||||
        conference_sid: @conference_name,
 | 
					 | 
				
			||||||
        requires_agent_join: true,
 | 
					 | 
				
			||||||
        call_direction: 'outbound',
 | 
					 | 
				
			||||||
        phone_number: contact.phone_number, # Include phone number for display in the UI
 | 
					 | 
				
			||||||
        avatar_url: contact.avatar_url # Include avatar URL for display in the UI
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      # Direct notification that agent needs to join
 | 
					 | 
				
			||||||
      ActionCable.server.broadcast(
 | 
					 | 
				
			||||||
        "account_#{account.id}",
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
          event: 'incoming_call',
 | 
					 | 
				
			||||||
          data: broadcast_data
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      )
 | 
					 | 
				
			||||||
    end
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,35 +6,35 @@ module Voice
 | 
				
			|||||||
    def valid?
 | 
					    def valid?
 | 
				
			||||||
      # Skip validation for these cases:
 | 
					      # Skip validation for these cases:
 | 
				
			||||||
      return true if skip_validation?
 | 
					      return true if skip_validation?
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
      begin
 | 
					      begin
 | 
				
			||||||
        # Find the inbox and get the auth token
 | 
					        # Find the inbox and get the auth token
 | 
				
			||||||
        to_number = params['To']
 | 
					        to_number = params['To']
 | 
				
			||||||
        inbox = find_voice_inbox(to_number)
 | 
					        inbox = find_voice_inbox(to_number)
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        # Allow callbacks if we can't find the inbox or auth token
 | 
					        # Allow callbacks if we can't find the inbox or auth token
 | 
				
			||||||
        return true unless inbox
 | 
					        return true unless inbox
 | 
				
			||||||
        return true unless (auth_token = get_auth_token(inbox))
 | 
					        return true unless (auth_token = get_auth_token(inbox))
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        # Check if we have a signature to validate
 | 
					        # Check if we have a signature to validate
 | 
				
			||||||
        signature = request.headers['X-Twilio-Signature']
 | 
					        signature = request.headers['X-Twilio-Signature']
 | 
				
			||||||
        return true unless signature.present?
 | 
					        return true unless signature.present?
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        # Validate the signature
 | 
					        # Validate the signature
 | 
				
			||||||
        validator = Twilio::Security::RequestValidator.new(auth_token)
 | 
					        validator = Twilio::Security::RequestValidator.new(auth_token)
 | 
				
			||||||
        url = "#{request.protocol}#{request.host_with_port}#{request.fullpath}"
 | 
					        url = "#{request.protocol}#{request.host_with_port}#{request.fullpath}"
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        is_valid = validator.validate(url, params.to_unsafe_h, signature)
 | 
					        is_valid = validator.validate(url, params.to_unsafe_h, signature)
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        # Log validation result
 | 
					        # Log validation result
 | 
				
			||||||
        if is_valid
 | 
					        if is_valid
 | 
				
			||||||
          Rails.logger.info("✅ TWILIO VALIDATION: Valid signature confirmed")
 | 
					          # Twilio signature validation successful
 | 
				
			||||||
        else
 | 
					        else
 | 
				
			||||||
          Rails.logger.error("⚠️ TWILIO VALIDATION: Invalid signature for URL: #{url}")
 | 
					          Rails.logger.error("Invalid Twilio signature for URL: #{url}")
 | 
				
			||||||
          return false
 | 
					          return false
 | 
				
			||||||
        end
 | 
					        end
 | 
				
			||||||
      rescue StandardError => e
 | 
					      rescue StandardError => e
 | 
				
			||||||
        Rails.logger.error("❌ TWILIO VALIDATION ERROR: #{e.message}")
 | 
					        Rails.logger.error("Twilio validation error: #{e.message}")
 | 
				
			||||||
        return true # Allow on errors for robustness
 | 
					        return true # Allow on errors for robustness
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -42,27 +42,27 @@ module Voice
 | 
				
			|||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private
 | 
					    private
 | 
				
			||||||
    
 | 
					
 | 
				
			||||||
    def skip_validation?
 | 
					    def skip_validation?
 | 
				
			||||||
      # Skip for OPTIONS requests and in development
 | 
					      # Skip for OPTIONS requests and in development
 | 
				
			||||||
      return true if request.method == "OPTIONS"
 | 
					      return true if request.method == 'OPTIONS'
 | 
				
			||||||
      return true if Rails.env.development?
 | 
					      return true if Rails.env.development?
 | 
				
			||||||
      return true if account.blank?
 | 
					      return true if account.blank?
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
      false
 | 
					      false
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_auth_token(inbox)
 | 
					    def get_auth_token(inbox)
 | 
				
			||||||
      channel = inbox.channel
 | 
					      channel = inbox.channel
 | 
				
			||||||
      return nil unless channel.is_a?(Channel::Voice)
 | 
					      return nil unless channel.is_a?(Channel::Voice)
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
      provider_config = channel.provider_config_hash
 | 
					      provider_config = channel.provider_config_hash
 | 
				
			||||||
      provider_config['auth_token'] if provider_config.present?
 | 
					      provider_config['auth_token'] if provider_config.present?
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def find_voice_inbox(to_number)
 | 
					    def find_voice_inbox(to_number)
 | 
				
			||||||
      return nil if to_number.blank?
 | 
					      return nil if to_number.blank?
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
      account.inboxes
 | 
					      account.inboxes
 | 
				
			||||||
             .where(channel_type: 'Channel::Voice')
 | 
					             .where(channel_type: 'Channel::Voice')
 | 
				
			||||||
             .joins('INNER JOIN channel_voice ON channel_voice.id = inboxes.channel_id')
 | 
					             .joins('INNER JOIN channel_voice ON channel_voice.id = inboxes.channel_id')
 | 
				
			||||||
@@ -70,4 +70,4 @@ module Voice
 | 
				
			|||||||
             .first
 | 
					             .first
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -104,6 +104,7 @@ Rails.application.routes.draw do
 | 
				
			|||||||
                collection do
 | 
					                collection do
 | 
				
			||||||
                  post :incoming
 | 
					                  post :incoming
 | 
				
			||||||
                  match :conference_status, via: [:post, :options]  # Allow both POST and OPTIONS
 | 
					                  match :conference_status, via: [:post, :options]  # Allow both POST and OPTIONS
 | 
				
			||||||
 | 
					                  match :call_status, via: [:post, :options]        # Allow both POST and OPTIONS for call status
 | 
				
			||||||
                  match :incoming, via: [:post, :options]           # Allow both POST and OPTIONS
 | 
					                  match :incoming, via: [:post, :options]           # Allow both POST and OPTIONS
 | 
				
			||||||
                end
 | 
					                end
 | 
				
			||||||
              end
 | 
					              end
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user