mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-11-04 13:07:55 +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
 | 
			
		||||
  skip_before_action :authenticate_user!, :set_current_user, only: [:incoming, :conference_status]
 | 
			
		||||
  protect_from_forgery with: :null_session, only: [:incoming, :conference_status]
 | 
			
		||||
  before_action :validate_twilio_signature, only: [:incoming]
 | 
			
		||||
  before_action :handle_options_request, 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, :call_status]
 | 
			
		||||
  before_action :validate_twilio_signature, only: [:incoming, :call_status]
 | 
			
		||||
  before_action :handle_options_request, only: [:incoming, :conference_status, :call_status]
 | 
			
		||||
 | 
			
		||||
  # Handle CORS preflight OPTIONS requests
 | 
			
		||||
  def handle_options_request
 | 
			
		||||
    if request.method == "OPTIONS"
 | 
			
		||||
    if request.method == 'OPTIONS'
 | 
			
		||||
      set_cors_headers
 | 
			
		||||
      head :ok
 | 
			
		||||
      return true
 | 
			
		||||
    end
 | 
			
		||||
    false
 | 
			
		||||
  end
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  def set_cors_headers
 | 
			
		||||
    headers['Access-Control-Allow-Origin'] = '*'
 | 
			
		||||
    headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS'
 | 
			
		||||
@@ -25,78 +25,121 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts
 | 
			
		||||
  def incoming
 | 
			
		||||
    # Set CORS headers first to ensure they're included
 | 
			
		||||
    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
 | 
			
		||||
    begin
 | 
			
		||||
      # Ensure account is set properly
 | 
			
		||||
      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
 | 
			
		||||
      
 | 
			
		||||
      Current.account = Account.find(params[:account_id]) if !Current.account && params[:account_id].present?
 | 
			
		||||
 | 
			
		||||
      # Validate required parameters
 | 
			
		||||
      validate_incoming_params
 | 
			
		||||
      
 | 
			
		||||
 | 
			
		||||
      # Process the call
 | 
			
		||||
      service = Voice::IncomingCallService.new(
 | 
			
		||||
        account: Current.account, 
 | 
			
		||||
        account: Current.account,
 | 
			
		||||
        params: params.to_unsafe_h.merge(host_with_port: request.host_with_port)
 | 
			
		||||
      )
 | 
			
		||||
      twiml_response = service.process
 | 
			
		||||
      
 | 
			
		||||
 | 
			
		||||
      # Return TwiML response
 | 
			
		||||
      Rails.logger.info("✅ INCOMING CALL: Successfully processed")
 | 
			
		||||
      render xml: twiml_response
 | 
			
		||||
    rescue StandardError => e
 | 
			
		||||
      # Log the error with detailed information
 | 
			
		||||
      Rails.logger.error("❌ INCOMING CALL ERROR: #{e.message}")
 | 
			
		||||
      Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}")
 | 
			
		||||
      
 | 
			
		||||
      Rails.logger.error("Incoming call error: #{e.message}")
 | 
			
		||||
 | 
			
		||||
      # Return friendly error message to caller
 | 
			
		||||
      render_error("We're sorry, but we're experiencing technical difficulties. Please try your call again later.")
 | 
			
		||||
    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
 | 
			
		||||
  def conference_status
 | 
			
		||||
    # Set CORS headers first to ensure they're always included
 | 
			
		||||
    set_cors_headers
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    # Return immediately for OPTIONS requests
 | 
			
		||||
    if request.method == "OPTIONS"
 | 
			
		||||
      return head :ok
 | 
			
		||||
    end
 | 
			
		||||
    
 | 
			
		||||
    # Log basic request info
 | 
			
		||||
    Rails.logger.info("🎧 CONFERENCE STATUS WEBHOOK: ConferenceSid=#{params['ConferenceSid']} Event=#{params['StatusCallbackEvent']}")
 | 
			
		||||
    
 | 
			
		||||
    return head :ok if request.method == 'OPTIONS'
 | 
			
		||||
 | 
			
		||||
    # Process conference status updates using service
 | 
			
		||||
    begin
 | 
			
		||||
      # Set account for local development if needed
 | 
			
		||||
      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
 | 
			
		||||
      if params['ConferenceSid'].blank? && params['CallSid'].blank?
 | 
			
		||||
        Rails.logger.error("❌ MISSING REQUIRED PARAMS: Need either ConferenceSid or CallSid")
 | 
			
		||||
      end
 | 
			
		||||
      
 | 
			
		||||
      Current.account = Account.find(params[:account_id]) if !Current.account && params[:account_id].present?
 | 
			
		||||
 | 
			
		||||
      # Validate required parameters - need either ConferenceSid or CallSid
 | 
			
		||||
      return head :ok if params['ConferenceSid'].blank? && params['CallSid'].blank?
 | 
			
		||||
 | 
			
		||||
      # Use service to process conference status
 | 
			
		||||
      service = Voice::ConferenceStatusService.new(account: Current.account, params: params)
 | 
			
		||||
      service.process
 | 
			
		||||
      
 | 
			
		||||
      Rails.logger.info("✅ CONFERENCE STATUS: Successfully processed")
 | 
			
		||||
 | 
			
		||||
      # Conference status processed successfully
 | 
			
		||||
    rescue StandardError => e
 | 
			
		||||
      # Log errors but don't affect the response
 | 
			
		||||
      Rails.logger.error("❌ CONFERENCE STATUS ERROR: #{e.message}")
 | 
			
		||||
      Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}")
 | 
			
		||||
      Rails.logger.error("Conference status error: #{e.message}")
 | 
			
		||||
    end
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    # Always return a successful response for Twilio
 | 
			
		||||
    head :ok
 | 
			
		||||
  end
 | 
			
		||||
@@ -104,43 +147,34 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def validate_incoming_params
 | 
			
		||||
    if params['CallSid'].blank?
 | 
			
		||||
      raise "Missing required parameter: CallSid"
 | 
			
		||||
    end
 | 
			
		||||
    
 | 
			
		||||
    if params['From'].blank?
 | 
			
		||||
      raise "Missing required parameter: From"
 | 
			
		||||
    end
 | 
			
		||||
    
 | 
			
		||||
    if params['To'].blank?
 | 
			
		||||
      raise "Missing required parameter: To"
 | 
			
		||||
    end
 | 
			
		||||
    
 | 
			
		||||
    if Current.account.nil?
 | 
			
		||||
      raise "Current account not set"
 | 
			
		||||
    end
 | 
			
		||||
    raise 'Missing required parameter: CallSid' if params['CallSid'].blank?
 | 
			
		||||
 | 
			
		||||
    raise 'Missing required parameter: From' if params['From'].blank?
 | 
			
		||||
 | 
			
		||||
    raise 'Missing required parameter: To' if params['To'].blank?
 | 
			
		||||
 | 
			
		||||
    return unless Current.account.nil?
 | 
			
		||||
 | 
			
		||||
    raise 'Current account not set'
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def validate_twilio_signature
 | 
			
		||||
    begin
 | 
			
		||||
      validator = Voice::TwilioValidatorService.new(
 | 
			
		||||
        account: Current.account,
 | 
			
		||||
        params: params,
 | 
			
		||||
        request: request
 | 
			
		||||
      )
 | 
			
		||||
      
 | 
			
		||||
      if !validator.valid?
 | 
			
		||||
        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')
 | 
			
		||||
    validator = Voice::TwilioValidatorService.new(
 | 
			
		||||
      account: Current.account,
 | 
			
		||||
      params: params,
 | 
			
		||||
      request: request
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    unless validator.valid?
 | 
			
		||||
      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
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def render_error(message)
 | 
			
		||||
@@ -149,4 +183,4 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts
 | 
			
		||||
    response.hangup
 | 
			
		||||
    render xml: response.to_s
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
@@ -21,7 +21,6 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
 | 
			
		||||
                                   provider:    :twilio)
 | 
			
		||||
                              .process_status_update('completed', nil, false, "Call ended by #{current_user.name}")
 | 
			
		||||
 | 
			
		||||
    broadcast_status(call_sid, 'completed')
 | 
			
		||||
    render_success('Call successfully ended')
 | 
			
		||||
  rescue StandardError => e
 | 
			
		||||
    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!
 | 
			
		||||
    update_join_metadata!(call_sid)
 | 
			
		||||
    broadcast_status(call_sid, 'in-progress')
 | 
			
		||||
 | 
			
		||||
    render json: {
 | 
			
		||||
      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")
 | 
			
		||||
  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 -----------------------------------------------------------------
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
  getCallStatus(callSid) {
 | 
			
		||||
    if (!callSid) {
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,6 @@ const {
 | 
			
		||||
  isAWhatsAppChannel,
 | 
			
		||||
  isAnEmailChannel,
 | 
			
		||||
  isAnInstagramChannel,
 | 
			
		||||
  isAVoiceChannel,
 | 
			
		||||
} = useInbox();
 | 
			
		||||
 | 
			
		||||
const {
 | 
			
		||||
@@ -42,8 +41,6 @@ const showStatusIndicator = computed(() => {
 | 
			
		||||
  if (status.value === MESSAGE_STATUS.FAILED) return false;
 | 
			
		||||
  // Don't show status for deleted messages
 | 
			
		||||
  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.TEMPLATE) return true;
 | 
			
		||||
 
 | 
			
		||||
@@ -1528,16 +1528,16 @@ export default {
 | 
			
		||||
        <div class="flex items-center">
 | 
			
		||||
          <!-- Left side with inbox avatar and call info -->
 | 
			
		||||
          <div class="inbox-avatar">
 | 
			
		||||
            <!-- Use the inbox avatar if available -->
 | 
			
		||||
            <!-- Use the inbox avatar if available and valid -->
 | 
			
		||||
            <img
 | 
			
		||||
              v-if="inboxAvatarUrl"
 | 
			
		||||
              v-if="inboxAvatarUrl && inboxAvatarUrl.startsWith('http')"
 | 
			
		||||
              :src="inboxAvatarUrl"
 | 
			
		||||
              :alt="inboxDisplayName"
 | 
			
		||||
              class="avatar-image"
 | 
			
		||||
              @error="handleAvatarError"
 | 
			
		||||
            />
 | 
			
		||||
            <!-- Fallback to initial if no avatar -->
 | 
			
		||||
            <span v-else>{{ inboxDisplayName.charAt(0).toUpperCase() }}</span>
 | 
			
		||||
            <!-- Fallback to phone icon for voice channels -->
 | 
			
		||||
            <i v-else class="i-ri-phone-fill text-white text-lg"></i>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="header-info">
 | 
			
		||||
            <div class="voice-label">{{ inboxDisplayName }}</div>
 | 
			
		||||
@@ -1707,7 +1707,7 @@ export default {
 | 
			
		||||
        <span class="inbox-name">
 | 
			
		||||
          <!-- Add inbox avatar to the header -->
 | 
			
		||||
          <img
 | 
			
		||||
            v-if="inboxAvatarUrl"
 | 
			
		||||
            v-if="inboxAvatarUrl && inboxAvatarUrl.startsWith('http')"
 | 
			
		||||
            :src="inboxAvatarUrl"
 | 
			
		||||
            :alt="inboxDisplayName"
 | 
			
		||||
            class="inline-avatar"
 | 
			
		||||
@@ -1718,6 +1718,19 @@ export default {
 | 
			
		||||
              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 }}
 | 
			
		||||
        </span>
 | 
			
		||||
        <span class="incoming-call-text">Incoming call</span>
 | 
			
		||||
 
 | 
			
		||||
@@ -402,7 +402,10 @@ export default {
 | 
			
		||||
        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;
 | 
			
		||||
      } else {
 | 
			
		||||
        this.replyType = REPLY_EDITOR_MODES.NOTE;
 | 
			
		||||
@@ -797,7 +800,12 @@ export default {
 | 
			
		||||
      this.$store.dispatch('draftMessages/setReplyEditorMode', {
 | 
			
		||||
        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.isRecordingAudio) {
 | 
			
		||||
          this.toggleAudioRecorder();
 | 
			
		||||
@@ -1225,7 +1233,7 @@ export default {
 | 
			
		||||
      :recording-audio-state="recordingAudioState"
 | 
			
		||||
      :send-button-text="replyButtonLabel"
 | 
			
		||||
      :show-audio-recorder="showAudioRecorder"
 | 
			
		||||
      :show-editor-toggle="isAPIInbox && !isOnPrivateNote"
 | 
			
		||||
      :show-editor-toggle="isAPIInbox && !isOnPrivateNote && !isAVoiceChannel"
 | 
			
		||||
      :show-emoji-picker="showEmojiPicker"
 | 
			
		||||
      :show-file-upload="showFileUpload"
 | 
			
		||||
      :toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
 | 
			
		||||
 
 | 
			
		||||
@@ -33,10 +33,6 @@ class ActionCableConnector extends BaseActionCableConnector {
 | 
			
		||||
      'conversation.updated': this.onConversationUpdated,
 | 
			
		||||
      'account.cache_invalidated': this.onCacheInvalidate,
 | 
			
		||||
      '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 => {
 | 
			
		||||
    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();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
@@ -118,6 +148,17 @@ class ActionCableConnector extends BaseActionCableConnector {
 | 
			
		||||
 | 
			
		||||
  onConversationUpdated = 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();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
@@ -203,53 +244,7 @@ class ActionCableConnector extends BaseActionCableConnector {
 | 
			
		||||
    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 {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,5 @@
 | 
			
		||||
import VoiceAPI from 'dashboard/api/channels/voice';
 | 
			
		||||
 | 
			
		||||
const state = {
 | 
			
		||||
  activeCall: null,
 | 
			
		||||
  incomingCall: null,
 | 
			
		||||
@@ -13,20 +15,21 @@ const getters = {
 | 
			
		||||
const actions = {
 | 
			
		||||
  handleCallStatusChanged({ state, dispatch }, { callSid, status, conversationId }) {
 | 
			
		||||
    const isActiveCall = callSid === state.activeCall?.callSid;
 | 
			
		||||
    const isIncomingCall = callSid === state.incomingCall?.callSid;
 | 
			
		||||
    const terminalStatuses = ['ended', 'missed', 'completed', 'failed', 'busy', 'no_answer'];
 | 
			
		||||
    
 | 
			
		||||
    // Update conversation status in the conversation list
 | 
			
		||||
    if (conversationId) {
 | 
			
		||||
      dispatch('conversations/updateConversationCallStatus', { 
 | 
			
		||||
        conversationId, 
 | 
			
		||||
        callStatus: status 
 | 
			
		||||
      }, { root: true });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    if (isActiveCall && terminalStatuses.includes(status)) {
 | 
			
		||||
      dispatch('clearActiveCall');
 | 
			
		||||
      if (window.app?.$data) {
 | 
			
		||||
        window.app.$data.showCallWidget = false;
 | 
			
		||||
    if (terminalStatuses.includes(status)) {
 | 
			
		||||
      if (isActiveCall) {
 | 
			
		||||
        dispatch('clearActiveCall');
 | 
			
		||||
      } else if (isIncomingCall) {
 | 
			
		||||
        dispatch('clearIncomingCall');
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Hide widget for any terminal status if it matches our call
 | 
			
		||||
      if (isActiveCall || isIncomingCall) {
 | 
			
		||||
        if (window.app?.$data) {
 | 
			
		||||
          window.app.$data.showCallWidget = false;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
@@ -48,6 +51,13 @@ const actions = {
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  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');
 | 
			
		||||
    if (window.app?.$data) {
 | 
			
		||||
      window.app.$data.showCallWidget = false;
 | 
			
		||||
 
 | 
			
		||||
@@ -32,27 +32,27 @@ module Voice
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        update_status(normalized_status, duration)
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        if custom_message.present?
 | 
			
		||||
          create_activity_message(custom_message)
 | 
			
		||||
        elsif call_ended?(normalized_status)
 | 
			
		||||
          create_activity_message(activity_message_for_status(normalized_status))
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        broadcast_status_change(normalized_status)
 | 
			
		||||
        true
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      def is_outbound?
 | 
			
		||||
        direction = conversation.additional_attributes['call_direction']
 | 
			
		||||
        return direction == 'outbound' if direction.present?
 | 
			
		||||
 | 
			
		||||
        conversation.additional_attributes['requires_agent_join'] == true
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      def normalized_ui_status(status)
 | 
			
		||||
        # Apply STATUS_MAPPING first
 | 
			
		||||
        mapped_status = STATUS_MAPPING[status] || status
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        # Handle missed calls for incoming calls
 | 
			
		||||
        if mapped_status == 'no_answer' && !is_outbound?
 | 
			
		||||
          'missed'
 | 
			
		||||
@@ -78,7 +78,11 @@ module Voice
 | 
			
		||||
          conversation.additional_attributes['call_duration'] = duration if duration
 | 
			
		||||
        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)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
@@ -96,15 +100,16 @@ module Voice
 | 
			
		||||
 | 
			
		||||
      def find_voice_call_message
 | 
			
		||||
        conversation.messages
 | 
			
		||||
                   .where(content_type: 'voice_call')
 | 
			
		||||
                   .order(created_at: :desc)
 | 
			
		||||
                   .first
 | 
			
		||||
                    .where(content_type: 'voice_call')
 | 
			
		||||
                    .order(created_at: :desc)
 | 
			
		||||
                    .first
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      def activity_message_for_status(status)
 | 
			
		||||
        return 'Call ended' if status == 'ended'
 | 
			
		||||
        return 'Missed call' if status == 'missed'
 | 
			
		||||
        return 'No answer' if status == 'no_answer'
 | 
			
		||||
 | 
			
		||||
        'Call ended'
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
@@ -120,24 +125,6 @@ module Voice
 | 
			
		||||
          additional_attributes: additional_attributes
 | 
			
		||||
        )
 | 
			
		||||
      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
 | 
			
		||||
 
 | 
			
		||||
@@ -36,7 +36,7 @@ module Voice
 | 
			
		||||
 | 
			
		||||
    def handle_conference_end
 | 
			
		||||
      current_status = conversation.additional_attributes['call_status']
 | 
			
		||||
      
 | 
			
		||||
 | 
			
		||||
      if current_status == 'in_progress'
 | 
			
		||||
        call_status_manager.process_status_update('ended')
 | 
			
		||||
      elsif current_status == 'ringing'
 | 
			
		||||
@@ -64,18 +64,18 @@ module Voice
 | 
			
		||||
 | 
			
		||||
    def handle_agent_join
 | 
			
		||||
      conversation.additional_attributes['agent_joined_at'] = Time.now.to_i
 | 
			
		||||
      
 | 
			
		||||
      if ringing_call?
 | 
			
		||||
        call_status_manager.process_status_update('in_progress')
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      return unless ringing_call?
 | 
			
		||||
 | 
			
		||||
      call_status_manager.process_status_update('in_progress')
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def handle_caller_join
 | 
			
		||||
      conversation.additional_attributes['caller_joined_at'] = Time.now.to_i
 | 
			
		||||
      
 | 
			
		||||
      if outbound_call? && ringing_call?
 | 
			
		||||
        call_status_manager.process_status_update('in_progress')
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      return unless outbound_call? && ringing_call?
 | 
			
		||||
 | 
			
		||||
      call_status_manager.process_status_update('in_progress')
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def agent_participant?
 | 
			
		||||
@@ -102,4 +102,4 @@ module Voice
 | 
			
		||||
      conversation.additional_attributes['agent_joined_at'].present?
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
@@ -78,31 +78,8 @@ module Voice
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def broadcast_agent_notification(conversation, info)
 | 
			
		||||
      contact = conversation.contact
 | 
			
		||||
      inbox = conversation.inbox
 | 
			
		||||
      
 | 
			
		||||
      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'
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      )
 | 
			
		||||
      # This method is no longer needed since conversation.created events
 | 
			
		||||
      # will handle incoming call notifications
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -6,16 +6,14 @@ module Voice
 | 
			
		||||
      find_inbox
 | 
			
		||||
      create_contact
 | 
			
		||||
 | 
			
		||||
      # Use a transaction to ensure the conversation and voice call message are created together
 | 
			
		||||
      # This ensures the voice call message is created before any auto-assignment activity messages
 | 
			
		||||
      # Use a transaction to ensure conversation, message, and call status are all set together
 | 
			
		||||
      # This ensures only one conversation.created event with complete call data
 | 
			
		||||
      ActiveRecord::Base.transaction do
 | 
			
		||||
        create_conversation
 | 
			
		||||
        create_voice_call_message
 | 
			
		||||
        set_initial_call_status
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      # Create activity message separately, after the voice call message
 | 
			
		||||
      create_activity_message
 | 
			
		||||
 | 
			
		||||
      generate_twiml_response
 | 
			
		||||
 | 
			
		||||
    rescue StandardError => e
 | 
			
		||||
@@ -132,68 +130,42 @@ module Voice
 | 
			
		||||
        message_params
 | 
			
		||||
      ).perform
 | 
			
		||||
 | 
			
		||||
      # Broadcast call notification
 | 
			
		||||
      broadcast_call_status
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # Create activity message separately after the voice call message
 | 
			
		||||
    def create_activity_message
 | 
			
		||||
      # Use CallStatusManager for consistency
 | 
			
		||||
      status_manager = Voice::CallStatus::Manager.new(
 | 
			
		||||
        conversation: @conversation,
 | 
			
		||||
        call_sid: caller_info[:call_sid],
 | 
			
		||||
        provider: :twilio
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      # Process ringing status with custom activity message
 | 
			
		||||
    # Set initial call status within the transaction
 | 
			
		||||
    def set_initial_call_status
 | 
			
		||||
      # Set call status directly on conversation to avoid separate broadcast
 | 
			
		||||
      @conversation.additional_attributes['call_status'] = 'ringing'
 | 
			
		||||
      @conversation.additional_attributes['call_started_at'] = Time.now.to_i
 | 
			
		||||
      @conversation.save!
 | 
			
		||||
      
 | 
			
		||||
      # Create activity message directly without CallStatusManager broadcast
 | 
			
		||||
      custom_message = "Incoming call from #{@contact.name.presence || caller_info[:from_number]}"
 | 
			
		||||
      status_manager.process_status_update('ringing', nil, true, custom_message)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def broadcast_call_status
 | 
			
		||||
      # Get contact name, ensuring we have a valid value
 | 
			
		||||
      contact_name_value = @contact.name.presence || caller_info[:from_number]
 | 
			
		||||
 | 
			
		||||
      # Create the data payload
 | 
			
		||||
      broadcast_data = {
 | 
			
		||||
        call_sid: caller_info[:call_sid],
 | 
			
		||||
        conversation_id: @conversation.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
 | 
			
		||||
        }
 | 
			
		||||
      @conversation.messages.create!(
 | 
			
		||||
        account_id: @conversation.account_id,
 | 
			
		||||
        inbox_id: @conversation.inbox_id,
 | 
			
		||||
        message_type: :activity,
 | 
			
		||||
        content: custom_message,
 | 
			
		||||
        sender: nil
 | 
			
		||||
      )
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def generate_twiml_response
 | 
			
		||||
      conference_name = @conversation.additional_attributes['conference_sid']
 | 
			
		||||
      Rails.logger.info("📞 IncomingCallService: Generating TwiML with conference name: #{conference_name}")
 | 
			
		||||
 | 
			
		||||
      response = Twilio::TwiML::VoiceResponse.new
 | 
			
		||||
      response.say(message: 'Thank you for calling. Please wait while we connect you with an agent.')
 | 
			
		||||
 | 
			
		||||
      # Setup callback URLs
 | 
			
		||||
      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
 | 
			
		||||
      response.dial do |dial|
 | 
			
		||||
      # Now add the caller to the conference with call status callback
 | 
			
		||||
      response.dial(
 | 
			
		||||
        action: call_status_callback_url,
 | 
			
		||||
        method: 'POST'
 | 
			
		||||
      ) do |dial|
 | 
			
		||||
        dial.conference(
 | 
			
		||||
          conference_name,
 | 
			
		||||
          startConferenceOnEnter: false,
 | 
			
		||||
@@ -208,9 +180,7 @@ module Voice
 | 
			
		||||
        )
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      result = response.to_s
 | 
			
		||||
      Rails.logger.info("📞 IncomingCallService: Generated TwiML: #{result}")
 | 
			
		||||
      result
 | 
			
		||||
      response.to_s
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def error_twiml(_message)
 | 
			
		||||
 
 | 
			
		||||
@@ -16,7 +16,6 @@ module Voice
 | 
			
		||||
      # Add the activity message separately, after the voice call message
 | 
			
		||||
      create_activity_message
 | 
			
		||||
 | 
			
		||||
      broadcast_to_agent
 | 
			
		||||
      @conversation
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
@@ -43,7 +42,7 @@ module Voice
 | 
			
		||||
      @conversation.reload
 | 
			
		||||
 | 
			
		||||
      # Log the conversation ID and display_id for debugging
 | 
			
		||||
      Rails.logger.info("🔍 OUTGOING CALL: Created conversation with ID=#{@conversation.id}, display_id=#{@conversation.display_id}")
 | 
			
		||||
      # Conversation created for outgoing call
 | 
			
		||||
 | 
			
		||||
      # The conference_sid should be set by the ConversationFinderService, but we double-check
 | 
			
		||||
      @conference_name = @conversation.additional_attributes['conference_sid']
 | 
			
		||||
@@ -57,30 +56,30 @@ module Voice
 | 
			
		||||
        @conversation.additional_attributes['conference_sid'] = @conference_name
 | 
			
		||||
        @conversation.save!
 | 
			
		||||
 | 
			
		||||
        Rails.logger.info("🔧 OUTGOING CALL: Fixed conference name to #{@conference_name}")
 | 
			
		||||
        # Logging removed
 | 
			
		||||
      else
 | 
			
		||||
        Rails.logger.info("✅ OUTGOING CALL: Using existing conference name #{@conference_name}")
 | 
			
		||||
        # Logging removed
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def initiate_call
 | 
			
		||||
      # Double-check that we have a valid conference name before calling
 | 
			
		||||
      if @conference_name.blank? || !@conference_name.match?(/^conf_account_\d+_conv_\d+$/)
 | 
			
		||||
        Rails.logger.error("❌ OUTGOING CALL: Invalid conference name before initiating call: #{@conference_name}")
 | 
			
		||||
        # Logging removed
 | 
			
		||||
 | 
			
		||||
        # Re-generate the conference name as a last resort
 | 
			
		||||
        @conference_name = "conf_account_#{account.id}_conv_#{@conversation.display_id}"
 | 
			
		||||
        Rails.logger.info("🔧 OUTGOING CALL: Re-generated conference name: #{@conference_name}")
 | 
			
		||||
        # Logging removed
 | 
			
		||||
 | 
			
		||||
        # Update the conversation with the new conference name
 | 
			
		||||
        @conversation.additional_attributes['conference_sid'] = @conference_name
 | 
			
		||||
        @conversation.save!
 | 
			
		||||
      else
 | 
			
		||||
        Rails.logger.info("✅ OUTGOING CALL: Valid conference name: #{@conference_name}")
 | 
			
		||||
        # Logging removed
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      # Log that we're about to initiate the call
 | 
			
		||||
      Rails.logger.info("📞 OUTGOING CALL: Initiating call to #{contact.phone_number} with conference #{@conference_name}")
 | 
			
		||||
        # Logging removed
 | 
			
		||||
 | 
			
		||||
      # Initiate the call using the channel's implementation
 | 
			
		||||
      @call_details = @voice_inbox.channel.initiate_call(
 | 
			
		||||
@@ -90,7 +89,7 @@ module Voice
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      # 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
 | 
			
		||||
      # Status will be properly set by CallStatusManager
 | 
			
		||||
@@ -109,7 +108,7 @@ module Voice
 | 
			
		||||
      @conversation.update!(additional_attributes: updated_attributes)
 | 
			
		||||
 | 
			
		||||
      # Log the final conversation state
 | 
			
		||||
      Rails.logger.info("📞 OUTGOING CALL: Conversation updated with call_sid=#{@call_details[:call_sid]}, conference_sid=#{@conference_name}")
 | 
			
		||||
        # Logging removed
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def create_voice_call_message
 | 
			
		||||
@@ -172,37 +171,5 @@ module Voice
 | 
			
		||||
      status_manager.process_status_update('initiated', nil, true, custom_message)
 | 
			
		||||
    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
 | 
			
		||||
 
 | 
			
		||||
@@ -6,35 +6,35 @@ module Voice
 | 
			
		||||
    def valid?
 | 
			
		||||
      # Skip validation for these cases:
 | 
			
		||||
      return true if skip_validation?
 | 
			
		||||
      
 | 
			
		||||
 | 
			
		||||
      begin
 | 
			
		||||
        # Find the inbox and get the auth token
 | 
			
		||||
        to_number = params['To']
 | 
			
		||||
        inbox = find_voice_inbox(to_number)
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        # Allow callbacks if we can't find the inbox or auth token
 | 
			
		||||
        return true unless inbox
 | 
			
		||||
        return true unless (auth_token = get_auth_token(inbox))
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        # Check if we have a signature to validate
 | 
			
		||||
        signature = request.headers['X-Twilio-Signature']
 | 
			
		||||
        return true unless signature.present?
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        # Validate the signature
 | 
			
		||||
        validator = Twilio::Security::RequestValidator.new(auth_token)
 | 
			
		||||
        url = "#{request.protocol}#{request.host_with_port}#{request.fullpath}"
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        is_valid = validator.validate(url, params.to_unsafe_h, signature)
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        # Log validation result
 | 
			
		||||
        if is_valid
 | 
			
		||||
          Rails.logger.info("✅ TWILIO VALIDATION: Valid signature confirmed")
 | 
			
		||||
          # Twilio signature validation successful
 | 
			
		||||
        else
 | 
			
		||||
          Rails.logger.error("⚠️ TWILIO VALIDATION: Invalid signature for URL: #{url}")
 | 
			
		||||
          Rails.logger.error("Invalid Twilio signature for URL: #{url}")
 | 
			
		||||
          return false
 | 
			
		||||
        end
 | 
			
		||||
      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
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
@@ -42,27 +42,27 @@ module Voice
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    private
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    def skip_validation?
 | 
			
		||||
      # 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 account.blank?
 | 
			
		||||
      
 | 
			
		||||
 | 
			
		||||
      false
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def get_auth_token(inbox)
 | 
			
		||||
      channel = inbox.channel
 | 
			
		||||
      return nil unless channel.is_a?(Channel::Voice)
 | 
			
		||||
      
 | 
			
		||||
 | 
			
		||||
      provider_config = channel.provider_config_hash
 | 
			
		||||
      provider_config['auth_token'] if provider_config.present?
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def find_voice_inbox(to_number)
 | 
			
		||||
      return nil if to_number.blank?
 | 
			
		||||
      
 | 
			
		||||
 | 
			
		||||
      account.inboxes
 | 
			
		||||
             .where(channel_type: 'Channel::Voice')
 | 
			
		||||
             .joins('INNER JOIN channel_voice ON channel_voice.id = inboxes.channel_id')
 | 
			
		||||
@@ -70,4 +70,4 @@ module Voice
 | 
			
		||||
             .first
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
@@ -104,6 +104,7 @@ Rails.application.routes.draw do
 | 
			
		||||
                collection do
 | 
			
		||||
                  post :incoming
 | 
			
		||||
                  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
 | 
			
		||||
                end
 | 
			
		||||
              end
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user