diff --git a/app/controllers/api/v1/accounts/channels/voice/webhooks_controller.rb b/app/controllers/api/v1/accounts/channels/voice/webhooks_controller.rb
index bb9e6eec1..6487d3d37 100644
--- a/app/controllers/api/v1/accounts/channels/voice/webhooks_controller.rb
+++ b/app/controllers/api/v1/accounts/channels/voice/webhooks_controller.rb
@@ -1,6 +1,26 @@
class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts::BaseController
skip_before_action :authenticate_user!, :set_current_user, only: [:incoming, :conference_status]
+ # Removed skip_before_action :verify_authenticity_token (it's not defined in BaseController)
+ 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]
+
+ # Handle CORS preflight OPTIONS requests
+ def handle_options_request
+ 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'
+ headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Twilio-Signature'
+ headers['Access-Control-Max-Age'] = '86400' # 24 hours
+ end
# Handle incoming calls from Twilio
def incoming
@@ -45,6 +65,18 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts
'call_direction' => 'inbound'
}
)
+
+ # Use format that includes account ID and conversation display ID
+ conference_name = "conf_account_#{Current.account.id}_conv_#{conversation.display_id}"
+
+ # Add conference name to conversation
+ conversation.additional_attributes['conference_sid'] = conference_name
+ conversation.save!
+
+ # SUPER EXPLICIT DEBUG logging for the conference name
+ Rails.logger.info("🎧🎧🎧 CREATING INITIAL CONFERENCE: '#{conference_name}' for account_id: #{Current.account.id}, conversation: #{conversation.display_id}")
+ Rails.logger.info("🎧🎧🎧 SAVED TO conversation.additional_attributes['conference_sid'] = '#{conversation.additional_attributes['conference_sid']}'")
+ Rails.logger.info("Creating conference: #{conference_name} for account: #{Current.account.id}, conversation: #{conversation.display_id}")
# Create an activity message for the incoming call
Messages::MessageBuilder.new(
@@ -71,134 +103,231 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts
contact_id: contact.id
}, account_id: inbox.account_id)
- # Generate minimal TwiML response
+ # Generate simplified TwiML response
response = Twilio::TwiML::VoiceResponse.new
- response.say(message: 'Thank you for calling. An agent will be with you shortly.')
- response.pause(length: 2)
-
- # Add minimal conference with just the essential parameters
+ response.say(message: 'Thank you for calling. Please wait while we connect you with an agent.')
+
+ # Log what we're doing
+ Rails.logger.info("🎧🎧🎧 CALLER CONNECTING TO CONFERENCE: '#{conference_name}'")
+
+ # Simple dialog approach for caller
response.dial do |dial|
dial.conference(
- "conf_#{call_sid}",
- status_callback: "#{base_url}/api/v1/accounts/#{Current.account.id}/channels/voice/webhooks/conference_status",
- status_callback_event: 'start end join leave',
- status_callback_method: 'POST',
- end_conference_on_exit: true
+ conference_name,
+ startConferenceOnEnter: false, # Caller waits for agent
+ endConferenceOnExit: true, # End when agent leaves
+ beep: false, # No beep sounds
+ muted: false, # Caller can speak
+ waitUrl: '', # No hold music
+ statusCallback: "#{base_url.gsub(/\/$/, '')}/api/v1/accounts/#{Current.account.id}/channels/voice/webhooks/conference_status",
+ statusCallbackMethod: 'POST',
+ statusCallbackEvent: 'start end join leave',
+ participantLabel: "caller-#{call_sid.last(8)}"
)
end
+
+ # Simplified logging
+ Rails.logger.info("🔊 Created simplified conference #{conference_name} for account #{Current.account.id}")
+ Rails.logger.info("🔊 Conference parameters: startConferenceOnEnter=false, endConferenceOnExit=true")
render xml: response.to_s
end
- # Handle conference status updates
+ # Handle conference status updates with enhanced logging for audio troubleshooting
def conference_status
- call_sid = params['CallSid']
- conference_sid = params['ConferenceSid']
- event = params['StatusCallbackEvent']
-
- # For local development, set Current.account if not set
- if Rails.env.development? && !Current.account
+ # Set CORS headers first to ensure they're always included
+ set_cors_headers
+
+ # SUPER IMPORTANT: Return a minimal response immediately for OPTIONS requests
+ if request.method == "OPTIONS"
+ return head :ok
+ end
+
+ # Wrap everything in a rescue block to prevent large error responses
+ begin
+ # Log only essential parameters to avoid large log messages
+ Rails.logger.info("📞 Conference status webhook: event=#{params['StatusCallbackEvent']}, call_sid=#{params['CallSid']&.truncate(10)}")
+
+ call_sid = params['CallSid']
+ conference_sid = params['ConferenceSid']
+ event = params['StatusCallbackEvent']
account_id = params[:account_id]
- Current.account = Account.find(account_id) if account_id
- end
-
- # Try to find the conversation by call_sid or conference_sid
- conversation = if call_sid.present?
- Current.account.conversations
- .where("additional_attributes->>'call_sid' = ?", call_sid)
- .first
- elsif conference_sid.present?
- Current.account.conversations
- .where("additional_attributes->>'conference_sid' = ?", conference_sid)
- .first
- end
-
- # Return minimal error if conversation not found
- return head :not_found unless conversation
-
- # Update conversation with conference info
- conversation.additional_attributes ||= {}
- conversation.additional_attributes['conference_sid'] = conference_sid
-
- case event
- when 'conference-start'
- conversation.additional_attributes['conference_status'] = 'started'
- activity_message = 'Conference started'
- when 'conference-end'
- conversation.additional_attributes['conference_status'] = 'ended'
- conversation.additional_attributes['call_status'] = 'completed'
- conversation.additional_attributes['call_ended_at'] = Time.now.to_i
- conversation.status = :resolved
- activity_message = 'Conference ended'
- when 'participant-join'
- activity_message = 'Participant joined the call'
- when 'participant-leave'
- activity_message = 'Participant left the call'
- else
- activity_message = 'Call event occurred'
- end
-
- conversation.save!
-
- # Create activity message with minimal attributes
- Messages::MessageBuilder.new(
- nil,
- conversation,
- {
- content: activity_message,
- message_type: :activity,
- additional_attributes: {
+ participant_label = params['ParticipantLabel']
+
+ # For local development, set Current.account if not set
+ if !Current.account
+ Current.account = Account.find(account_id) if account_id
+ end
+
+ # Try to find the conversation by parsing conference_sid directly or through additional attributes
+ conversation = nil
+
+ # First try to find by exact conference_sid match
+ if conference_sid.present?
+ conversation = Current.account.conversations
+ .where("additional_attributes->>'conference_sid' = ?", conference_sid)
+ .first
+ end
+
+ # If not found and conference_sid looks like our format, extract conversation ID directly
+ if conversation.nil? && conference_sid.present? && conference_sid.start_with?('conf_account_')
+ # Try to parse conversation ID from conference name (conf_account_X_conv_Y)
+ conference_parts = conference_sid.match(/conf_account_\d+_conv_(\d+)/)
+ if conference_parts && conference_parts[1].present?
+ conversation_display_id = conference_parts[1]
+ conversation = Current.account.conversations.find_by(display_id: conversation_display_id)
+ Rails.logger.info("🎧 Found conversation by display_id=#{conversation_display_id} from conference_sid=#{conference_sid}")
+ end
+ end
+
+ # If still not found, try by call_sid
+ if conversation.nil? && call_sid.present?
+ conversation = Current.account.conversations
+ .where("additional_attributes->>'call_sid' = ?", call_sid)
+ .first
+ end
+
+ # If conversation found, update it
+ if conversation
+ # Add participant info to conversation for debugging
+ begin
+ # Update participant list directly in conversation for real-time monitoring
+ conversation.additional_attributes ||= {}
+ conversation.additional_attributes['participants'] ||= []
+
+ # Check if this participant is already in the list
+ existing_participant = conversation.additional_attributes['participants'].find do |p|
+ p['call_sid'] == call_sid
+ end
+
+ if event == 'join'
+ # Add participant if not exists
+ unless existing_participant
+ conversation.additional_attributes['participants'] << {
+ 'call_sid' => call_sid,
+ 'label' => participant_label,
+ 'joined_at' => Time.now.to_i
+ }
+ end
+ elsif event == 'leave'
+ # Remove participant if exists
+ conversation.additional_attributes['participants'].reject! { |p| p['call_sid'] == call_sid }
+ end
+
+ # Always flag outbound calls that need agent join
+ if conversation.additional_attributes['call_direction'] == 'outbound' &&
+ participant_label&.start_with?('caller-') &&
+ event == 'join'
+
+ # This is the customer joining an outbound call - flag for agent to join immediately
+ conversation.additional_attributes['requires_agent_join'] = true
+
+ # Broadcast an immediate "incoming call" notification for the agent
+ broadcast_call_status('incoming_call', {
+ call_sid: call_sid,
+ conversation_id: conversation.id,
+ inbox_id: conversation.inbox_id,
+ inbox_name: conversation.inbox.name,
+ contact_name: conversation.contact.name || 'Outbound Call',
+ contact_id: conversation.contact_id,
+ is_outbound: true
+ }, account_id: conversation.account_id)
+ end
+
+ # Save the updated conversation
+ conversation.save!
+ rescue => participant_error
+ Rails.logger.error("Error updating participants: #{participant_error.message}")
+ end
+
+ # Process conversation updates in the background to avoid delaying response
+ Sidekiq::Client.enqueue_to(
+ 'default',
+ 'ProcessConferenceStatusJob',
+ conversation_id: conversation.id,
+ event: event,
call_sid: call_sid,
- event_type: event
- }
- }
- ).perform
-
- # Broadcast call status updates on account-level channel
- broadcast_call_status('call_status_changed', {
- call_sid: call_sid,
- status: conversation.additional_attributes['call_status'] || 'in-progress',
- conversation_id: conversation.id
- }, account_id: conversation.account_id)
-
- # Return minimal response
+ conference_sid: conference_sid,
+ account_id: Current.account.id,
+ participant_sid: params['ParticipantSid'],
+ participant_label: participant_label,
+ call_sid_ending_with: params['CallSidEndingWith'],
+ audio_level: params['AudioLevel']
+ )
+ else
+ Rails.logger.error("⚠️ Conference webhook: Conversation not found for call_sid=#{call_sid}, conference_sid=#{conference_sid}")
+ end
+ rescue => e
+ # Just log errors but don't let them affect the response
+ Rails.logger.error("Error in conference_status: #{e.message[0..100]}")
+ end
+
+ # CRITICAL: Always return a minimal success response - this is what Twilio expects
+ # Return just a 200 OK header with minimal content
head :ok
end
private
def validate_twilio_signature
+ # Skip for OPTIONS requests
+ return true if request.method == "OPTIONS"
+
# Find the inbox for the phone number
to_number = params['To']
# Skip validation for local development
return true if Rails.env.development?
- inbox = Current.account.inboxes
- .where(channel_type: 'Channel::Voice')
- .joins('INNER JOIN channel_voice ON channel_voice.id = inboxes.channel_id')
- .where('channel_voice.phone_number = ?', to_number)
- .first
+ # Skip if no To param (happens in some callback scenarios)
+ return true if to_number.blank?
- return render_error('Inbox not found for this phone number') unless inbox
+ begin
+ inbox = Current.account.inboxes
+ .where(channel_type: 'Channel::Voice')
+ .joins('INNER JOIN channel_voice ON channel_voice.id = inboxes.channel_id')
+ .where('channel_voice.phone_number = ?', to_number)
+ .first
- # Get Twilio Auth Token from inbox's channel
- channel = inbox.channel
- return render_error('Channel is not a voice channel') unless channel.is_a?(Channel::Voice)
+ # If inbox not found, we'll log it but allow the request for Twilio callbacks
+ # This is necessary because conference callbacks may not have the original To number
+ unless inbox
+ Rails.logger.warn("⚠️ No inbox found for phone number #{to_number} - allowing request for Twilio callback")
+ return true
+ end
- auth_token = channel.provider_config_hash['auth_token']
+ # Get Twilio Auth Token from inbox's channel
+ channel = inbox.channel
+ unless channel.is_a?(Channel::Voice)
+ Rails.logger.warn("⚠️ Channel is not a voice channel - allowing request for Twilio callback")
+ return true
+ end
- # Validate incoming request signature
- validator = Twilio::Security::RequestValidator.new(auth_token)
- signature = request.headers['X-Twilio-Signature']
+ auth_token = channel.provider_config_hash['auth_token']
- # Check if incoming signature is valid
- url = "#{request.protocol}#{request.host_with_port}#{request.fullpath}"
- is_valid = validator.validate(url, params.to_unsafe_h, signature)
+ # Validate incoming request signature if present
+ signature = request.headers['X-Twilio-Signature']
+
+ # Allow requests without signature for callbacks
+ unless signature.present?
+ Rails.logger.warn("⚠️ No Twilio signature in request - allowing for callbacks")
+ return true
+ end
- unless is_valid
- render_error('Invalid Twilio signature')
- return false
+ # 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)
+
+ unless is_valid
+ Rails.logger.error("⚠️ Invalid Twilio signature detected")
+ render_error('Invalid Twilio signature')
+ return false
+ end
+ rescue => e
+ Rails.logger.error("Error validating Twilio signature: #{e.message}")
+ # Always allow callbacks even if validation fails
+ return true
end
true
diff --git a/app/controllers/api/v1/accounts/contacts/calls_controller.rb b/app/controllers/api/v1/accounts/contacts/calls_controller.rb
index e97166e20..51b60fe0f 100644
--- a/app/controllers/api/v1/accounts/contacts/calls_controller.rb
+++ b/app/controllers/api/v1/accounts/contacts/calls_controller.rb
@@ -20,8 +20,19 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
# Create a new conversation for this call
conversation = find_or_create_conversation(voice_inbox)
+ # CRITICAL: Create a conference name FIRST to ensure consistency
+ conference_name = "conf_account_#{Current.account.id}_conv_#{conversation.display_id}"
+
+ # Create conference for outbound call
+
# Initiate the call using the channel's implementation - this returns the call details
- call_details = voice_inbox.channel.initiate_call(to: @contact.phone_number)
+ call_details = voice_inbox.channel.initiate_call(
+ to: @contact.phone_number,
+ conference_name: conference_name
+ )
+
+ # Add the conference name to the call details
+ call_details[:conference_sid] = conference_name
# Create a message for this call with call details
params = {
@@ -35,8 +46,42 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
# Make sure the conversation has the latest activity timestamp
conversation.update(last_activity_at: Time.current)
- # Store call SID and status for front-end
- conversation.update!(additional_attributes: (conversation.additional_attributes || {}).merge(call_details))
+
+ # Add the conference_sid to the conversation's additional_attributes
+ # Ensure we don't lose other attributes that might already be set
+ updated_attributes = (conversation.additional_attributes || {}).merge(call_details)
+
+ # CRITICAL: Add additional attributes needed for immediate agent join notification
+ updated_attributes[:call_status] = 'in-progress'
+ updated_attributes[:requires_agent_join] = true
+
+ # Now update the conversation with all the attributes
+ conversation.update!(additional_attributes: updated_attributes)
+
+ # Conference created successfully
+
+ # DIRECT AGENT NOTIFICATION: Immediately broadcast to ActionCable that agent needs to join
+ # This bypasses any queueing and directly tells the frontend a call needs agent
+ ActionCable.server.broadcast(
+ "account_#{Current.account.id}",
+ {
+ event: 'incoming_call',
+ data: {
+ call_sid: call_details[:call_sid],
+ conversation_id: conversation.id,
+ inbox_id: voice_inbox.id,
+ inbox_name: voice_inbox.name,
+ contact_name: @contact.name || @contact.phone_number,
+ contact_id: @contact.id,
+ account_id: Current.account.id,
+ is_outbound: true,
+ conference_sid: conference_name,
+ requires_agent_join: true,
+ # Send additional information to help with debugging
+ call_direction: 'outbound'
+ }
+ }
+ )
# Broadcast the conversation and message to the appropriate ActionCable channels
ActionCableBroadcastJob.perform_later(
diff --git a/app/controllers/api/v1/accounts/voice/tokens_controller.rb b/app/controllers/api/v1/accounts/voice/tokens_controller.rb
new file mode 100644
index 000000000..c806b6fd0
--- /dev/null
+++ b/app/controllers/api/v1/accounts/voice/tokens_controller.rb
@@ -0,0 +1,136 @@
+require 'twilio-ruby'
+
+class Api::V1::Accounts::Voice::TokensController < Api::V1::Accounts::BaseController
+ def create
+ # 1. Find inbox
+ inbox = Current.account.inboxes.find_by(id: params[:inbox_id])
+
+ unless inbox
+ render json: { error: 'Inbox not found' }, status: :not_found
+ return
+ end
+
+ # 2. Get Twilio credentials from channel
+ channel = inbox.channel
+ config = channel.provider_config_hash || {}
+
+ # Get Twilio credentials from channel config
+ account_sid = config['account_sid']
+ auth_token = config['auth_token']
+ api_key_sid = config['api_key_sid']
+ api_key_secret = config['api_key_secret']
+ phone_number = channel.phone_number
+
+ # 3. Create a unique client identifier
+ client_identity = "agent-#{current_user.id}-#{Current.account.id}"
+
+ # 4. Generate Twilio Access Token
+ begin
+ # Simple log for debugging
+ Rails.logger.debug "Generating Twilio token for identity: #{client_identity} using API key: #{api_key_sid}"
+
+ # Create Twilio JWT AccessToken with API key credentials
+ token = Twilio::JWT::AccessToken.new(
+ account_sid,
+ api_key_sid,
+ api_key_secret,
+ identity: client_identity,
+ ttl: 3600
+ )
+
+ # Create Voice grant for the token
+ voice_grant = Twilio::JWT::AccessToken::VoiceGrant.new
+
+ # 1. Always enable incoming calls
+ voice_grant.incoming_allow = true
+
+ # 2. CRITICAL: For outgoing WebRTC calls, we need an application SID (TwiML App SID)
+ # This is required to fix error 31002: "Token does not allow outgoing calls"
+ outgoing_application_sid = config['outgoing_application_sid']
+
+ # For WebRTC calls with Twilio Voice SDK, the outgoing_application_sid is mandatory
+ # Without it, browser-based calling won't work properly
+
+ if outgoing_application_sid.present?
+ # We have a configured TwiML App SID - use it
+ voice_grant.outgoing_application_sid = outgoing_application_sid
+
+ # Set additional parameters that will be passed to the TwiML app
+ # CRITICAL: Include is_agent=true to help identify agent connections on the server side
+ voice_grant.outgoing_application_params = {
+ 'account_id' => Current.account.id.to_s,
+ 'agent_id' => current_user.id.to_s,
+ 'identity' => client_identity,
+ 'client_name' => client_identity,
+ 'accountSid' => account_sid,
+ 'is_agent' => 'true' # CRITICAL: Identify agent connections
+ }
+
+ Rails.logger.info("Using configured TwiML App SID: #{outgoing_application_sid}")
+ else
+ # Log a clear error message - TwiML App SID is essential for browser-based calling
+ twiml_url = "#{ENV.fetch('FRONTEND_URL', '')}/api/v1/accounts/#{Current.account.id}/voice/twiml_for_client"
+
+ # Return detailed instructions for setting up TwiML App
+ setup_instructions = <<~INSTRUCTIONS
+ No TwiML App SID configured! Browser-based calling requires a Twilio TwiML App.
+
+ To create a TwiML App:
+ 1. Go to Twilio Console > Voice > TwiML Apps
+ 2. Create a new TwiML app
+ 3. Set the Voice Request URL to: #{twiml_url}
+ 4. Save and copy the new TwiML App SID
+ 5. Update your voice channel configuration with this SID
+ INSTRUCTIONS
+
+ Rails.logger.error(setup_instructions)
+
+ # Still try to create a token, but with a warning that it will likely fail
+ # Use a fake App SID to avoid immediate rejection
+ voice_grant.outgoing_application_sid = "AP00000000000000000000000000000000"
+
+ # Set parameters that would be used if it were a real App SID
+ voice_grant.outgoing_application_params = {
+ 'account_id' => Current.account.id.to_s,
+ 'agent_id' => current_user.id.to_s,
+ 'missing_twiml_app' => true # Flag to indicate the problem
+ }
+ end
+
+ # Log info for detailed debugging
+ Rails.logger.info("Voice token created for identity: #{client_identity}")
+ Rails.logger.info("Outgoing params: #{voice_grant.outgoing_application_params.inspect}")
+
+ # Add the grant to the token
+ token.add_grant(voice_grant)
+
+ # Add a warning message if no TwiML App SID is configured
+ twiml_url = "#{ENV.fetch('FRONTEND_URL', '')}/api/v1/accounts/#{Current.account.id}/voice/twiml_for_client"
+ warning_message = !outgoing_application_sid.present? ?
+ "Browser calling requires a Twilio TwiML App SID. Configure one in the voice channel settings." : nil
+
+ # Return token response with additional debugging info
+ render json: {
+ token: token.to_jwt,
+ identity: client_identity,
+ voice_enabled: true,
+ account_sid: account_sid,
+ agent_id: current_user.id,
+ account_id: Current.account.id,
+ inbox_id: inbox.id,
+ phone_number: phone_number,
+ twiml_endpoint: twiml_url,
+ has_twiml_app: outgoing_application_sid.present?,
+ warning: warning_message
+ }
+ rescue => e
+ Rails.logger.error("Error generating token: #{e.message}")
+ Rails.logger.error("Backtrace: #{e.backtrace[0..5].join("\n")}")
+
+ render json: {
+ error: 'Failed to generate token',
+ details: e.message
+ }, status: :internal_server_error
+ end
+ end
+end
\ No newline at end of file
diff --git a/app/controllers/api/v1/accounts/voice_controller.rb b/app/controllers/api/v1/accounts/voice_controller.rb
index b023d332e..e665ba39e 100644
--- a/app/controllers/api/v1/accounts/voice_controller.rb
+++ b/app/controllers/api/v1/accounts/voice_controller.rb
@@ -1,209 +1,548 @@
+require 'twilio-ruby'
+
class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
- before_action :fetch_conversation, only: [:end_call, :join_call, :reject_call, :call_status]
+ before_action :fetch_conversation, only: [:end_call, :join_call, :reject_call]
+ skip_before_action :authenticate_user!, only: [:twiml_for_client]
+ # Removed skip_before_action :verify_authenticity_token (it's not defined in BaseController)
+ protect_from_forgery with: :null_session, only: [:twiml_for_client]
+ before_action :handle_options_request, only: [:twiml_for_client]
+
+ # Handle CORS preflight OPTIONS requests
+ def handle_options_request
+ if request.method == "OPTIONS"
+ set_cors_headers
+ head :ok
+ return true
+ end
+ false
+ end
+
+ def set_cors_headers
+ # Add explicit Content-Type header to ensure browser requests are handled properly
+ headers['Content-Type'] = 'text/xml; charset=utf-8' unless request.method == 'OPTIONS'
+
+ # Standard CORS headers
+ headers['Access-Control-Allow-Origin'] = '*'
+ headers['Access-Control-Allow-Methods'] = 'POST, GET, OPTIONS'
+ headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Twilio-Signature'
+ headers['Access-Control-Max-Age'] = '86400' # 24 hours
+
+ # Log headers for debugging
+ Rails.logger.info("🚨 RESPONSE HEADERS SET: #{headers.to_h.inspect}")
+ end
+
+ # No hard-coded credentials - we'll fetch them from the channel
def end_call
call_sid = params[:call_sid] || @conversation.additional_attributes&.dig('call_sid')
return render json: { error: 'No active call found' }, status: :not_found unless call_sid
- # Get the inbox and channel information
- inbox = @conversation.inbox
- channel = inbox&.channel
+ # Get the channel config
+ channel = @conversation.inbox.channel
+ config = channel.provider_config_hash
- if channel.is_a?(Channel::Voice) && channel.provider == 'twilio'
- config = channel.provider_config_hash
+ # Create a Twilio client using credentials from the channel
+ client = Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
+ call = client.calls(call_sid).fetch
+
+ # Only try to end the call if it's still in progress
+ if call.status == 'in-progress' || call.status == 'ringing'
+ client.calls(call_sid).update(status: 'completed')
- begin
- # Create a Twilio client and end the call
- client = Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
- call = client.calls(call_sid).fetch
-
- # Only try to end the call if it's still in progress
- if call.status == 'in-progress' || call.status == 'ringing'
- client.calls(call_sid).update(status: 'completed')
-
- # Update conversation call status
- @conversation.additional_attributes ||= {}
- @conversation.additional_attributes['call_status'] = 'completed'
- @conversation.save!
-
- # Create an activity message noting the call has ended
- Messages::MessageBuilder.new(
- nil,
- @conversation,
- {
- content: 'Call ended by agent',
- message_type: :activity,
- additional_attributes: {
- call_sid: call_sid,
- call_status: 'completed',
- ended_by: current_user.name
- }
- }
- ).perform
-
- render json: { status: 'success', message: 'Call successfully ended' }
- else
- render json: { status: 'success', message: "Call already in '#{call.status}' state" }
- end
- rescue Twilio::REST::RestError => e
- render json: { error: "Failed to end call: #{e.message}" }, status: :internal_server_error
- end
+ # Update conversation call status
+ @conversation.additional_attributes['call_status'] = 'completed'
+ @conversation.save!
+
+ # Create an activity message noting the call has ended
+ Messages::MessageBuilder.new(
+ nil,
+ @conversation,
+ {
+ content: 'Call ended by agent',
+ message_type: :activity,
+ additional_attributes: {
+ call_sid: call_sid,
+ call_status: 'completed',
+ ended_by: current_user.name
+ }
+ }
+ ).perform
+
+ render json: { status: 'success', message: 'Call successfully ended' }
else
- render json: { error: 'Unsupported channel provider for call control' }, status: :unprocessable_entity
+ render json: { status: 'success', message: "Call already in '#{call.status}' state" }
end
+ rescue => e
+ render json: { error: "Failed to end call: #{e.message}" }, status: :internal_server_error
end
def join_call
call_sid = params[:call_sid] || @conversation.additional_attributes&.dig('call_sid')
- return render json: { error: 'No active call found' }, status: :not_found unless call_sid
- # Get the inbox and channel information
- inbox = @conversation.inbox
- channel = inbox&.channel
+ # Check if this is an outbound call that needs to be joined (might not have call_sid yet)
+ is_outbound_call = @conversation.additional_attributes&.dig('requires_agent_join') == true
- if channel.is_a?(Channel::Voice) && channel.provider == 'twilio'
- config = channel.provider_config_hash
-
- begin
- # Create a Twilio client
- client = Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
-
- # Get the conference SID from the conversation attributes
- # For incoming calls, Twilio typically places the caller in a conference that agents can join
- conference_sid = @conversation.additional_attributes&.dig('conference_sid')
-
- if conference_sid
- # Create a call that connects the agent to the conference
- client.calls.create(
- to: current_user.phone_number || config['agent_phone_number'],
- from: channel.phone_number,
- status_callback: "#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/status_callback",
- status_callback_event: ['initiated', 'ringing', 'answered', 'completed'],
- status_callback_method: 'POST',
- url: "#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/twiml?conference_sid=#{conference_sid}&agent_id=#{current_user.id}"
- )
-
- # Update conversation to show agent joined
- @conversation.additional_attributes ||= {}
- @conversation.additional_attributes['agent_joined'] = true
- @conversation.additional_attributes['joined_at'] = Time.now.to_i
- @conversation.additional_attributes['joined_by'] = {
- id: current_user.id,
- name: current_user.name
- }
- @conversation.save!
-
- # Create an activity message noting the agent joined
- Messages::MessageBuilder.new(
- nil,
- @conversation,
- {
- content: "#{current_user.name} joined the call",
- message_type: :activity,
- additional_attributes: {
- call_sid: call_sid,
- conference_sid: conference_sid,
- joined_by: current_user.name,
- joined_at: Time.now.to_i
- }
- }
- ).perform
-
- render json: {
- status: 'success',
- message: 'Agent joining call',
- conference_sid: conference_sid
- }
- else
- render json: { error: 'Conference not found for this call' }, status: :unprocessable_entity
- end
- rescue Twilio::REST::RestError => e
- render json: { error: "Failed to join call: #{e.message}" }, status: :internal_server_error
- end
- else
- render json: { error: 'Unsupported channel provider for call control' }, status: :unprocessable_entity
+ unless call_sid || is_outbound_call
+ return render json: { error: 'No active call found' }, status: :not_found
end
+
+ # Get the conference SID from the conversation
+ conference_sid = @conversation.additional_attributes&.dig('conference_sid')
+
+ # Check conversation record for conference information
+
+ # If not found, create one using account ID and conversation display ID
+ unless conference_sid
+ # Use the same format as in webhooks_controller for consistency
+ conference_sid = "conf_account_#{Current.account.id}_conv_#{@conversation.display_id}"
+
+ # Save it for future use
+ @conversation.additional_attributes ||= {}
+ @conversation.additional_attributes['conference_sid'] = conference_sid
+ @conversation.save!
+
+ # Created new conference
+ else
+ # Using existing conference
+ end
+
+ # For outbound calls, ensure we also update call_status if not already set
+ if is_outbound_call && !@conversation.additional_attributes['call_status']
+ @conversation.additional_attributes['call_status'] = 'in-progress'
+ @conversation.save!
+ # Set call status for outbound call
+ end
+
+ # Agent joining call via WebRTC
+
+ # Update conversation to show agent joined
+ @conversation.additional_attributes['agent_joined'] = true
+ @conversation.additional_attributes['joined_at'] = Time.now.to_i
+ @conversation.additional_attributes['joined_by'] = {
+ id: current_user.id,
+ name: current_user.name
+ }
+ @conversation.save!
+
+ # Create an activity message
+ Messages::MessageBuilder.new(
+ nil,
+ @conversation,
+ {
+ content: "#{current_user.name} joined the call",
+ message_type: :activity,
+ additional_attributes: {
+ call_sid: call_sid,
+ conference_sid: conference_sid,
+ joined_by: current_user.name,
+ joined_at: Time.now.to_i
+ }
+ }
+ ).perform
+
+ # Return conference information for the WebRTC client with detailed logging
+ response_data = {
+ status: 'success',
+ message: 'Agent joining call via WebRTC',
+ conference_sid: conference_sid,
+ using_webrtc: true,
+ # Add useful debugging info
+ conversation_id: @conversation.display_id,
+ account_id: Current.account.id,
+ # Add even more debug info
+ conference_name_debug: "#{conference_sid}"
+ }
+
+ # Return response with conference information
+
+ render json: response_data
+ rescue => e
+ Rails.logger.error("Error joining call: #{e.message}")
+ render json: { error: "Failed to join call: #{e.message}" }, status: :internal_server_error
end
def reject_call
call_sid = params[:call_sid] || @conversation.additional_attributes&.dig('call_sid')
return render json: { error: 'No active call found' }, status: :not_found unless call_sid
- # Get the inbox and channel information
- inbox = @conversation.inbox
- channel = inbox&.channel
+ # Update conversation to show agent rejected call
+ @conversation.additional_attributes['agent_rejected'] = true
+ @conversation.additional_attributes['rejected_at'] = Time.now.to_i
+ @conversation.additional_attributes['rejected_by'] = {
+ id: current_user.id,
+ name: current_user.name
+ }
+ @conversation.save!
- if channel.is_a?(Channel::Voice) && channel.provider == 'twilio'
- # Update conversation to show agent rejected call
- @conversation.additional_attributes ||= {}
- @conversation.additional_attributes['agent_rejected'] = true
- @conversation.additional_attributes['rejected_at'] = Time.now.to_i
- @conversation.additional_attributes['rejected_by'] = {
- id: current_user.id,
- name: current_user.name
- }
- @conversation.save!
-
- # Create an activity message noting the agent rejected the call
- Messages::MessageBuilder.new(
- nil,
- @conversation,
- {
- content: "#{current_user.name} declined to answer",
- message_type: :activity,
- additional_attributes: {
- call_sid: call_sid,
- rejected_by: current_user.name,
- rejected_at: Time.now.to_i
- }
+ # Create an activity message noting the agent rejected the call
+ Messages::MessageBuilder.new(
+ nil,
+ @conversation,
+ {
+ content: "#{current_user.name} declined to answer",
+ message_type: :activity,
+ additional_attributes: {
+ call_sid: call_sid,
+ rejected_by: current_user.name,
+ rejected_at: Time.now.to_i
}
- ).perform
-
- render json: {
- status: 'success',
- message: 'Call rejected by agent'
}
- else
- render json: { error: 'Unsupported channel provider for call control' }, status: :unprocessable_entity
- end
+ ).perform
+
+ render json: {
+ status: 'success',
+ message: 'Call rejected by agent'
+ }
end
def call_status
- call_sid = @conversation.additional_attributes&.dig('call_sid')
- return render json: { error: 'No call found' }, status: :not_found unless call_sid
+ call_sid = params[:call_sid]
+ return render json: { error: 'No active call found' }, status: :not_found unless call_sid
+
+ conversation = Current.account.conversations.where("additional_attributes->>'call_sid' = ?", call_sid).first
+ return render json: { error: 'Conversation not found' }, status: :not_found unless conversation
+
+ # Get the channel config
+ channel = conversation.inbox.channel
+ config = channel.provider_config_hash
- # Get the inbox and channel information
- inbox = @conversation.inbox
- channel = inbox&.channel
-
- if channel.is_a?(Channel::Voice) && channel.provider == 'twilio'
- config = channel.provider_config_hash
+ begin
+ # Create a Twilio client using credentials from the channel
+ client = Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
+ call = client.calls(call_sid).fetch
- begin
- # Create a Twilio client and fetch the call status
- client = Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
- call = client.calls(call_sid).fetch
-
- render json: {
- status: call.status,
- duration: call.duration,
- direction: call.direction,
- from: call.from,
- to: call.to,
- start_time: call.start_time,
- end_time: call.end_time
- }
- rescue Twilio::REST::RestError => e
- render json: { error: "Failed to fetch call status: #{e.message}" }, status: :internal_server_error
- end
- else
- render json: { error: 'Unsupported channel provider for call status' }, status: :unprocessable_entity
+ render json: {
+ status: call.status,
+ duration: call.duration,
+ direction: call.direction,
+ from: call.from,
+ to: call.to,
+ start_time: call.start_time,
+ end_time: call.end_time
+ }
+ rescue => e
+ render json: { error: "Failed to fetch call status: #{e.message}" }, status: :internal_server_error
end
end
+ # TwiML endpoint for Twilio Client browser calls - with ultra-robust error handling
+ def twiml_for_client
+ begin
+ # Log everything for debugging
+ Rails.logger.info("TwiML_FOR_CLIENT CALLED with params: #{params.inspect}")
+
+ # Extract just what we need - the To parameter (conference name)
+ # Check for the To parameter which should be sent by the Twilio client
+ to = params[:To]
+
+ # Simple debug log of what we received
+ Rails.logger.info("📞 Received request for TwiML with To parameter: '#{to}'")
+
+ # Verify the format is what we expect
+ if to && to.match?(/^conf_account_\d+_conv_\d+$/)
+ Rails.logger.info("✅ Conference ID is in the expected format: #{to}")
+ elsif to
+ Rails.logger.info("⚠️ Conference ID does not match expected format: #{to}")
+ end
+
+ # SUPER CRITICAL DEBUGGING - We need to know EXACTLY what is coming in as the To parameter
+ Rails.logger.info("🚨 PARAMS INSPECTION: #{params.to_json}")
+ Rails.logger.info("🚨 TO PARAMETER (CAPS) EXISTS?: #{params.key?(:To)}")
+ Rails.logger.info("🚨 TO PARAMETER (LOWERCASE) EXISTS?: #{params.key?(:to)}")
+ Rails.logger.info("🚨 TO PARAMETER FINAL VALUE: '#{to}'")
+ Rails.logger.info("🚨 TO PARAMETER TYPE: #{to.class}")
+ Rails.logger.info("🚨 TO PARAMETER EMPTY?: #{to.blank?}")
+ Rails.logger.info("🚨 TO PARAMETER STARTS WITH 'conf_'?: #{to.to_s.start_with?('conf_')}")
+
+ # Critical debugging for troubleshooting
+ Rails.logger.info("PARAMS RECEIVED: #{params.inspect}")
+ Rails.logger.info("REQUEST HEADERS: #{request.headers.to_h.select { |k, _| k.start_with?('HTTP_') }.inspect}")
+ Rails.logger.info("CLIENT IP: #{request.remote_ip}")
+
+ # Log missing to parameter and try to find the correct conference ID
+ if to.blank?
+ # Log the issue clearly
+ Rails.logger.error("🚨 Missing 'To' parameter in request! Trying to find the correct conference ID")
+
+ # Get account ID from params
+ account_id = params[:account_id]
+
+ if account_id.present?
+ # Find the latest active call for this account
+ Rails.logger.info("🔍 Looking for latest active call for account #{account_id}")
+
+ begin
+ # Find the most recent conversation with an active call
+ conversation = Conversation.joins(:inbox)
+ .where(account_id: account_id)
+ .where("additional_attributes->>'call_status' IN ('ringing', 'in-progress')")
+ .where("additional_attributes ? 'conference_sid'")
+ .order(created_at: :desc)
+ .first
+
+ if conversation
+ # Use the exact conference ID from the conversation
+ to = conversation.additional_attributes['conference_sid']
+
+ if to && to.start_with?('conf_account_') && to.include?('_conv_')
+ Rails.logger.info("✅ Found active call with conference ID: #{to}")
+ else
+ Rails.logger.error("❌ Found conversation but conference ID format is invalid: #{to}")
+ end
+ else
+ Rails.logger.error("❌ No active calls found for account #{account_id}")
+ end
+ rescue => e
+ Rails.logger.error("❌ Error finding active call: #{e.message}")
+ end
+ end
+
+ # If we still don't have a valid To parameter, use a well-known format that will fail predictably
+ if to.blank?
+ to = "MISSING_TO_PARAMETER"
+ Rails.logger.error("❌ Could not find a valid conference ID - call will fail")
+ end
+ end
+
+ # CRITICAL: Do NOT modify the conference name - simply ensure it's a string
+ # This was the source of the issue - we were adding an extra prefix even when it already had one
+ to = to.to_s
+
+ # Log the final conference name
+ Rails.logger.info("FINAL CONFERENCE NAME: #{to}")
+
+ # IMPROVED Account handling - more resilient with better logging
+ account_id = params[:account_id].presence
+ Rails.logger.info("ACCOUNT_ID FROM PARAMS: #{account_id.inspect}")
+
+ # Safer account ID validation
+ begin
+ account = nil
+ if account_id.present?
+ # Try to parse as integer for safer lookup
+ safe_account_id = account_id.to_i
+ account = Account.find_by(id: safe_account_id)
+
+ if account
+ Rails.logger.info("✅ Found account with ID: #{safe_account_id}")
+ else
+ Rails.logger.warn("⚠️ No account found with ID: #{safe_account_id}")
+ end
+ end
+
+ # Fallback chain - try multiple ways to find an account
+ if account.nil?
+ Rails.logger.info("Looking for first account as fallback")
+ account = Account.first
+
+ if account
+ Rails.logger.info("✅ Found fallback account with ID: #{account.id}")
+ else
+ Rails.logger.error("❌ No accounts exist in the system!")
+ end
+ end
+
+ # Set current account if found
+ if account
+ Current.account = account
+ Rails.logger.info("✅ Current.account set to ID: #{account.id}")
+ end
+ rescue => account_error
+ Rails.logger.error("❌ Error setting Current.account: #{account_error.message}")
+ Rails.logger.error(account_error.backtrace.first(3).join("\n"))
+ end
+
+ # Make the TwiML response generation as simple as possible
+ response = Twilio::TwiML::VoiceResponse.new do |r|
+ # Log everything about the request
+ # Generate TwiML for agent to join conference
+
+ # SIMPLEST POSSIBLE APPROACH - direct conference connection without any extra audio
+ r.dial do |dial|
+ # Using this conference name in TwiML
+
+ # Get a safe callback URL
+ base_callback_url = begin
+ if Current.account&.id
+ "#{base_url.gsub(/\/$/, '')}/api/v1/accounts/#{Current.account.id}/channels/voice/webhooks/conference_status"
+ else
+ "#{base_url.gsub(/\/$/, '')}/api/v1/accounts/1/channels/voice/webhooks/conference_status"
+ end
+ end
+
+ # Agent ID for participant label
+ agent_id = current_user.present? ? current_user.id.to_s : 'unknown-user'
+
+ # Log connection parameters to help debug outbound call issues
+ is_agent = params['is_agent'] == 'true'
+ Rails.logger.info("🔥🔥🔥 AGENT CONNECTING TO CONFERENCE: #{to}, agent_id=#{agent_id}, is_agent=#{is_agent}")
+
+ # CRITICAL: Look for outbound call indicators in URL parameters
+ if params['is_outbound'] == 'true' || is_agent
+ Rails.logger.info("🚨🚨🚨 DETECTED OUTBOUND CALL OR AGENT CONNECTING")
+ end
+
+ # Absolute minimal conference parameters for agent joining
+ dial.conference(
+ to,
+ startConferenceOnEnter: true, # Agent joining starts the conference
+ endConferenceOnExit: true, # End when agent leaves
+ muted: false, # Agent can speak
+ beep: false, # No beep sounds
+ waitUrl: '', # No hold music
+ earlyMedia: true, # Enable early media for faster connection
+ statusCallback: base_callback_url,
+ statusCallbackEvent: 'start end join leave',
+ statusCallbackMethod: 'POST',
+ participantLabel: "agent-#{agent_id}"
+ )
+ end
+ end
+
+ # Extra logging to help diagnose issues
+ Rails.logger.info("🎧 TwiML conference parameters for agent: startConferenceOnEnter=true, endConferenceOnExit=true, conference_name=#{to}")
+ Rails.logger.info("🔊 Generated TwiML length: #{response.to_s.length} bytes")
+ # Add more detailed debugging about what we're actually doing
+ Rails.logger.info("🔍 DEBUG: Agent joining as PARTICIPANT to conference '#{to}' with account_id=#{account_id}")
+
+ # Set CORS headers to properly respond to Twilio
+ set_cors_headers
+
+ # Render with proper MIME type
+ render xml: response.to_s, content_type: 'text/xml'
+ rescue => e
+ # Enhanced error logging
+ Rails.logger.error("💥 ERROR IN TWIML GENERATION: #{e.class.name}: #{e.message}")
+ Rails.logger.error("💥 EXCEPTION BACKTRACE: #{e.backtrace.first(10).join("\n")}")
+ Rails.logger.error("💥 PARAMS AT TIME OF ERROR: #{params.inspect}")
+
+ # Generate a super-simple error response that explains the issue
+ error_response = Twilio::TwiML::VoiceResponse.new
+ error_response.say(message: "We apologize, but there was a technical issue connecting your call.")
+ error_response.pause(length: 1)
+ error_response.say(message: "The specific error was: #{e.message[0..100]}")
+ error_response.pause(length: 1)
+ error_response.say(message: "The call will now disconnect. Please try again.")
+ error_response.hangup
+
+ # Set CORS headers
+ set_cors_headers
+
+ render xml: error_response.to_s, content_type: 'text/xml'
+ end
+ end
+
+ # Helper method to render TwiML error response with minimal parameters
+ def render_twiml_error(message)
+ begin
+ response = Twilio::TwiML::VoiceResponse.new do |r|
+ r.say(message: "Error: #{message}")
+ r.hangup
+ end
+
+ render xml: response.to_s, content_type: 'text/xml'
+ rescue => e
+ # Last resort error handling
+ Rails.logger.error("💥 ERROR IN ERROR HANDLER: #{e.message}")
+ render plain: "
([\s\S]*?)<\/pre>/);
+
+ const errorName = nameMatchResult ? nameMatchResult[1] : null;
+ const errorDetails = detailsMatchResult ? detailsMatchResult[1] : null;
+
+ if (errorName || errorDetails) {
+ const enhancedError = new Error(`Server error: ${errorName || 'Internal Server Error'}`);
+ enhancedError.details = errorDetails;
+ enhancedError.originalError = error;
+ throw enhancedError;
+ }
+ }
+
+ throw error;
+ });
+ }
+
+ // Initialize the Twilio Device
+ async initializeDevice(inboxId) {
+ // If already initialized, return the existing device after checking its health
+ if (this.initialized && this.device) {
+ const deviceState = this.device.state;
+ console.log('Device already initialized, current state:', deviceState);
+
+ // If the device is in a bad state, destroy and reinitialize
+ if (deviceState === 'error' || deviceState === 'unregistered') {
+ console.log('Device is in a bad state, destroying and reinitializing...');
+ try {
+ this.device.destroy();
+ } catch (e) {
+ console.log('Error destroying device:', e);
+ }
+ this.device = null;
+ this.initialized = false;
+ } else {
+ // Device is in a good state, return it
+ return this.device;
+ }
+ }
+
+ // Device needs to be initialized or reinitialized
+ try {
+ console.log(`Starting Twilio Device initialization for inbox: ${inboxId}`);
+
+ // Import the Twilio Voice SDK
+ let Device;
+ try {
+ // We know the package is installed via package.json
+ const { Device: TwilioDevice } = await import('@twilio/voice-sdk');
+ Device = TwilioDevice;
+ console.log('✓ Twilio Voice SDK imported successfully');
+ } catch (importError) {
+ console.error('✗ Failed to import Twilio Voice SDK:', importError);
+ throw new Error(`Failed to load Twilio Voice SDK: ${importError.message}`);
+ }
+
+ // Validate inbox ID
+ if (!inboxId) {
+ throw new Error('Inbox ID is required to initialize the Twilio Device');
+ }
+
+ // Step 1: Get a token from the server
+ console.log(`Requesting Twilio token for inbox: ${inboxId}`);
+ let response;
+ try {
+ response = await this.getToken(inboxId);
+ console.log(`✓ Token response received with status: ${response.status}`);
+ } catch (tokenError) {
+ console.error('✗ Token request failed:', tokenError);
+
+ // Enhanced error handling for token requests
+ if (tokenError.details) {
+ // If we already have extracted details from the error, include those
+ console.error('Token error details:', tokenError.details);
+ throw new Error(`Failed to get token: ${tokenError.message}`);
+ }
+
+ // Check for specific HTTP error status codes
+ if (tokenError.response) {
+ const status = tokenError.response.status;
+ const data = tokenError.response.data;
+
+ if (status === 401) {
+ throw new Error('Authentication error: Please check your Twilio credentials');
+ } else if (status === 403) {
+ throw new Error('Permission denied: You don\'t have access to this inbox');
+ } else if (status === 404) {
+ throw new Error('Inbox not found or does not have voice capability');
+ } else if (status === 500) {
+ throw new Error('Server error: The server encountered an error processing your request. Check your Twilio configuration.');
+ } else if (data && data.error) {
+ throw new Error(`Server error: ${data.error}`);
+ }
+ }
+
+ throw new Error(`Failed to get token: ${tokenError.message}`);
+ }
+
+ // Validate token response
+ if (!response.data || !response.data.token) {
+ console.error('✗ Invalid token response data:', response.data);
+
+ // Check if we have an error message in the response
+ if (response.data && response.data.error) {
+ throw new Error(`Server did not return a valid token: ${response.data.error}`);
+ } else {
+ throw new Error('Server did not return a valid token');
+ }
+ }
+
+ // Check for warnings about missing TwiML App SID
+ if (response.data.warning) {
+ console.warn('⚠️ Twilio Voice Warning:', response.data.warning);
+
+ if (!response.data.has_twiml_app) {
+ console.error(
+ '🚨 IMPORTANT: Missing TwiML App SID. Browser-based calling requires a ' +
+ 'TwiML App configured in Twilio Console. Set the Voice Request URL to: ' +
+ response.data.twiml_endpoint
+ );
+ }
+ }
+
+ // Extract token data
+ const { token, identity, voice_enabled, account_sid } = response.data;
+
+ // Log diagnostic information
+ console.log(`✓ Token data received for identity: ${identity}`);
+ console.log(`✓ Voice enabled: ${voice_enabled}`);
+ console.log(`✓ Twilio Account SID available: ${!!account_sid}`);
+
+ // Log the TwiML endpoint that will be used
+ if (response.data.twiml_endpoint) {
+ console.log(`✓ TwiML endpoint: ${response.data.twiml_endpoint}`);
+ } else {
+ console.warn('⚠️ No TwiML endpoint found in token response');
+ }
+
+ // Check if voice is enabled
+ if (!voice_enabled) {
+ throw new Error('Voice is not enabled for this inbox. Check your Twilio configuration.');
+ }
+
+ // Step 2: Create Twilio Device
+ const deviceOptions = {
+ // Use absolute minimal options - less is more for audio compatibility
+ allowIncomingWhileBusy: true, // Allow incoming calls while already on a call
+ debug: true, // Enable debug logging
+ warnings: true, // Show warnings in console
+ // The prebuilt hold music usually interrupts the actual call
+ disableAudioContextSounds: true, // Disable browser audio context for sounds
+ };
+
+ console.log('Creating Twilio Device with options:', deviceOptions);
+
+ try {
+ this.device = new Device(token, deviceOptions);
+ console.log('✓ Twilio Device created successfully');
+ } catch (deviceError) {
+ console.error('✗ Failed to create Twilio Device:', deviceError);
+ throw new Error(`Failed to create Twilio Device: ${deviceError.message}`);
+ }
+
+ // Step 3: Set up event listeners with enhanced error handling
+ this._setupDeviceEventListeners(inboxId);
+
+ // Step 4: Register the device with Twilio
+ console.log('Registering Twilio Device...');
+ try {
+ await this.device.register();
+ console.log('✓ Twilio Device registered successfully');
+ this.initialized = true;
+ return this.device;
+ } catch (registerError) {
+ console.error('✗ Failed to register Twilio Device:', registerError);
+
+ // Handle specific registration errors
+ if (registerError.message && registerError.message.includes('token')) {
+ throw new Error('Invalid Twilio token. Check your account credentials.');
+ } else if (registerError.message && registerError.message.includes('permission')) {
+ throw new Error('Missing microphone permission. Please allow microphone access.');
+ }
+
+ throw new Error(`Failed to register device: ${registerError.message}`);
+ }
+ } catch (error) {
+ // Clear device and initialized flag in case of error
+ this.device = null;
+ this.initialized = false;
+
+ console.error('Failed to initialize Twilio Device:', error);
+
+ // Create a detailed error with context for debugging
+ const enhancedError = new Error(`Twilio Device initialization failed: ${error.message}`);
+ enhancedError.originalError = error;
+ enhancedError.inboxId = inboxId;
+ enhancedError.timestamp = new Date().toISOString();
+ enhancedError.browserInfo = {
+ userAgent: navigator.userAgent,
+ hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
+ };
+
+ // Add specific advice for known error cases
+ if (error.message.includes('permission')) {
+ enhancedError.advice = 'Please ensure your browser allows microphone access.';
+ } else if (error.message.includes('token')) {
+ enhancedError.advice = 'Check your Twilio credentials in the Voice channel settings.';
+ } else if (error.message.includes('TwiML')) {
+ enhancedError.advice = 'Set up a valid TwiML app in your Twilio console and configure it in the inbox settings.';
+ } else if (error.message.includes('configuration')) {
+ enhancedError.advice = 'Review your Voice inbox configuration to ensure all required fields are completed.';
+ }
+
+ throw enhancedError;
+ }
+ }
+
+ // Helper method to set up device event listeners
+ _setupDeviceEventListeners(inboxId) {
+ if (!this.device) return;
+
+ // Remove any existing listeners to prevent duplicates
+ this.device.removeAllListeners();
+
+ // Add standard event listeners
+ this.device.on('registered', () => {
+ console.log('✓ Twilio Device registered with Twilio servers');
+ });
+
+ this.device.on('unregistered', () => {
+ console.log('⚠️ Twilio Device unregistered from Twilio servers');
+ });
+
+ this.device.on('tokenWillExpire', () => {
+ console.log('⚠️ Twilio token is about to expire, refreshing...');
+ this.getToken(inboxId)
+ .then(newTokenResponse => {
+ if (newTokenResponse.data && newTokenResponse.data.token) {
+ console.log('✓ Successfully obtained new token');
+ this.device.updateToken(newTokenResponse.data.token);
+ } else {
+ console.error('✗ Failed to get a valid token for renewal');
+ }
+ })
+ .catch(tokenError => {
+ console.error('✗ Error refreshing token:', tokenError);
+ });
+ });
+
+ this.device.on('incoming', connection => {
+ console.log('📞 Incoming call received via Twilio Device');
+ this.activeConnection = connection;
+
+ // Set up connection-specific events
+ this._setupConnectionEventListeners(connection);
+ });
+
+ this.device.on('error', error => {
+ // Enhanced error logging with full details
+ const errorDetails = {
+ code: error.code,
+ message: error.message,
+ description: error.description || 'No description',
+ twilioErrorObject: error,
+ connectionInfo: this.activeConnection ? {
+ parameters: this.activeConnection.parameters,
+ status: this.activeConnection.status && this.activeConnection.status(),
+ direction: this.activeConnection.direction,
+ } : 'No active connection',
+ deviceState: this.device.state,
+ browserInfo: {
+ userAgent: navigator.userAgent,
+ platform: navigator.platform
+ },
+ timestamp: new Date().toISOString()
+ };
+
+ console.error('❌ DETAILED Twilio Device Error:', errorDetails);
+
+ // Provide helpful troubleshooting tips based on error code
+ switch (error.code) {
+ case 31000:
+ console.error('⚠️ Error 31000: General Error. This could be an authentication, configuration, or network issue.');
+ console.error('31000 Error Details:', {
+ sdp: error.sdp || 'No SDP data',
+ callState: error.call ? error.call.state : 'No call state',
+ connectionState: error.connection ? error.connection.state : 'No connection state',
+ peerConnectionState: error.peerConnection ? error.peerConnection.iceConnectionState : 'No ICE state',
+ message: error.message,
+ twilioError: error,
+ info: error.info || 'No additional info',
+ solution: 'Check Twilio account status, SDP negotiations, and network connectivity'
+ });
+
+ // Create a network diagnostic to check connectivity
+ fetch('https://status.twilio.com/api/v2/status.json')
+ .then(response => response.json())
+ .then(data => {
+ console.log('Twilio service status check:', data);
+ })
+ .catch(statusError => {
+ console.error('Failed to check Twilio status:', statusError);
+ });
+ break;
+ case 31002:
+ console.error('⚠️ Error 31002: Permission Denied. Your browser microphone is blocked or unavailable.');
+ break;
+ case 31003:
+ console.error('⚠️ Error 31003: TwiML App Error. Your TwiML application does not exist or is misconfigured.');
+ break;
+ case 31005:
+ console.error('⚠️ Error 31005: Error sent from gateway in HANGUP. This usually means the TwiML endpoint is not reachable or returning invalid TwiML.');
+ console.error('Additional details for 31005:', {
+ activeConnection: this.activeConnection ? 'Yes' : 'No',
+ deviceState: this.device ? this.device.state : 'No device',
+ params: this.activeConnection ? this.activeConnection.parameters : 'No params',
+ twimlEndpoint: this.activeConnection && this.activeConnection.parameters ?
+ this.activeConnection.parameters.To : 'Unknown endpoint',
+ hangupReason: error.hangupReason || 'Unknown', // Capture hangup reason
+ message: error.message,
+ description: error.description,
+ customMessage: error.customMessage,
+ originalError: error.originalError ? JSON.stringify(error.originalError) : 'None'
+ });
+
+ // Make a test HTTP request to the TwiML endpoint to check if it's accessible
+ fetch('/api/v1/accounts/' + (this.activeConnection?.parameters?.account_id || 'current') + '/voice/twiml_for_client')
+ .then(response => {
+ console.log('TwiML endpoint accessibility test result:', {
+ status: response.status,
+ ok: response.ok,
+ statusText: response.statusText
+ });
+ })
+ .catch(fetchError => {
+ console.error('Failed to reach TwiML endpoint:', fetchError);
+ });
+ break;
+ case 31008:
+ console.error('⚠️ Error 31008: Connection Error. The call could not be established.');
+ break;
+ case 31204:
+ console.error('⚠️ Error 31204: ICE Connection Failed. WebRTC connection failure, check firewall settings.');
+ break;
+ default:
+ console.error(`⚠️ Unspecified error with code ${error.code}: ${error.message}`);
+ }
+ });
+
+ this.device.on('connect', connection => {
+ console.log('📞 Call connected');
+ this.activeConnection = connection;
+ this._setupConnectionEventListeners(connection);
+ });
+
+ this.device.on('disconnect', () => {
+ console.log('📞 Call disconnected');
+ this.activeConnection = null;
+ });
+ }
+
+ // Set up event listeners for the active connection with enhanced audio diagnostic logging
+ _setupConnectionEventListeners(connection) {
+ if (!connection) return;
+
+ // Add advanced audio debug data
+ const getAudioDiagnostics = () => {
+ const audioContext = window.AudioContext || window.webkitAudioContext;
+ let audioInfo = { supported: !!audioContext };
+
+ try {
+ if (audioContext) {
+ const context = new audioContext();
+ audioInfo = {
+ ...audioInfo,
+ sampleRate: context.sampleRate,
+ state: context.state,
+ baseLatency: context.baseLatency,
+ outputLatency: context.outputLatency,
+ destination: {
+ maxChannelCount: context.destination.maxChannelCount,
+ numberOfInputs: context.destination.numberOfInputs,
+ numberOfOutputs: context.destination.numberOfOutputs
+ }
+ };
+ context.close();
+ }
+ } catch (e) {
+ audioInfo.error = e.message;
+ }
+
+ // Check if microphone is accessible
+ let microphoneInfo = { detected: false, active: false, tracks: [] };
+ if (window.activeAudioStream) {
+ const tracks = window.activeAudioStream.getAudioTracks();
+ microphoneInfo = {
+ detected: true,
+ active: tracks.some(track => track.enabled && track.readyState === 'live'),
+ tracks: tracks.map(track => ({
+ id: track.id,
+ label: track.label,
+ enabled: track.enabled,
+ muted: track.muted,
+ readyState: track.readyState,
+ constraints: track.getConstraints()
+ }))
+ };
+ }
+
+ return {
+ audioContext: audioInfo,
+ microphone: microphoneInfo,
+ speakersMuted: typeof window.speechSynthesis !== 'undefined' ?
+ window.speechSynthesis.speaking === false : 'unknown'
+ };
+ };
+
+ connection.on('error', error => {
+ // Significantly enhanced connection error logging with audio diagnostics
+ const diagnostics = getAudioDiagnostics();
+
+ const connectionErrorDetails = {
+ code: error.code,
+ message: error.message,
+ description: error.description || 'No description',
+ twilioErrorObject: error,
+ connectionInfo: {
+ parameters: connection.parameters,
+ status: connection.status && connection.status(),
+ direction: connection.direction,
+ },
+ deviceState: this.device ? this.device.state : 'No device',
+ timestamp: new Date().toISOString(),
+ // Audio diagnostics for troubleshooting
+ audioDiagnostics: diagnostics,
+ // Browser media permissions
+ mediaPermissions: {
+ hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
+ activeAudioStream: !!window.activeAudioStream,
+ activeAudioTracks: window.activeAudioStream ?
+ window.activeAudioStream.getAudioTracks().length : 0
+ }
+ };
+
+ console.error('❌ DETAILED Connection Error with Audio Diagnostics:', connectionErrorDetails);
+ });
+
+ connection.on('mute', isMuted => {
+ console.log(`📞 Call ${isMuted ? 'muted' : 'unmuted'}`);
+ });
+
+ connection.on('accept', () => {
+ // Enhanced logging for accept event with audio diagnostics
+ const diagnostics = getAudioDiagnostics();
+
+ console.log('📞 Call accepted with audio diagnostics:', {
+ connectionParameters: connection.parameters,
+ status: connection.status && connection.status(),
+ audioDiagnostics: diagnostics,
+ activeAudioStream: window.activeAudioStream ? {
+ active: window.activeAudioStream.active,
+ id: window.activeAudioStream.id,
+ trackCount: window.activeAudioStream.getTracks().length
+ } : 'No active stream'
+ });
+
+ // AUDIO HEALTH CHECK AFTER CONNECTION
+ setTimeout(() => {
+ console.log('🔊 AUDIO HEALTH CHECK:', {
+ connectionActive: this.activeConnection === connection,
+ connectionState: connection.status && connection.status(),
+ audioTracks: window.activeAudioStream ?
+ window.activeAudioStream.getAudioTracks().map(track => ({
+ label: track.label,
+ enabled: track.enabled,
+ readyState: track.readyState,
+ muted: track.muted
+ })) : 'No active stream',
+ // Device state after 5 seconds
+ deviceState: this.device ? this.device.state : 'No device'
+ });
+ }, 5000);
+ });
+
+ connection.on('disconnect', () => {
+ console.log('📞 Call disconnected', {
+ disconnectCause: connection.parameters ? connection.parameters.DisconnectCause : 'Unknown',
+ finalStatus: connection.status && connection.status(),
+ audioDiagnostics: getAudioDiagnostics()
+ });
+ this.activeConnection = null;
+ });
+
+ connection.on('reject', () => {
+ console.log('📞 Call rejected', {
+ rejectCause: connection.parameters ? connection.parameters.DisconnectCause : 'Unknown',
+ audioDiagnostics: getAudioDiagnostics()
+ });
+ this.activeConnection = null;
+ });
+
+ // Additional event for warning messages
+ connection.on('warning', warning => {
+ console.warn('⚠️ Connection Warning:', warning);
+ });
+
+ // Listen for TwiML processing events
+ connection.on('twiml-processing', twiml => {
+ console.log('📄 Processing TwiML:', twiml);
+ });
+
+ // Enhanced audio events for debugging
+ if (typeof connection.on === 'function') {
+ try {
+ // Check for volume events
+ connection.on('volume', (inputVolume, outputVolume) => {
+ // Log only significant volume changes to avoid console spam
+ if (Math.abs(inputVolume) > 50 || Math.abs(outputVolume) > 50) {
+ console.log(`🔊 Volume change - Input: ${inputVolume}, Output: ${outputVolume}`);
+ }
+ });
+
+ // Check for media stream events if supported
+ if (typeof connection.getRemoteStream === 'function') {
+ const remoteStream = connection.getRemoteStream();
+ if (remoteStream) {
+ console.log('✅ Remote audio stream available:', {
+ active: remoteStream.active,
+ id: remoteStream.id,
+ tracks: remoteStream.getTracks().map(t => ({
+ kind: t.kind,
+ enabled: t.enabled,
+ readyState: t.readyState
+ }))
+ });
+ } else {
+ console.warn('⚠️ No remote audio stream available');
+ }
+ }
+ } catch (e) {
+ console.warn('Error setting up enhanced audio events:', e);
+ }
+ }
+ }
+
+ // Make a call using the Twilio Client
+ makeClientCall(params) {
+ if (!this.device || !this.initialized) {
+ throw new Error('Twilio Device not initialized');
+ }
+
+ this.activeConnection = this.device.connect(params);
+ return this.activeConnection;
+ }
+
+ // Join a conference call using the Twilio Client
+ joinClientCall(conferenceParams) {
+ if (!this.device || !this.initialized) {
+ throw new Error('Twilio Device not initialized');
+ }
+
+ // Log the exact conference ID received
+ console.log('Connecting to conference with params:', conferenceParams);
+ console.log('⭐ CONFERENCE ID VALUE:', conferenceParams.To);
+ console.log('⭐ CONFERENCE ID FORMAT CHECK:',
+ conferenceParams.To &&
+ conferenceParams.To.startsWith('conf_account_') &&
+ conferenceParams.To.includes('_conv_') ?
+ 'CORRECT ✅' : 'INCORRECT ❌');
+
+ try {
+ // IMPORTANT: Do NOT try to register if already registered
+ // Only check state is ready
+ if (this.device.state !== 'ready' && this.device.state !== 'registered') {
+ console.warn('Twilio device not in ready state:', this.device.state);
+ // Don't try to register again if already registered
+ }
+
+ // SUPER MINIMAL PARAMETER APPROACH - explicitly construct the parameters
+ // using exactly the format expected by Twilio
+ const params = {};
+
+ // The 'To' parameter MUST be capitalized for Twilio and is required
+ params.To = conferenceParams.To;
+
+ // The account_id is needed for server-side routing
+ params.account_id = conferenceParams.account_id;
+
+ // ENSURE the conference ID is exactly in the format we expect
+ // conf_account_{account_id}_conv_{conversationId}
+ if (!params.To || !params.To.startsWith('conf_account_') || !params.To.includes('_conv_')) {
+ console.error(`CRITICAL ERROR: Conference ID format is incorrect: '${params.To}'`);
+ console.error('Expected format: conf_account_{account_id}_conv_{conversationId}');
+ throw new Error('Invalid conference ID format. Expected conf_account_{account_id}_conv_{conversationId}');
+ }
+
+ // MOST CRITICAL DEBUG OUTPUT - this is exactly what we're sending to Twilio
+ console.log(`⭐⭐⭐ CONNECTING TO CONFERENCE: Conference name='${params.To}', account_id=${params.account_id}`);
+
+ // IMPORTANT: Do NOT modify the conference name - use exactly what was passed
+ // This ensures we use the exact same conference name as created on the server side
+
+ // Connect to the conference - different Twilio SDK versions return different types
+ try {
+ // SIMPLIFIED APPROACH - Just use standard params with capitalized 'To'
+ // No extra URL parameters or fancy options
+ console.log(`⭐⭐⭐ Connecting to conference '${params.To}' with params:`, params);
+
+ const connection = this.device.connect(params);
+
+ // Save the connection to our instance
+ this.activeConnection = connection;
+
+ // Check what kind of connection object we have (Promise vs older non-Promise style)
+ if (connection && typeof connection.then === 'function') {
+ // It's a Promise - newer Twilio SDK version
+ console.log('Using Promise-based Twilio connection - handling async');
+
+ // Return the connection object but also set up Promise handling
+ connection.then(resolvedConnection => {
+ console.log('WebRTC Promise connection resolved successfully');
+ this.activeConnection = resolvedConnection;
+
+ // Try to add listeners if this version supports it
+ try {
+ if (typeof resolvedConnection.on === 'function') {
+ resolvedConnection.on('accept', () => {
+ console.log('✅ Conference connection accepted via Promise');
+ });
+ }
+ } catch (listenerError) {
+ console.warn('Could not add listeners to Promise connection:', listenerError);
+ }
+ }).catch(connError => {
+ console.error('WebRTC Promise connection error:', connError);
+ });
+ } else {
+ // It's a synchronous connection - older Twilio SDK
+ console.log('Successfully initiated synchronous connection to conference');
+ }
+
+ return connection;
+ } catch (connectError) {
+ console.error('Error during device.connect():', connectError);
+ throw connectError;
+ }
+ } catch (error) {
+ console.error('Error connecting to conference:', error);
+ throw error;
+ }
+ }
+
+ // End a client call
+ endClientCall() {
+ console.log('Attempting to end WebRTC call');
+
+ // Check if we have an active connection
+ if (this.activeConnection) {
+ try {
+ // Try to disconnect - handle both Promise and non-Promise interfaces
+ if (typeof this.activeConnection.disconnect === 'function') {
+ console.log('Using Connection.disconnect() method');
+ this.activeConnection.disconnect();
+ } else {
+ // In modern Twilio SDK, might need to use the device
+ console.log('Connection.disconnect not available, using Device');
+ if (this.device && typeof this.device.disconnectAll === 'function') {
+ this.device.disconnectAll();
+ }
+ }
+
+ this.activeConnection = null;
+ return true;
+ } catch (error) {
+ console.error('Error disconnecting WebRTC call:', error);
+ // Reset connection anyway
+ this.activeConnection = null;
+ return false;
+ }
+ } else if (this.device) {
+ // Try disconnecting all calls from the device even if no active connection
+ try {
+ if (typeof this.device.disconnectAll === 'function') {
+ this.device.disconnectAll();
+ return true;
+ }
+ } catch (error) {
+ console.error('Error disconnecting device calls:', error);
+ }
+ }
+
+ return false;
+ }
+
+ // Mute/unmute a client call
+ setMute(isMuted) {
+ console.log(`Attempting to ${isMuted ? 'mute' : 'unmute'} WebRTC call`);
+
+ if (this.activeConnection) {
+ try {
+ // Check if the mute function exists
+ if (typeof this.activeConnection.mute === 'function') {
+ this.activeConnection.mute(isMuted);
+ console.log(`Call ${isMuted ? 'muted' : 'unmuted'} successfully`);
+ return true;
+ } else {
+ console.warn('Connection.mute method not available');
+ return false;
+ }
+ } catch (error) {
+ console.error('Error muting/unmuting WebRTC call:', error);
+ return false;
+ }
+ }
+
+ console.warn('No active connection to mute/unmute');
+ return false;
+ }
+
+ // Get the status of the device with additional diagnostic info
+ getDeviceStatus() {
+ if (!this.device) {
+ return 'not_initialized';
+ }
+
+ const deviceState = this.device.state;
+
+ // Append a recommended action based on the state
+ switch (deviceState) {
+ case 'registered':
+ return 'ready';
+ case 'unregistered':
+ return 'disconnected';
+ case 'destroyed':
+ return 'terminated';
+ case 'busy':
+ return 'busy';
+ case 'error':
+ return 'error';
+ default:
+ return deviceState;
+ }
+ }
+
+ // Get comprehensive diagnostic information about the device and connection
+ getDiagnosticInfo() {
+ const browserInfo = {
+ userAgent: navigator.userAgent,
+ platform: navigator.platform,
+ vendor: navigator.vendor,
+ hasMediaDevices: !!navigator.mediaDevices,
+ hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
+ };
+
+ const deviceInfo = this.device ? {
+ state: this.device.state,
+ isInitialized: this.initialized,
+ capabilities: this.device.capabilities || {},
+ isBusy: this.device.isBusy || false,
+ audio: {
+ isAudioSelectionSupported: this.device.isAudioSelectionSupported || false
+ }
+ } : { state: 'not_initialized' };
+
+ const connectionInfo = this.activeConnection ? {
+ status: this.activeConnection.status(),
+ isMuted: this.activeConnection.isMuted(),
+ direction: this.activeConnection.direction,
+ parameters: this.activeConnection.parameters,
+ } : { status: 'no_connection' };
+
+ return {
+ timestamp: new Date().toISOString(),
+ browser: browserInfo,
+ device: deviceInfo,
+ connection: connectionInfo
+ };
+ }
+
+ // Get the status of the active connection
+ getConnectionStatus() {
+ if (!this.activeConnection) {
+ return 'no_connection';
+ }
+
+ const status = this.activeConnection.status();
+
+ // Translate connection statuses to more user-friendly terms
+ switch (status) {
+ case 'pending':
+ return 'connecting';
+ case 'open':
+ return 'connected';
+ case 'connecting':
+ return 'connecting';
+ case 'ringing':
+ return 'ringing';
+ case 'closed':
+ return 'ended';
+ default:
+ return status;
+ }
+ }
}
export default new VoiceAPI();
diff --git a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue
index b42d4c2e0..3c47e95c1 100644
--- a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue
+++ b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import VoiceAPI from 'dashboard/api/channels/voice';
import ContactAPI from 'dashboard/api/contacts';
+import DashboardAudioNotificationHelper from 'dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper';
export default {
name: 'FloatingCallWidget',
@@ -29,6 +30,16 @@ export default {
type: [Number, String],
default: null,
},
+ inboxId: {
+ type: [Number, String],
+ default: null,
+ },
+ // Always use WebRTC by default - the useWebRTC prop is kept for compatibility
+ // but hardcoded to true since agents will only ever use WebRTC now
+ useWebRTC: {
+ type: Boolean,
+ default: true, // Always true - no longer optional
+ },
},
emits: ['callEnded', 'callJoined', 'callRejected'],
setup(props, { emit }) {
@@ -42,6 +53,11 @@ export default {
const isFullscreen = ref(false);
const ringtoneAudio = ref(null);
const displayContactName = ref(props.contactName || 'Loading...');
+ const isWebRTCInitialized = ref(false);
+ const isWebRTCSupported = ref(true);
+ const twilioDeviceStatus = ref('not_initialized');
+ const microphonePermission = ref('not_requested'); // 'not_requested', 'granted', 'denied'
+ const currentVolume = ref(0.7); // 0-1 scale
// Define local fallback translations in case i18n fails
const translations = {
@@ -129,9 +145,39 @@ export default {
};
const stopRingtone = () => {
+ console.log('Attempting to stop ringtone');
if (ringtoneAudio.value) {
- ringtoneAudio.value.pause();
- ringtoneAudio.value.currentTime = 0;
+ try {
+ ringtoneAudio.value.pause();
+ ringtoneAudio.value.currentTime = 0;
+ console.log('Ringtone stopped successfully');
+
+ // Also use the DashboardAudioNotificationHelper to ensure all audio stops
+ if (window.DashboardAudioNotificationHelper ||
+ (typeof DashboardAudioNotificationHelper !== 'undefined')) {
+ try {
+ DashboardAudioNotificationHelper.stopAudio('call_ring');
+ console.log('🔇 Also stopped ringtone using DashboardAudioNotificationHelper');
+ } catch (dashboardError) {
+ console.warn('Could not stop audio via DashboardAudioNotificationHelper:', dashboardError);
+ }
+ }
+ } catch (error) {
+ console.error('Error stopping ringtone:', error);
+ }
+ } else {
+ console.log('No ringtone audio element to stop');
+
+ // Still try to stop any global audio
+ if (window.DashboardAudioNotificationHelper ||
+ (typeof DashboardAudioNotificationHelper !== 'undefined')) {
+ try {
+ DashboardAudioNotificationHelper.stopAudio('call_ring');
+ console.log('🔇 Stopped ringtone using DashboardAudioNotificationHelper only');
+ } catch (dashboardError) {
+ console.warn('Could not stop audio via DashboardAudioNotificationHelper:', dashboardError);
+ }
+ }
}
};
@@ -214,7 +260,7 @@ export default {
store.dispatch('calls/clearIncomingCall');
// User feedback
- useAlert({ message: 'Call ended', type: 'success' });
+ useAlert('Call ended');
};
// End active call
@@ -235,7 +281,7 @@ export default {
emit('callEnded');
// Show success message to user
- useAlert({ message: 'Call ended', type: 'success' });
+ useAlert('Call ended');
// Now try the API call (after UI is updated)
try {
@@ -280,32 +326,68 @@ export default {
const acceptCall = async () => {
console.log('Accepting incoming call with SID:', incomingCall.value?.callSid);
+ // Check if this is an outbound call by looking for isOutbound flag
+ const isOutboundCall = incomingCall.value && incomingCall.value.isOutbound === true;
+
+ // Make sure to stop the ringtone - force multiple attempts for reliability
stopRingtone();
+ // Try again after a short delay to ensure it stops
+ setTimeout(() => {
+ stopRingtone();
+ }, 100);
try {
// Call the API to join the call (conference) as an agent
if (incomingCall.value) {
const { callSid, conversationId } = incomingCall.value;
- // Show user feedback
- useAlert({ message: safeTranslate('CONVERSATION.CALL_ACCEPTED'), type: 'info' });
+ // Show user feedback immediately
+ useAlert('Joining call...');
- // Make API call to join the conference
- await VoiceAPI.joinCall(callSid, conversationId);
+ let joinSuccess = false;
- // Move incoming call to active call
- store.dispatch('calls/acceptIncomingCall');
+ // WebRTC is now the only option for agents
+ console.log('Attempting to join call with WebRTC (only option for agents)');
+ try {
+ const webRTCSuccess = await joinCallWithWebRTC();
+ if (webRTCSuccess) {
+ console.log('Successfully joined call with WebRTC');
+ joinSuccess = true;
+ } else {
+ console.log('WebRTC join failed - agent must use browser interface');
+ useAlert('Browser-based call connection failed. Please check your microphone permissions and try again.');
+ }
+ } catch (webrtcError) {
+ console.error('WebRTC join error - agent must use browser for calls:', webrtcError);
+ useAlert('Browser-based call connection failed. Please check your microphone permissions and try again.');
+ }
- // Start call duration timer
- startDurationTimer();
+ // If WebRTC failed, we cannot proceed - no phone fallback
+ if (!joinSuccess) {
+ console.log('WebRTC join failed - agents must use web interface only');
+ throw new Error('WebRTC connection failed - agents must use the web interface');
+ }
- // Emit event
- emit('callJoined');
+ if (joinSuccess) {
+ // Force stop the ringtone again to be certain
+ stopRingtone();
+
+ // Start call duration timer
+ startDurationTimer();
+
+ // Move incoming call to active call
+ store.dispatch('calls/acceptIncomingCall');
+
+ // Emit event
+ emit('callJoined');
+ } else {
+ throw new Error('Failed to join call via WebRTC or phone');
+ }
}
} catch (error) {
console.error('Error joining call:', error);
- useAlert({ message: safeTranslate('CONVERSATION.CALL_JOIN_ERROR'), type: 'error' });
- forceEndCall();
+ useAlert('Failed to join call. Please try again.');
+ // Don't force end call immediately, let the user try again
}
};
@@ -320,7 +402,7 @@ export default {
const { callSid, conversationId } = incomingCall.value;
// Show user feedback
- useAlert({ message: safeTranslate('CONVERSATION.CALL_REJECTED'), type: 'info' });
+ useAlert(safeTranslate('CONVERSATION.CALL_REJECTED'));
// Make API call to reject the call (optional, the caller will stay in the queue)
await VoiceAPI.rejectCall(callSid, conversationId);
@@ -349,16 +431,15 @@ export default {
};
const toggleMute = () => {
- // This would typically connect to Twilio's mute functionality
- // For now we'll just toggle the state
+ // If WebRTC is initialized, use that for mute/unmute
+ if (isWebRTCInitialized.value) {
+ toggleMuteWebRTC();
+ return;
+ }
+
+ // Otherwise just toggle the state for UI feedback
isMuted.value = !isMuted.value;
- useAlert({
- message: isMuted.value ? 'Call muted' : 'Call unmuted',
- type: 'info',
- });
-
- // In a real implementation, you'd call Twilio's API to mute the call
- // Example: window.twilioDevice.activeConnection().mute(isMuted.value);
+ useAlert(isMuted.value ? 'Call muted' : 'Call unmuted');
};
const toggleCallOptions = () => {
@@ -369,6 +450,812 @@ export default {
isFullscreen.value = !isFullscreen.value;
// Would typically adjust UI accordingly
};
+
+ // WebRTC and Twilio Voice SDK methods
+
+ // Get inbox ID from all possible sources
+ const getAvailableInboxId = () => {
+ // Try all possible sources of inbox ID in order of most reliable first
+ return props.inboxId ||
+ props.conversation?.inbox_id ||
+ callInfo.value?.inboxId ||
+ activeCall.value?.inboxId ||
+ incomingCall.value?.inboxId ||
+ store.getters['calls/getActiveCall']?.inboxId ||
+ store.getters['calls/getIncomingCall']?.inboxId ||
+ // Last resort - try to find an inbox ID from the current conversation in the store
+ (props.conversationId && store.getters['getConversation']?.(props.conversationId)?.inbox_id);
+ };
+
+ // Initialize the Twilio device
+ const initializeTwilioDevice = async () => {
+ // No need to initialize if already done
+ if (isWebRTCInitialized.value) return true;
+
+ try {
+ // Try to find an inbox ID from any available source
+ const inboxId = getAvailableInboxId();
+
+ // Debug available IDs
+ console.log('Available IDs:', {
+ propsInboxId: props.inboxId,
+ callInfoInboxId: callInfo.value?.inboxId,
+ activeCallInboxId: activeCall.value?.inboxId,
+ incomingCallInboxId: incomingCall.value?.inboxId,
+ storeActiveCallInboxId: store.getters['calls/getActiveCall']?.inboxId,
+ storeIncomingCallInboxId: store.getters['calls/getIncomingCall']?.inboxId,
+ conversationInboxId: props.conversation?.inbox_id,
+ resolvedInboxId: inboxId
+ });
+
+ if (!inboxId) {
+ const errorMsg = 'No inbox ID available to initialize Twilio Device. Please try again with a voice-enabled inbox.';
+ console.error(errorMsg);
+ useAlert(errorMsg);
+ return false;
+ }
+
+ // Check browser support for WebRTC
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ isWebRTCSupported.value = false;
+ const errorMsg = 'WebRTC is not supported in this browser. Try Chrome, Firefox, or Edge.';
+ console.error(errorMsg);
+ useAlert(errorMsg);
+ return false;
+ }
+
+ // Step 1: Request microphone permission with specific audio constraints
+ // These help establish a better audio connection
+ try {
+ console.log('Requesting microphone permission with optimized audio...');
+
+ // Enhanced audio constraints to resolve audio connection issues
+ const audioConstraints = {
+ audio: {
+ echoCancellation: { exact: true }, // Force echo cancellation
+ noiseSuppression: { exact: true }, // Force noise suppression
+ autoGainControl: { exact: true }, // Force auto gain control
+ channelCount: { ideal: 1 }, // Mono is more reliable than stereo
+ latency: { ideal: 0.01 }, // Lower latency for better real-time
+ sampleRate: { ideal: 48000 }, // Higher sample rate for voice clarity
+ sampleSize: { ideal: 16 }, // Standard bit depth
+ volume: { ideal: 1.0 } // Maximum volume
+ }
+ };
+
+ const stream = await navigator.mediaDevices.getUserMedia(audioConstraints);
+
+ // CRITICAL: Keep the stream active to maintain audio permissions
+ window.activeAudioStream = stream;
+
+ microphonePermission.value = 'granted';
+ console.log('✅ Microphone permission granted with optimized audio settings');
+
+ // Listen for track-ended events that might indicate audio issues
+ stream.getAudioTracks().forEach(track => {
+ console.log('Audio track active:', track.label, track.enabled);
+ track.onended = () => {
+ console.warn('⚠️ Audio track ended unexpectedly');
+ };
+ });
+ } catch (micError) {
+ // Fall back to default audio constraints if specific ones fail
+ try {
+ console.log('Falling back to default audio constraints...');
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ window.activeAudioStream = stream;
+ microphonePermission.value = 'granted';
+ console.log('✅ Microphone permission granted with default settings');
+ } catch (fallbackError) {
+ microphonePermission.value = 'denied';
+ const errorMsg = `Microphone access denied: ${fallbackError.message}. Please allow microphone access in your browser settings.`;
+ console.error(errorMsg, fallbackError);
+ useAlert(errorMsg);
+ return false;
+ }
+ }
+
+ // Step 2: Initialize Twilio Device
+ try {
+ console.log('Initializing Twilio Device with inbox ID:', inboxId);
+
+ // Enhanced error handling during initialization
+ try {
+ await VoiceAPI.initializeDevice(inboxId);
+ } catch (initError) {
+ // Check for specific error cases
+ if (initError.message && initError.message.includes('Server error')) {
+ // If we have more details from the server error, show them
+ if (initError.details) {
+ console.error('Server error details:', initError.details);
+
+ // Check for common error patterns
+ if (initError.details.includes('uninitialized constant Twilio::JWT::AccessToken')) {
+ throw new Error('Twilio gem not properly installed. Please check server configuration.');
+ } else if (initError.details.includes('undefined method') && initError.details.includes('for nil:NilClass')) {
+ throw new Error('Voice channel configuration is incomplete. Please check your inbox setup.');
+ } else if (initError.details.includes('Missing Twilio credentials')) {
+ throw new Error('Missing Twilio credentials. Please configure account SID and auth token in channel settings.');
+ }
+ }
+ }
+
+ // If no special case was handled, re-throw the original error
+ throw initError;
+ }
+
+ // Check if device was actually initialized
+ if (!VoiceAPI.device) {
+ throw new Error('Device initialization failed silently. Please check server logs.');
+ }
+
+ isWebRTCInitialized.value = true;
+ twilioDeviceStatus.value = VoiceAPI.getDeviceStatus();
+
+ // Success notification
+ console.log('Twilio Device initialized successfully with status:', twilioDeviceStatus.value);
+ useAlert('Voice call device initialized successfully');
+
+ return true;
+ } catch (deviceError) {
+ console.error('Twilio Device initialization error:', deviceError);
+
+ // Construct a helpful error message based on the error details
+ let errorMsg = 'Failed to initialize voice client';
+
+ // Check for specific error patterns
+ if (deviceError.message && deviceError.message.includes('token')) {
+ errorMsg = 'Failed to get Twilio token. Check your Twilio credentials in the Voice channel settings.';
+ } else if (deviceError.message && deviceError.message.includes('TwiML')) {
+ errorMsg = 'Twilio configuration issue: Missing or invalid TwiML App SID in Voice channel settings.';
+ } else if (deviceError.message && deviceError.message.includes('Voice channel configuration')) {
+ errorMsg = deviceError.message;
+ } else if (deviceError.message && deviceError.message.includes('Server error')) {
+ errorMsg = 'Server error while initializing voice client. Check your Twilio configuration.';
+ } else if (deviceError.message) {
+ errorMsg = `Voice client initialization failed: ${deviceError.message}`;
+ }
+
+ useAlert(errorMsg);
+ return false;
+ }
+ } catch (error) {
+ // Handle any uncaught errors
+ console.error('Unexpected error during Twilio setup:', error);
+ useAlert(`Unexpected error: ${error.message}. Please check console for details.`);
+ return false;
+ }
+ };
+
+ // Join a call using the Twilio Client - this is the only option for agents now
+ const joinCallWithWebRTC = async () => {
+ try {
+ // CRITICAL: Before anything else, set up media streams properly
+ // This is the most important step to ensure audio works
+ await prepareAudioDevice();
+
+ // 1. Make sure device is initialized first
+ if (!isWebRTCInitialized.value) {
+ console.log('Twilio device not initialized, attempting to initialize...');
+ const initSuccess = await initializeTwilioDevice();
+ if (!initSuccess) {
+ console.error('Failed to initialize Twilio device');
+ return false;
+ }
+ }
+
+ // RESET the device if it's in a weird state
+ if (VoiceAPI.getDeviceStatus() !== 'ready') {
+ console.log('Twilio device not in ready state, resetting...');
+ await VoiceAPI.endClientCall(); // End any existing calls
+ await VoiceAPI.initializeDevice(getAvailableInboxId()); // Reinitialize
+ }
+
+ console.log('Twilio device ready to make calls with state:', VoiceAPI.getDeviceStatus());
+
+ // 2. Get call details
+ if (!incomingCall.value) {
+ console.error('No incoming call data available');
+ return false;
+ }
+
+ const { callSid, conversationId, accountId: callAccountId } = incomingCall.value;
+ console.log('Joining conference with callSid:', callSid);
+
+ // First inform the server that agent is joining via WebRTC (using the new API function signature)
+ // Create a variable to store the server response
+ let serverResponse = null;
+
+ try {
+ // Ensure we have a valid account ID to include with the API call
+ let accountId = callAccountId ||
+ (window.Current && window.Current.account && window.Current.account.id) ||
+ (typeof Current !== 'undefined' && Current.account && Current.account.id);
+
+ // Try to get from URL if not available elsewhere
+ if (!accountId) {
+ const urlMatch = window.location.pathname.match(/\/accounts\/(\d+)/);
+ if (urlMatch && urlMatch[1]) {
+ accountId = urlMatch[1];
+ }
+ }
+
+ console.log('Found account ID for API call:', accountId);
+
+ // Save the server response to our variable that will be accessible in the next block
+ const response = await VoiceAPI.joinCall({
+ call_sid: callSid,
+ conversation_id: conversationId,
+ account_id: accountId
+ });
+
+ console.log('API joinCall response details:', {
+ hasData: !!response.data,
+ statusCode: response.status,
+ accountIdSent: accountId
+ });
+
+ serverResponse = response.data;
+ console.log('Server response for join_call:', serverResponse);
+ } catch (apiError) {
+ console.error('Error calling join_call API:', apiError);
+ // Continue anyway, as we might still be able to join the conference
+ }
+
+ // CRUCIAL: Try to fix audio issues proactively
+ await fixAudioBeforeCall();
+
+ // 3. Use absolutely minimal parameters for WebRTC connections
+ try {
+ // Use the server response to get the conference ID
+
+ // Get account ID from URL if not set yet
+ let accountId = callAccountId ||
+ (window.Current && window.Current.account && window.Current.account.id) ||
+ (typeof Current !== 'undefined' && Current.account && Current.account.id);
+
+ // Try to get from URL if still not available
+ if (!accountId) {
+ const urlMatch = window.location.pathname.match(/\/accounts\/(\d+)/);
+ if (urlMatch && urlMatch[1]) {
+ accountId = urlMatch[1];
+ }
+ }
+
+ // First check specifically for conference_sid in the incomingCall object
+ // This is the most direct source for outbound calls
+ let conferenceId = incomingCall.value?.conference_sid;
+
+ // Log if we found a conference ID directly on the incomingCall
+ if (conferenceId) {
+ // Found conference_sid directly from incoming call
+ }
+
+ // If not found in incomingCall, try the server response
+ if (!conferenceId) {
+ conferenceId = serverResponse?.conference_sid;
+ }
+
+ // First try to get conference ID from server response if still not found
+ if (!conferenceId && serverResponse) {
+ // For outbound calls, the conference info might be in a different format
+ // Try to find it from multiple possible locations
+ conferenceId = serverResponse.conference_sid ||
+ serverResponse.conferenceId ||
+ serverResponse.conference_name;
+
+ // Use alternative conference ID sources
+ }
+
+ // If still no conference ID, try to generate it from conversation details
+ if (!conferenceId && incomingCall.value) {
+ const accountId = incomingCall.value.accountId ||
+ (window.Current && window.Current.account && window.Current.account.id);
+ const convId = incomingCall.value.conversationId;
+
+ // Check if this is an outbound call
+ const isOutboundCall = incomingCall.value && incomingCall.value.isOutbound === true;
+
+ // First check if the conference_sid was passed directly in the incomingCall data
+ if (isOutboundCall && incomingCall.value.conference_sid) {
+ conferenceId = incomingCall.value.conference_sid;
+ }
+
+ // If we still don't have a conference ID, try to generate it
+ if (!conferenceId && accountId && convId) {
+ // Generate conference ID in the standard format
+ conferenceId = `conf_account_${accountId}_conv_${convId}`;
+ // Use standard conference ID format based on account and conversation IDs
+ }
+ }
+
+ if (!conferenceId) {
+ console.error('No conference_sid in server response:', serverResponse);
+ return false;
+ }
+
+ // Validate conference ID
+
+ // For simplicity, use the standard endpoint - we'll fix the parameter passing
+ const twimlEndpoint = `${window.location.origin}/api/v1/accounts/${accountId}/voice/twiml_for_client`;
+
+ // Log what we're doing with detailed info for debugging
+ // Connect to conference
+
+ // accountId is already defined above, no need to redefine it
+
+ // IMPORTANT: Twilio expects parameters to be in the correct case
+ // The parameter MUST be 'To' (capital T) for Twilio Voice SDK
+ // This is a common source of issues - where parameters get lowercased
+ const enhancedParams = {
+ To: conferenceId, // KEEP THIS CAPITALIZED - it's critical!
+ account_id: accountId // This should be lowercase
+ };
+
+ // Double check to ensure the parameter is correctly capitalized
+ if (!('To' in enhancedParams)) {
+ console.error('CRITICAL ERROR: The To parameter is not correctly capitalized!');
+ }
+
+ // Enhanced parameters for joining the call
+
+ // Explicitly initialize the device again to ensure it's ready
+ // This helps when there might be issues with the device state
+ if (VoiceAPI.getDeviceStatus() !== 'ready') {
+ console.log('Ensuring Twilio device is ready before connecting...');
+ try {
+ await VoiceAPI.initializeDevice(getAvailableInboxId());
+ console.log('Device reinitialized successfully');
+ } catch (initError) {
+ console.error('Error reinitializing device:', initError);
+ // Continue anyway - the device might still work
+ }
+ }
+
+ // Make the WebRTC call to join conference
+
+ // Get the connection object back so we can verify status
+ const connection = VoiceAPI.joinClientCall(enhancedParams);
+
+ // Log diagnostic info and verify connection
+ if (connection) {
+ console.log('Conference connection established', {
+ connectionObject: !!connection,
+ hasOnMethod: typeof connection.on === 'function',
+ connectionState: connection.status ? connection.status() : 'No status method',
+ deviceState: VoiceAPI.getDeviceStatus()
+ });
+
+ // Attach event handlers for audio troubleshooting
+ try {
+ if (typeof connection.on === 'function') {
+ // When call is accepted
+ connection.on('accept', () => {
+ console.log('✅ Agent call accepted, conference should start now');
+
+ // IMPORTANT: Try to unmute explicitly to ensure audio path is open
+ try {
+ if (connection.mute) {
+ connection.mute(false);
+ }
+ } catch (e) {
+ console.warn('Error unmuting connection:', e);
+ }
+
+ // Run the two-way audio check TWICE - once immediately and once after delay
+ verifyTwoWayAudio();
+
+ setTimeout(() => {
+ verifyTwoWayAudio();
+
+ // If still having issues, try to re-establish the audio path
+ try {
+ if (VoiceAPI.activeConnection) {
+ console.log('Trying to refresh audio path...');
+ // Toggle mute quickly to refresh audio path
+ VoiceAPI.activeConnection.mute(true);
+ setTimeout(() => VoiceAPI.activeConnection.mute(false), 100);
+ }
+ } catch (e) {
+ console.warn('Error toggling mute:', e);
+ }
+ }, 5000);
+ });
+
+ // Monitor for warnings
+ connection.on('warning', (warning) => {
+ console.warn('⚠️ Connection warning:', warning);
+ });
+ } else {
+ console.log('Connection object does not have .on() method - newer Twilio SDK version');
+ // Still schedule the two-way audio check
+ setTimeout(() => {
+ verifyTwoWayAudio();
+ }, 5000);
+ }
+
+ // CRITICAL: Handle volume events to ensure audio path is open
+ if (typeof connection.on === 'function' && typeof connection.volume === 'function') {
+ connection.on('volume', (inputVolume, outputVolume) => {
+ console.log(`🔊 Volume change - Input: ${inputVolume}, Output: ${outputVolume}`);
+ });
+ }
+ } catch (e) {
+ console.warn('Error setting up connection event handlers:', e);
+ }
+ }
+
+ console.log('WebRTC join call initiated successfully with minimal parameters');
+ return true;
+ } catch (connectionError) {
+ console.error('Error in VoiceAPI.joinClientCall:', connectionError);
+ return false;
+ }
+ } catch (error) {
+ console.error('Unexpected error in joinCallWithWebRTC:', error);
+ return false;
+ }
+ };
+
+ // New function to set up audio device properly before call
+ const prepareAudioDevice = async () => {
+ try {
+ console.log('🎙️ Preparing audio device for optimal performance...');
+
+ // First, ensure we have microphone permission
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ console.error('📛 WebRTC not supported in this browser');
+ return false;
+ }
+
+ // 1. Create a clean audio context to reset any audio state
+ try {
+ const AudioContext = window.AudioContext || window.webkitAudioContext;
+ if (AudioContext) {
+ const audioCtx = new AudioContext();
+
+ // Force audio context to start - critical for iOS
+ if (audioCtx.state === 'suspended') {
+ await audioCtx.resume();
+ }
+
+ console.log('🔊 Audio context created and started:', audioCtx.state);
+
+ // Create a test oscillator to ensure audio subsystem is active
+ const oscillator = audioCtx.createOscillator();
+ oscillator.type = 'sine';
+ oscillator.frequency.value = 440; // A4 note
+
+ const gainNode = audioCtx.createGain();
+ gainNode.gain.value = 0.01; // Very quiet - just to activate the system
+
+ oscillator.connect(gainNode);
+ gainNode.connect(audioCtx.destination);
+
+ // Start and immediately stop - just to warm up the audio system
+ oscillator.start();
+ setTimeout(() => {
+ oscillator.stop();
+ audioCtx.close();
+ console.log('🔊 Audio system warmed up');
+ }, 100);
+ }
+ } catch (e) {
+ console.warn('Error creating audio context:', e);
+ }
+
+ // 2. Get all available audio devices to ensure they're activated
+ try {
+ if (navigator.mediaDevices.enumerateDevices) {
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ const audioDevices = devices.filter(device => device.kind === 'audioinput');
+ console.log(`🎙️ Available audio input devices: ${audioDevices.length}`);
+
+ // If we have multiple mics, log them
+ if (audioDevices.length > 0) {
+ audioDevices.forEach(device => {
+ console.log(`- ${device.label || 'Unnamed device'} (${device.deviceId.substring(0, 8)}...)`);
+ });
+ }
+ }
+ } catch (e) {
+ console.warn('Error enumerating devices:', e);
+ }
+
+ // 3. Request a fresh audio stream with enhanced constraints
+ try {
+ // Release any previous streams
+ if (window.activeAudioStream) {
+ window.activeAudioStream.getTracks().forEach(track => track.stop());
+ }
+
+ // Request a new stream with HIGH-QUALITY audio
+ const stream = await navigator.mediaDevices.getUserMedia({
+ audio: {
+ echoCancellation: { exact: true },
+ noiseSuppression: { exact: true },
+ autoGainControl: { exact: true },
+ channelCount: { ideal: 1 },
+ sampleRate: { ideal: 48000 },
+ latency: { ideal: 0.01 },
+ sampleSize: { ideal: 16 }
+ }
+ });
+
+ // Save the stream globally
+ window.activeAudioStream = stream;
+
+ // Ensure tracks are active and enabled
+ stream.getAudioTracks().forEach(track => {
+ track.enabled = true;
+ console.log(`🎙️ Audio track ready: ${track.label} (${track.readyState})`);
+ });
+
+ console.log('✅ High-quality audio stream acquired successfully');
+ return true;
+ } catch (e) {
+ console.error('Error obtaining audio stream:', e);
+
+ // Fall back to basic audio to ensure we at least have something
+ try {
+ const basicStream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ window.activeAudioStream = basicStream;
+ console.log('⚠️ Fallback to basic audio stream successful');
+ return true;
+ } catch (fallbackError) {
+ console.error('Critical: Failed to obtain even basic audio stream:', fallbackError);
+ return false;
+ }
+ }
+ } catch (e) {
+ console.error('Error in audio device preparation:', e);
+ return false;
+ }
+ };
+
+ // Function to proactively fix audio issues before joining a call
+ const fixAudioBeforeCall = async () => {
+ try {
+ console.log('🎯 Running proactive audio fixes before joining call');
+
+ // 1. Ensure we have a fresh audio stream with optimal constraints
+ if (window.activeAudioStream) {
+ // Stop all current tracks to ensure we get a fresh stream
+ window.activeAudioStream.getTracks().forEach(track => track.stop());
+ }
+
+ // 2. Request a new stream with strict audio constraints
+ const stream = await navigator.mediaDevices.getUserMedia({
+ audio: {
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true,
+ // Try to force sample rate to match Twilio's preferred settings
+ sampleRate: { ideal: 48000 }
+ }
+ });
+
+ // 3. Save the new stream and log details
+ window.activeAudioStream = stream;
+
+ console.log('🎤 New audio stream acquired with tracks:',
+ stream.getAudioTracks().map(track => ({
+ label: track.label,
+ enabled: track.enabled,
+ readyState: track.readyState,
+ muted: track.muted,
+ constraints: track.getConstraints()
+ }))
+ );
+
+ // 4. Try to play a short beep to activate the audio subsystem
+ try {
+ // Create a temporary audio context
+ const AudioContext = window.AudioContext || window.webkitAudioContext;
+ const audioCtx = new AudioContext();
+
+ // Create an oscillator for a short beep
+ const oscillator = audioCtx.createOscillator();
+ oscillator.type = 'sine';
+ oscillator.frequency.setValueAtTime(440, audioCtx.currentTime); // 440 Hz = A4 note
+
+ // Create a gain node to control volume
+ const gainNode = audioCtx.createGain();
+ gainNode.gain.setValueAtTime(0.1, audioCtx.currentTime); // 10% volume
+
+ // Connect the oscillator to the gain node and the gain node to the destination
+ oscillator.connect(gainNode);
+ gainNode.connect(audioCtx.destination);
+
+ // Start the oscillator and stop it after 100ms
+ oscillator.start();
+ setTimeout(() => {
+ oscillator.stop();
+ audioCtx.close();
+ console.log('🔊 Audio subsystem warmed up with test tone');
+ }, 100);
+ } catch (audioErr) {
+ console.warn('Could not play test tone, but continuing:', audioErr);
+ }
+
+ return true;
+ } catch (err) {
+ console.error('Error in audio fix routine:', err);
+ // Don't block the call if fixes fail
+ return false;
+ }
+ };
+
+ // Function to check for active two-way audio in the conference
+ const verifyTwoWayAudio = () => {
+ console.log('🔍 Running two-way audio verification...');
+
+ // 1. Check device and connection state
+ const deviceState = VoiceAPI.getDeviceStatus();
+ const connectionActive = VoiceAPI.activeConnection !== null;
+
+ // 2. Check if audio tracks are active and not muted
+ let audioTracksOk = false;
+ if (window.activeAudioStream) {
+ const audioTracks = window.activeAudioStream.getAudioTracks();
+ audioTracksOk = audioTracks.some(track =>
+ track.enabled && track.readyState === 'live' && !track.muted);
+ }
+
+ // 3. Check if the connection has active remote media
+ let remoteMediaOk = false;
+ try {
+ if (VoiceAPI.activeConnection &&
+ typeof VoiceAPI.activeConnection.getRemoteStream === 'function') {
+ const remoteStream = VoiceAPI.activeConnection.getRemoteStream();
+ if (remoteStream && remoteStream.active) {
+ remoteMediaOk = true;
+ }
+ }
+ } catch (e) {
+ console.warn('Error checking remote media:', e);
+ }
+
+ // Logging all diagnostic info
+ console.log('📊 Two-way audio check results:', {
+ deviceState,
+ connectionActive,
+ audioTracksOk,
+ remoteMediaOk,
+ // Additional audio diagnostic info
+ audioContext: {
+ supported: !!(window.AudioContext || window.webkitAudioContext),
+ // Check if we have audio output devices
+ outputDevices: navigator.mediaDevices &&
+ typeof navigator.mediaDevices.enumerateDevices === 'function' ?
+ 'Call enumerateDevices() to check outputs' : 'Not supported'
+ },
+ // Microphone info
+ microphone: window.activeAudioStream ? {
+ trackCount: window.activeAudioStream.getAudioTracks().length,
+ tracks: window.activeAudioStream.getAudioTracks().map(t => ({
+ label: t.label,
+ enabled: t.enabled,
+ readyState: t.readyState,
+ muted: t.muted
+ }))
+ } : 'No active stream'
+ });
+
+ // FIXED: Improved overall status assessment to include remoteMediaOk
+ // This was causing false negative alerts even when everything was fine
+ const overallStatus = deviceState === 'busy' &&
+ connectionActive &&
+ audioTracksOk &&
+ remoteMediaOk; // Added remoteMediaOk to criteria
+
+ // Simple audio path check without DTMF tones
+ if (VoiceAPI.activeConnection) {
+ try {
+ console.log('🔄 Checking audio connection status...');
+
+ // Only toggle mute on/off to refresh the audio stream
+ setTimeout(() => {
+ if (VoiceAPI.activeConnection) {
+ console.log('✓ Toggling mute ON to reset audio...');
+ VoiceAPI.activeConnection.mute(true);
+
+ setTimeout(() => {
+ if (VoiceAPI.activeConnection) {
+ console.log('✓ Toggling mute OFF to reset audio...');
+ VoiceAPI.activeConnection.mute(false);
+ }
+ }, 300);
+ }
+ }, 500);
+
+ // Check WebRTC stats if available for diagnostics
+ setTimeout(() => {
+ if (VoiceAPI.activeConnection && typeof VoiceAPI.activeConnection.getStats === 'function') {
+ try {
+ VoiceAPI.activeConnection.getStats().then(stats => {
+ console.log('📊 WebRTC Connection Stats:', stats);
+
+ // Look for specific audio issues in stats
+ const audioIssues = [];
+ stats.forEach(stat => {
+ if (stat.type === 'inbound-rtp' && stat.kind === 'audio') {
+ if (stat.packetsLost > 0) {
+ audioIssues.push(`Packet loss: ${stat.packetsLost} packets`);
+ }
+ if (stat.jitter > 0.05) { // High jitter
+ audioIssues.push(`High jitter: ${stat.jitter.toFixed(3)}s`);
+ }
+ }
+ });
+
+ if (audioIssues.length > 0) {
+ console.warn('🔊 Audio quality issues detected:', audioIssues);
+ } else {
+ console.log('✓ No WebRTC audio quality issues detected');
+ }
+ });
+ } catch (statsError) {
+ console.warn('Cannot get WebRTC stats:', statsError);
+ }
+ }
+ }, 2000);
+ } catch (e) {
+ console.warn('Error checking audio connection:', e);
+ }
+ }
+
+ if (!overallStatus) {
+ console.warn('⚠️ POTENTIAL TWO-WAY AUDIO ISSUE DETECTED');
+ // Alert the user about potential audio issues, but don't show every time
+ // as it might be transitional and resolve itself
+ if (Math.random() < 0.3) { // Only show 30% of the time to avoid too many alerts
+ useAlert('Audio connection establishing. If you cannot hear the caller after a few seconds, try pressing 1 on your keyboard.');
+ }
+
+ // Suggest adding more status callbacks for debugging
+ console.log('SUGGESTION: Add more conference status callbacks in webhook_controller.rb and voice_controller.rb to debug audio issues.');
+ } else {
+ console.log('✅ Two-way audio appears to be configured correctly');
+ }
+
+ return overallStatus;
+ };
+
+ // End a WebRTC call
+ const endWebRTCCall = () => {
+ if (!isWebRTCInitialized.value) return false;
+
+ try {
+ // End the call in the client
+ const result = VoiceAPI.endClientCall();
+
+ // Clean up UI
+ stopDurationTimer();
+
+ return result;
+ } catch (error) {
+ console.error('Error ending WebRTC call:', error);
+ return false;
+ }
+ };
+
+ // Toggle mute with WebRTC
+ const toggleMuteWebRTC = () => {
+ if (!isWebRTCInitialized.value) return false;
+
+ try {
+ isMuted.value = !isMuted.value;
+ const result = VoiceAPI.setMute(isMuted.value);
+
+ useAlert(isMuted.value ? 'Call muted' : 'Call unmuted');
+
+ return result;
+ } catch (error) {
+ console.error('Error toggling mute:', error);
+ return false;
+ }
+ };
// Explicit debug handler for end call click
const handleEndCallClick = async () => {
@@ -399,8 +1286,13 @@ export default {
// Emit event
emit('callEnded');
+
+ // If WebRTC is initialized, end the call via WebRTC
+ if (isWebRTCInitialized.value) {
+ endWebRTCCall();
+ }
- // Make API call if we have a valid conversation ID and a real call SID (not pending)
+ // Also make API call if we have a valid conversation ID and a real call SID (not pending)
if (savedConversationId && savedCallSid && savedCallSid !== 'pending') {
// Check if it's a valid Twilio call SID (starts with CA or TJ)
const isValidTwilioSid =
@@ -409,19 +1301,16 @@ export default {
if (isValidTwilioSid) {
try {
await VoiceAPI.endCall(savedCallSid, savedConversationId);
- useAlert({ message: 'Call ended', type: 'success' });
+ useAlert('Call ended');
} catch (error) {
console.error('Error ending call:', error);
- useAlert({
- message: 'Call ended (but server may still show as active)',
- type: 'warning',
- });
+ useAlert('Call ended (but server may still show as active)');
}
} else {
- useAlert({ message: 'Call ended', type: 'success' });
+ useAlert('Call ended');
}
} else {
- useAlert({ message: 'Call ended', type: 'success' });
+ useAlert('Call ended');
}
// Set global call status in all possible places to ensure widget is removed
@@ -443,8 +1332,15 @@ export default {
// Safe translation helper with fallback
const safeTranslate = key => {
try {
- return t(key);
+ const translation = t(key);
+ // Check if the translation is actually the key itself (which happens when the key is not found)
+ if (translation === key) {
+ // Return the fallback from our local translations or the key itself
+ return translations[key] || key;
+ }
+ return translation;
} catch (error) {
+ console.warn(`Translation error for key '${key}':`, error);
return translations[key] || key;
}
};
@@ -477,28 +1373,124 @@ export default {
};
onMounted(() => {
+ console.log('FloatingCallWidget mounted, initializing components...');
+
// If this is an active call, start timer
if (hasActiveCall.value) {
+ console.log('Active call detected, starting timer');
startDurationTimer();
}
- // If this is an incoming call, play ringtone
+ // Handle incoming calls
if (isIncoming.value) {
- // Slight delay to ensure DOM is fully rendered
- setTimeout(() => {
- playRingtone();
- }, 300);
+ // Check if this is an outbound call
+ const isOutboundCall = incomingCall.value && incomingCall.value.isOutbound === true;
+
+ if (isOutboundCall && incomingCall.value.requiresAgentJoin) {
+ // Auto-join outbound calls after a short delay to ensure everything is loaded
+ setTimeout(() => {
+ acceptCall();
+ }, 1000);
+ } else if (!isOutboundCall) {
+ // Only play ringtone for true inbound calls, not outbound ones
+ console.log('Inbound call detected, preparing ringtone');
+
+ // Slight delay to ensure DOM is fully rendered
+ setTimeout(() => {
+ playRingtone();
+ }, 300);
+ }
}
// Fetch contact details if needed (after slight delay to ensure callInfo is populated)
setTimeout(() => {
fetchContactDetails();
}, 500);
+
+ // Initialize WebRTC if enabled, with staged approach
+ if (props.useWebRTC) {
+ // First check browser compatibility immediately
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ console.log('WebRTC not supported in this browser');
+ isWebRTCSupported.value = false;
+ } else {
+ console.log('WebRTC is supported, scheduling initialization');
+
+ // Check if we have an inbox ID available
+ const inboxId = getAvailableInboxId();
+
+ if (!inboxId) {
+ console.error('No inbox ID available during component mount. WebRTC initialization will be delayed.');
+
+ // We'll wait for store updates that might populate the inbox ID
+ const storeWatcher = watch(
+ () => [activeCall.value?.inboxId, incomingCall.value?.inboxId, props.inboxId, store.getters['calls/getActiveCall']?.inboxId, store.getters['calls/getIncomingCall']?.inboxId],
+ () => {
+ const newInboxId = getAvailableInboxId();
+
+ if (newInboxId) {
+ console.log(`Inbox ID is now available: ${newInboxId}, proceeding with WebRTC initialization`);
+ storeWatcher(); // Stop watching since we found an ID
+
+ // Now continue with initialization since we have an inbox ID
+ initializeWebRTC();
+ }
+ },
+ { immediate: true }
+ );
+ } else {
+ console.log(`Inbox ID available at mount: ${inboxId}, proceeding with WebRTC initialization`);
+ // We have an inbox ID, proceed with initialization
+ initializeWebRTC();
+ }
+ }
+ } else {
+ console.log('WebRTC is disabled for this component');
+ }
});
+
+ // Extracted WebRTC initialization logic to its own function
+ const initializeWebRTC = () => {
+ // Perform a staged initialization:
+ // 1. First wait for the DOM to be fully rendered
+ setTimeout(() => {
+ // 2. Request microphone permissions
+ navigator.mediaDevices.getUserMedia({ audio: true })
+ .then(() => {
+ console.log('Microphone permission granted, initializing device');
+ microphonePermission.value = 'granted';
+
+ // 3. Wait a bit longer before fully initializing the device
+ setTimeout(() => {
+ initializeTwilioDevice()
+ .then(success => {
+ if (success) {
+ console.log('Twilio device initialized successfully on component mount');
+ } else {
+ console.error('Twilio device initialization failed on component mount');
+ }
+ })
+ .catch(error => {
+ console.error('Error during Twilio device initialization on mount:', error);
+ });
+ }, 500);
+ })
+ .catch(error => {
+ console.error('Microphone permission denied during initial setup:', error);
+ microphonePermission.value = 'denied';
+ useAlert('Browser call requires microphone access. Please enable it in your browser settings.');
+ });
+ }, 1000);
+ };
onBeforeUnmount(() => {
stopDurationTimer();
stopRingtone();
+
+ // Clean up Twilio device if it's initialized
+ if (isWebRTCInitialized.value) {
+ endWebRTCCall();
+ }
});
// Watch for call store changes
@@ -506,12 +1498,20 @@ export default {
() => isIncoming.value,
newIsIncoming => {
if (newIsIncoming) {
- // Immediate UI feedback with delay for audio to allow browser autoplay policies
+ // Check if this is an outbound call
+ const isOutboundCall = incomingCall.value && incomingCall.value.isOutbound === true;
+
+ // Immediate UI feedback
stopDurationTimer();
- setTimeout(() => {
- playRingtone();
- }, 300);
+
+ // Only play ringtone for true inbound calls, not outbound ones
+ if (!isOutboundCall) {
+ setTimeout(() => {
+ playRingtone();
+ }, 300);
+ }
} else {
+ // Make sure to always stop the ringtone when not incoming
stopRingtone();
}
},
@@ -522,7 +1522,13 @@ export default {
() => isJoined.value,
newIsJoined => {
if (newIsJoined) {
+ // Make multiple attempts to stop the ringtone
stopRingtone();
+ // Double-check after a short delay
+ setTimeout(() => {
+ stopRingtone();
+ }, 100);
+
startDurationTimer();
}
}
@@ -540,6 +1546,8 @@ export default {
{ immediate: true }
);
+ // This function was removed as it's no longer needed
+
return {
isCallActive,
callDuration,
@@ -553,6 +1561,11 @@ export default {
incomingCall,
callInfo,
displayContactName,
+ isWebRTCInitialized,
+ isWebRTCSupported,
+ microphonePermission,
+ twilioDeviceStatus,
+ currentVolume,
endCall,
forceEndCall,
acceptCall,
@@ -561,6 +1574,7 @@ export default {
toggleMute,
toggleCallOptions,
toggleFullscreen,
+ initializeTwilioDevice,
safeTranslate,
};
},
@@ -637,6 +1651,44 @@ export default {
>
+
+
+
+
+
+
+
+
+
+
+ Browser calls not supported
+
+
+ Browser call initializing...
+
+
+ Browser Call: {{ twilioDeviceStatus === 'ready' ? 'Ready' : twilioDeviceStatus }}
+
+
+
+
+
+
+ {{ isMuted ? 'Muted' : 'Active' }}
+
+
+
+
@@ -767,6 +1819,7 @@ export default {
background: var(--r-600, #b91c1c);
}
}
+
&.accept-call-button,
&.reject-call-button {
@@ -832,4 +1885,103 @@ export default {
transform: scale(1);
}
}
+
+// WebRTC indicator styles
+.webrtc-status {
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ display: flex;
+ justify-content: center;
+}
+
+.webrtc-indicator {
+ display: flex;
+ align-items: center;
+ font-size: 12px;
+ color: var(--s-300, #9ca3af);
+ background-color: rgba(0, 0, 0, 0.2);
+ border-radius: 12px;
+ padding: 4px 8px;
+
+ // Different states for the indicator
+ &.is-active .webrtc-dot {
+ background-color: var(--g-500, #10b981);
+ }
+
+ &.is-inactive .webrtc-dot {
+ background-color: var(--y-500, #f59e0b);
+ animation: webrtcPulse 1.5s infinite;
+ }
+
+ &.is-unsupported .webrtc-dot {
+ background-color: var(--s-400, #6b7280);
+ }
+
+ &.is-ready .webrtc-dot {
+ background-color: var(--g-500, #10b981);
+ }
+
+ &.is-busy .webrtc-dot {
+ background-color: var(--b-400, #60a5fa);
+ }
+
+ &.is-error .webrtc-dot {
+ background-color: var(--r-500, #dc2626);
+ }
+}
+
+.webrtc-dot {
+ width: 8px;
+ height: 8px;
+ flex-shrink: 0;
+ border-radius: 50%;
+ background-color: var(--r-500, #dc2626);
+ margin-right: 6px;
+ position: relative;
+
+ &:after {
+ content: '';
+ position: absolute;
+ top: -2px;
+ left: -2px;
+ right: -2px;
+ bottom: -2px;
+ border-radius: 50%;
+ border: 1px solid currentColor;
+ animation: webrtcPulse 1.5s infinite;
+ }
+}
+
+.webrtc-text {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ white-space: nowrap;
+}
+
+.mic-status {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-left: 8px;
+ padding-left: 8px;
+ border-left: 1px solid rgba(255, 255, 255, 0.2);
+}
+
+@keyframes webrtcPulse {
+ 0% {
+ transform: scale(1);
+ opacity: 0.8;
+ }
+ 50% {
+ transform: scale(1.5);
+ opacity: 0;
+ }
+ 100% {
+ transform: scale(1);
+ opacity: 0;
+ }
+}
diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js
index 9c5cc20dc..9142fa3bc 100644
--- a/app/javascript/dashboard/helper/actionCable.js
+++ b/app/javascript/dashboard/helper/actionCable.js
@@ -205,8 +205,15 @@ class ActionCableConnector extends BaseActionCableConnector {
inboxName: data.inbox_name,
contactName: data.contact_name,
contactId: data.contact_id,
+ accountId: data.account_id,
+ isOutbound: data.is_outbound || false, // Check if this is an outbound call requiring agent join
+ conference_sid: data.conference_sid, // Pass the conference_sid directly to the floating widget
+ requiresAgentJoin: data.requires_agent_join || false, // Flag for calls needing immediate agent join
+ callDirection: data.call_direction // Add call direction for additional context
};
+ // Process outbound calls
+
// Update store
this.app.$store.dispatch('calls/setIncomingCall', normalizedPayload);
@@ -214,6 +221,8 @@ class ActionCableConnector extends BaseActionCableConnector {
if (window.app && window.app.$data) {
window.app.$data.showCallWidget = true;
}
+
+ // For outbound calls, we don't need to play a ringtone as we're initiating the call
};
onCallStatusChanged = data => {
@@ -222,6 +231,7 @@ class ActionCableConnector extends BaseActionCableConnector {
callSid: data.call_sid,
status: data.status,
conversationId: data.conversation_id,
+ inboxId: data.inbox_id,
};
// Update store
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue
index 4804010cb..c49c32d66 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue
@@ -205,6 +205,7 @@ export default {
inboxName: inbox?.name || 'Primary',
conversationId: conversation.id,
contactId: this.contact.id,
+ inboxId: conversation.inbox_id,
});
// Set App's showCallWidget to true
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Voice.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Voice.vue
index da0c0ecab..1fcc83cc6 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Voice.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Voice.vue
@@ -26,6 +26,9 @@ export default {
phoneNumber: '',
accountSid: '',
authToken: '',
+ apiKeySid: '',
+ apiKeySecret: '',
+ twimlAppSid: '',
providerOptions: [
{ value: 'twilio', label: 'Twilio' },
// Add more providers as needed
@@ -50,6 +53,16 @@ export default {
authToken: {
required: this.provider === 'twilio',
},
+ apiKeySid: {
+ required: this.provider === 'twilio',
+ },
+ apiKeySecret: {
+ required: this.provider === 'twilio',
+ },
+ // TwiML App SID is not required, but if provided it must follow Twilio's format
+ twimlAppSid: {
+ // Optional - will not be required
+ },
};
},
methods: {
@@ -59,10 +72,19 @@ export default {
},
getProviderConfig() {
if (this.provider === 'twilio') {
- return {
+ const config = {
account_sid: this.accountSid,
auth_token: this.authToken,
+ api_key_sid: this.apiKeySid,
+ api_key_secret: this.apiKeySecret,
};
+
+ // Add the TwiML App SID if provided
+ if (this.twimlAppSid) {
+ config.outgoing_application_sid = this.twimlAppSid;
+ }
+
+ return config;
}
// Add handler for other providers here
return {};
@@ -186,6 +208,59 @@ export default {
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js
index ce0f156b4..1c2e87f5d 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -215,7 +215,7 @@ export const actions = {
const inboxParams = {
name: params.voice.name || `Voice (${params.voice.phone_number})`,
channel: {
- type: 'Channel::Voice',
+ type: 'voice',
phone_number: params.voice.phone_number,
provider: params.voice.provider,
provider_config: params.voice.provider_config,
diff --git a/app/jobs/process_conference_status_job.rb b/app/jobs/process_conference_status_job.rb
new file mode 100644
index 000000000..f2d504997
--- /dev/null
+++ b/app/jobs/process_conference_status_job.rb
@@ -0,0 +1,146 @@
+class ProcessConferenceStatusJob < ApplicationJob
+ queue_as :default
+
+ def perform(options = {})
+ # Extract parameters from options
+ conversation_id = options[:conversation_id]
+ event = options[:event]
+ call_sid = options[:call_sid]
+ conference_sid = options[:conference_sid]
+ account_id = options[:account_id]
+ participant_sid = options[:participant_sid]
+ participant_label = options[:participant_label]
+ call_sid_ending_with = options[:call_sid_ending_with]
+ audio_level = options[:audio_level]
+
+ # Set the current account (required for proper routing)
+ Current.account = Account.find(account_id)
+
+ # Find the conversation
+ conversation = Current.account.conversations.find_by(id: conversation_id)
+ return unless conversation
+
+ # Update conversation with conference info
+ conversation.additional_attributes ||= {}
+ conversation.additional_attributes['conference_sid'] = conference_sid
+
+ # Store more detailed audio diagnostics for speak events
+ if event == 'participant-speak'
+ conversation.additional_attributes['last_speak_event'] = {
+ participant_sid: participant_sid,
+ timestamp: Time.now.to_i,
+ audio_level: audio_level || 'unknown'
+ }
+ end
+
+ # Process the event
+ case event
+ when 'conference-start'
+ conversation.additional_attributes['conference_status'] = 'started'
+ activity_message = 'Conference started'
+ when 'conference-end'
+ conversation.additional_attributes['conference_status'] = 'ended'
+ conversation.additional_attributes['call_status'] = 'completed'
+ conversation.additional_attributes['call_ended_at'] = Time.now.to_i
+ conversation.status = :resolved
+ activity_message = 'Conference ended'
+ when 'participant-join'
+ # Track participant type for debugging
+ participant_type = participant_label || (call_sid_ending_with || '').start_with?('agent') ? 'agent' : 'caller'
+ activity_message = "#{participant_type.capitalize} joined the call"
+
+ # Track all participants for audio diagnostics
+ conversation.additional_attributes['participants'] ||= {}
+ conversation.additional_attributes['participants'][participant_sid] = {
+ joined_at: Time.now.to_i,
+ type: participant_type,
+ call_sid: call_sid,
+ status: 'joined'
+ }
+ when 'participant-leave'
+ # Update participant status
+ if conversation.additional_attributes['participants']&.key?(participant_sid)
+ participant_type = conversation.additional_attributes['participants'][participant_sid]['type'] || 'Participant'
+ activity_message = "#{participant_type.capitalize} left the call"
+
+ # Mark participant as left
+ conversation.additional_attributes['participants'][participant_sid]['status'] = 'left'
+ conversation.additional_attributes['participants'][participant_sid]['left_at'] = Time.now.to_i
+ else
+ activity_message = 'Participant left the call'
+ end
+ when 'participant-speak'
+ # This is critical for diagnosing audio issues
+ if conversation.additional_attributes['participants']&.key?(participant_sid)
+ participant_type = conversation.additional_attributes['participants'][participant_sid]['type'] || 'Participant'
+ activity_message = "#{participant_type} speaking detected"
+
+ # Track speaking events
+ participant = conversation.additional_attributes['participants'][participant_sid]
+ participant['speak_events'] ||= []
+ participant['speak_events'] << Time.now.to_i
+
+ # Only keep the last 5 events to avoid bloating the database
+ participant['speak_events'] = participant['speak_events'].last(5) if participant['speak_events'].size > 5
+
+ conversation.additional_attributes['participants'][participant_sid] = participant
+ else
+ activity_message = 'Speech detected'
+ end
+ else
+ activity_message = "Call event: #{event}"
+ end
+
+ # Save conversation with enhanced tracking
+ begin
+ conversation.save!
+ Rails.logger.info("✅ Conference status updated: #{event} for conversation_id=#{conversation.id}")
+ rescue => e
+ Rails.logger.error("❌ Failed to save conversation: #{e.message}")
+ end
+
+ # Create activity message with enhanced attributes
+ begin
+ Messages::MessageBuilder.new(
+ nil,
+ conversation,
+ {
+ content: activity_message,
+ message_type: :activity,
+ additional_attributes: {
+ call_sid: call_sid,
+ event_type: event,
+ conference_sid: conference_sid,
+ timestamp: Time.now.to_i,
+ participant_sid: participant_sid,
+ audio_level: audio_level
+ }
+ }
+ ).perform
+ rescue => e
+ Rails.logger.error("❌ Failed to create activity message: #{e.message}")
+ end
+
+ # Broadcast call status updates on account-level channel
+ begin
+ # Include account_id in the data to help with validation
+ data_with_account = {
+ call_sid: call_sid,
+ status: conversation.additional_attributes['call_status'] || 'in-progress',
+ conversation_id: conversation.id,
+ event: event,
+ account_id: conversation.account_id
+ }
+
+ ActionCable.server.broadcast(
+ "account_#{conversation.account_id}",
+ {
+ event: 'call_status_changed',
+ data: data_with_account
+ }
+ )
+ rescue => e
+ Rails.logger.error("❌ Failed to broadcast call status: #{e.message}")
+ end
+ end
+end
\ No newline at end of file
diff --git a/app/models/channel/voice.rb b/app/models/channel/voice.rb
index 3d70f7003..79edd7cb2 100644
--- a/app/models/channel/voice.rb
+++ b/app/models/channel/voice.rb
@@ -16,10 +16,10 @@ class Channel::Voice < ApplicationRecord
"#{provider.capitalize} Voice"
end
- def initiate_call(to:)
+ def initiate_call(to:, conference_name: nil)
case provider
when 'twilio'
- initiate_twilio_call(to)
+ initiate_twilio_call(to, conference_name)
# Add more providers as needed
# when 'other_provider'
# initiate_other_provider_call(to)
@@ -30,14 +30,22 @@ class Channel::Voice < ApplicationRecord
private
- def initiate_twilio_call(to)
+ def initiate_twilio_call(to, conference_name = nil)
config = provider_config_hash
- # Generate a full URL for Twilio to request TwiML
- host = ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
+ # Generate a public URL for Twilio to request TwiML (must set FRONTEND_URL)
+ host = ENV.fetch('FRONTEND_URL')
# Use the simplest possible TwiML endpoint
callback_url = "#{host}/twilio/voice/simple"
+
+ # Add conference name as a parameter if provided
+ if conference_name.present?
+ callback_url += "?conference_name=#{CGI.escape(conference_name)}"
+
+ # Log this for debugging
+ Rails.logger.info("🚨 OUTBOUND CALL: Adding conference_name '#{conference_name}' to callback URL: #{callback_url}")
+ end
# Parameters including status callbacks for call progress tracking
params = {
@@ -52,11 +60,13 @@ class Channel::Voice < ApplicationRecord
# Create the call
call = twilio_client(config).calls.create(**params)
- # Return the bare minimum
+ # Return info needed to properly route and track the call
{
provider: 'twilio',
call_sid: call.sid,
- status: call.status
+ status: call.status,
+ call_direction: 'outbound', # CRITICAL: Tag as outbound so webhooks know to prompt agent
+ requires_agent_join: true # Flag that agent should join immediately
}
end
diff --git a/config/routes.rb b/config/routes.rb
index 55db8e099..83cf3f45f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -91,8 +91,14 @@ Rails.application.routes.draw do
namespace :channels do
resource :twilio_channel, only: [:create]
namespace :voice do
- post 'webhooks/incoming', to: 'webhooks#incoming'
- post 'webhooks/conference_status', to: 'webhooks#conference_status'
+ # Voice webhooks using resource scope to avoid plural/singular confusion
+ resource :webhooks, only: [], controller: 'webhooks' do
+ collection do
+ post :incoming
+ match :conference_status, via: [:post, :options] # Allow both POST and OPTIONS
+ match :incoming, via: [:post, :options] # Allow both POST and OPTIONS
+ end
+ end
end
end
resources :conversations, only: [:index, :create, :show, :update] do
@@ -182,11 +188,22 @@ Rails.application.routes.draw do
delete :avatar, on: :member
end
- # Voice call management
- post 'voice/end_call', to: 'voice#end_call'
- post 'voice/join_call', to: 'voice#join_call'
- post 'voice/reject_call', to: 'voice#reject_call'
- get 'voice/call_status', to: 'voice#call_status'
+ # Voice call management - using resource to avoid plural/singular confusion
+ resource :voice, only: [], controller: 'voice' do
+ member do
+ post :end_call
+ post :join_call
+ post :reject_call
+ get :call_status
+ # Explicitly set the format for TwiML to ensure proper Content-Type headers
+ match :twiml_for_client, via: [:get, :post, :options], defaults: { format: :xml } # Allow GET, POST, and OPTIONS
+ end
+ end
+
+ # Voice call client SDK support
+ namespace :voice do
+ resources :tokens, only: [:create]
+ end
resources :inbox_members, only: [:create, :show], param: :inbox_id do
collection do
delete :destroy
@@ -490,15 +507,19 @@ Rails.application.routes.draw do
resources :callback, only: [:create]
resources :delivery_status, only: [:create]
- # Define controller explicitly to avoid the plural/singular confusion
- get 'voice/twiml', to: 'voice#twiml'
- post 'voice/twiml', to: 'voice#twiml'
- get 'voice/simple', to: 'voice#simple_twiml'
- post 'voice/simple', to: 'voice#simple_twiml'
- post 'voice/handle_recording', to: 'voice#handle_recording'
- post 'voice/handle_user_input', to: 'voice#handle_user_input'
- post 'voice/transcription_callback', to: 'voice#transcription_callback'
- post 'voice/status_callback', to: 'voice#status_callback'
+ # Use resource scope to avoid plural/singular confusion
+ resource :voice, only: [], controller: 'voice' do
+ collection do
+ get :twiml
+ post :twiml
+ get :simple, action: :simple_twiml
+ post :simple, action: :simple_twiml
+ post :handle_recording
+ post :handle_user_input
+ post :transcription_callback
+ post :status_callback
+ end
+ end
end
get 'microsoft/callback', to: 'microsoft/callbacks#show'
diff --git a/package.json b/package.json
index d09360953..628a0b47d 100644
--- a/package.json
+++ b/package.json
@@ -50,6 +50,7 @@
"@sindresorhus/slugify": "2.2.1",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/vue-table": "^8.20.5",
+ "@twilio/voice-sdk": "^2.12.4",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/compiler-sfc": "^3.5.8",
"@vuelidate/core": "^2.0.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 07d58cbcf..fb72f6dfe 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -70,6 +70,9 @@ importers:
'@tanstack/vue-table':
specifier: ^8.20.5
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
+ '@twilio/voice-sdk':
+ specifier: ^2.12.4
+ version: 2.12.4
'@vitejs/plugin-vue':
specifier: ^5.1.4
version: 5.1.4(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
@@ -1746,6 +1749,13 @@ packages:
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
+ '@twilio/voice-errors@1.7.0':
+ resolution: {integrity: sha512-9TvniWpzU0iy6SYFAcDP+HG+/mNz2yAHSs7+m0DZk86lE+LoTB6J/ZONTPuxXrXWi4tso/DulSHuA0w7nIQtGg==}
+
+ '@twilio/voice-sdk@2.12.4':
+ resolution: {integrity: sha512-zP2lXl8ciWogTfBEc6pGVAeSvJ/zectX6guu8U1MRa3ZKauLr899JMoVkgGMgJUNFI4vxEi6vacWV4uL7KdnnQ==}
+ engines: {node: '>= 12'}
+
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
@@ -1764,6 +1774,9 @@ packages:
'@types/markdown-it@12.2.3':
resolution: {integrity: sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==}
+ '@types/md5@2.3.2':
+ resolution: {integrity: sha512-v+JFDu96+UYJ3/UWzB0mEglIS//MZXgRaJ4ubUPwOM0gvLc/kcQ3TWNYwENEK7/EcXGQVrW8h/XqednSjBd/Og==}
+
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
@@ -2815,6 +2828,10 @@ packages:
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
+ events@3.3.0:
+ resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
+ engines: {node: '>=0.8.x'}
+
execa@7.2.0:
resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==}
engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0}
@@ -3538,6 +3555,10 @@ packages:
resolution: {integrity: sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ loglevel@1.6.7:
+ resolution: {integrity: sha512-cY2eLFrQSAfVPhCgH1s7JI73tMbg9YC3v3+ZHVW67sBS7UxWzNEk/ZBbSfLykBWHp33dqqtOv82gjhKEi81T/A==}
+ engines: {node: '>= 0.6.0'}
+
loupe@3.1.3:
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
@@ -4358,6 +4379,10 @@ packages:
rrweb-cssom@0.7.1:
resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==}
+ rtcpeerconnection-shim@1.2.8:
+ resolution: {integrity: sha512-5Sx90FGru1sQw9aGOM+kHU4i6mbP8eJPgxliu2X3Syhg8qgDybx8dpDTxUwfJvPnubXFnZeRNl59DWr4AttJKQ==}
+ engines: {node: '>=6.0.0', npm: '>=3.10.0'}
+
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -4398,6 +4423,9 @@ packages:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
+ sdp@2.12.0:
+ resolution: {integrity: sha512-jhXqQAQVM+8Xj5EjJGVweuEzgtGWb3tmEEpl3CLP3cStInSbVHSg0QWOGQzNq8pSID4JkpeV2mPqlMDLrm0/Vw==}
+
sdp@3.2.0:
resolution: {integrity: sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==}
@@ -6744,6 +6772,17 @@ snapshots:
'@tootallnate/once@2.0.0': {}
+ '@twilio/voice-errors@1.7.0': {}
+
+ '@twilio/voice-sdk@2.12.4':
+ dependencies:
+ '@twilio/voice-errors': 1.7.0
+ '@types/md5': 2.3.2
+ events: 3.3.0
+ loglevel: 1.6.7
+ md5: 2.3.0
+ rtcpeerconnection-shim: 1.2.8
+
'@types/estree@1.0.7': {}
'@types/flexsearch@0.7.6': {}
@@ -6761,6 +6800,8 @@ snapshots:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
+ '@types/md5@2.3.2': {}
+
'@types/mdurl@2.0.0': {}
'@types/node@22.7.0':
@@ -8082,6 +8123,8 @@ snapshots:
eventemitter3@5.0.1: {}
+ events@3.3.0: {}
+
execa@7.2.0:
dependencies:
cross-spawn: 7.0.6
@@ -8923,6 +8966,8 @@ snapshots:
strip-ansi: 7.1.0
wrap-ansi: 8.1.0
+ loglevel@1.6.7: {}
+
loupe@3.1.3: {}
lower-case@2.0.2:
@@ -9798,6 +9843,10 @@ snapshots:
rrweb-cssom@0.7.1: {}
+ rtcpeerconnection-shim@1.2.8:
+ dependencies:
+ sdp: 2.12.0
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -9853,6 +9902,8 @@ snapshots:
dependencies:
xmlchars: 2.2.0
+ sdp@2.12.0: {}
+
sdp@3.2.0: {}
section-matter@1.0.0: