chore: inbound and outbound calls that work

This commit is contained in:
Sojan
2025-05-02 02:41:38 -07:00
parent 3f0c01e166
commit 3692cde1a9
17 changed files with 3434 additions and 366 deletions
@@ -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
@@ -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(
@@ -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
@@ -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: "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Response><Say>Error occurred</Say><Hangup/></Response>", content_type: 'text/xml'
end
end
private
def fetch_conversation
@conversation = Current.account.conversations.find(params[:id] || params[:conversation_id])
end
# Helper method to get base URL with extra resilience
def base_url
# Try several methods to determine the base URL, with detailed logging
base = nil
source = nil
begin
# First, try request.base_url if available
if defined?(request) && request&.respond_to?(:base_url) && request.base_url.present?
base = request.base_url
source = 'request.base_url'
Rails.logger.info("✅ Got base_url from request: #{base}")
end
# If not available, check for Rails.application.routes.default_url_options
if base.nil? && defined?(Rails) && Rails.application&.routes&.respond_to?(:default_url_options)
options = Rails.application.routes.default_url_options
if options && options[:host].present?
protocol = options[:protocol] || 'http'
port = options[:port].present? ? ":#{options[:port]}" : ''
base = "#{protocol}://#{options[:host]}#{port}"
source = 'Rails.application.routes.default_url_options'
Rails.logger.info("✅ Got base_url from Rails routes: #{base}")
end
end
# Check for specific Chatwoot ENV variables
if base.nil?
frontend_url = ENV.fetch('FRONTEND_URL', nil)
if frontend_url.present?
base = frontend_url
source = 'FRONTEND_URL env var'
Rails.logger.info("✅ Got base_url from FRONTEND_URL env var: #{base}")
end
end
# Check for additional Chatwoot ENV variables
if base.nil?
api_url = ENV.fetch('API_URL', nil)
if api_url.present?
base = api_url.to_s.gsub(/\/api\/v\d+\/?$/, '') # Remove API version path if present
source = 'API_URL env var'
Rails.logger.info("✅ Got base_url from API_URL env var: #{base}")
end
end
# Try to use Current account domain
if base.nil? && Current.account&.domain.present?
base = "https://#{Current.account.domain}"
source = 'Current.account.domain'
Rails.logger.info("✅ Got base_url from Current.account.domain: #{base}")
end
# Detect local development environments
if base.nil? && (request&.host == 'localhost' || request&.host&.include?('.local'))
port = request&.port || 3000
base = "http://#{request.host}:#{port}"
source = 'localhost detection'
Rails.logger.info("✅ Detected localhost development: #{base}")
end
# Ultimate fallback - use either a sojan-local.chatwoot.dev pattern or localhost
if base.nil?
if request&.host.present? && request.host.include?('chatwoot')
base = "https://#{request.host}"
source = 'request.host fallback for chatwoot domain'
else
base = 'http://localhost:3000'
source = 'localhost hardcoded fallback'
end
Rails.logger.info("⚠️ Using fallback base_url: #{base} (source: #{source})")
end
# Ensure base URL doesn't have a trailing slash
base = base.chomp('/') if base
# Additional safeguard
if !base.to_s.match?(/^https?:\/\//)
base = "http://#{base}"
Rails.logger.warn("⚠️ Added missing protocol to base_url: #{base}")
end
Rails.logger.info("🌐 FINAL base_url: #{base} (source: #{source})")
base
rescue => e
# If all else fails, return localhost but log the error
Rails.logger.error("❌ Error determining base URL: #{e.message}")
Rails.logger.error(e.backtrace.first(3).join("\n"))
'http://localhost:3000'
end
end
end
+108 -24
View File
@@ -263,49 +263,111 @@ class Twilio::VoiceController < ActionController::Base
from_number = params['From']
to_number = params['To']
direction = params['Direction']
# Check if we have an explicit conference_name parameter - for outbound calls
# This is passed directly from the channel for outbound calls
conference_name_param = params['conference_name']
# Determine if outbound call
is_outbound = direction == 'outbound-api'
response = Twilio::TwiML::VoiceResponse.new
# The signup follow-up message
response.say(message: 'Hello from Chatwoot. This is a courtesy call to check on your recent signup. We would love to hear any feedback or questions you might have about your experience so far. Please share your thoughts after the beep.')
response.pause(length: 1) # give a moment before the beep and recording
# Record their feedback after a beep, then let handle_recording hang up
response.record(
action: '/twilio/voice/handle_recording',
method: 'POST',
maxLength: 3600,
timeout: 30,
playBeep: true
)
# End the call
response.hangup
# If we have call details, log them for the conversation
# If we have call details, log them and create a conference
if call_sid.present?
# Find the inbox for this voice call
inbox_number = is_outbound ? from_number : to_number
inbox = find_inbox(inbox_number)
if inbox.present?
# Create or find conversation
# Find or create conversation
contact_number = is_outbound ? to_number : from_number
conversation = find_or_create_conversation(inbox, contact_number, call_sid)
# Add call activity message
track_call_activity(conversation, 'in-progress', true, is_outbound)
# IMPORTANT: Use the provided conference_name if available, otherwise create one
account_id = inbox.account_id
if conference_name_param.present?
# Use the provided conference name
conference_name = conference_name_param
Rails.logger.info("🚨 USING PROVIDED CONFERENCE NAME: '#{conference_name}'")
else
# Create a new conference name
conference_name = "conf_account_#{account_id}_conv_#{conversation.display_id}"
Rails.logger.info("🚨 CREATED NEW CONFERENCE NAME: '#{conference_name}'")
end
# Store the conference name in the conversation for the agent to join
conversation.additional_attributes ||= {}
conversation.additional_attributes['conference_sid'] = conference_name
conversation.additional_attributes['call_direction'] = 'outbound'
conversation.additional_attributes['requires_agent_join'] = true
# Log this critical information
Rails.logger.info("🚨🚨🚨 OUTBOUND CALL: Setting conference_sid=#{conference_name} and requires_agent_join=true")
# Save the conversation
conversation.save!
# Log the conference creation
Rails.logger.info("🎧🎧🎧 OUTBOUND CALL: Created conference: #{conference_name} for account: #{account_id}, conversation: #{conversation.display_id}")
# Generate TwiML that connects the caller to a conference
response = Twilio::TwiML::VoiceResponse.new
# Simple greeting
response.say(message: 'Please wait while we connect you to an agent')
# Connect to conference - CRITICAL: Make parameters match the agent side in voice_controller.rb
response.dial do |dial|
dial.conference(
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
earlyMedia: true, # Enable early media for faster connection - ADDED THIS PARAMETER
statusCallback: "#{base_url}/api/v1/accounts/#{account_id}/channels/voice/webhooks/conference_status",
statusCallbackMethod: 'POST',
statusCallbackEvent: 'start end join leave',
participantLabel: "caller-#{call_sid.last(8)}"
)
end
render xml: response.to_s, status: :ok
return
end
end
# Fallback to simple TwiML if we couldn't set up a conference
response = Twilio::TwiML::VoiceResponse.new
response.say(message: 'Hello from Chatwoot. This is a courtesy call to check on your recent signup.')
response.pause(length: 1)
response.say(message: 'We will connect you with an agent shortly.')
response.hangup
render xml: response.to_s, status: :ok
end
private
# Helper method to get base URL with extra resilience
def base_url
# Use FRONTEND_URL env variable as the most reliable source for outbound calls
frontend_url = ENV.fetch('FRONTEND_URL', nil)
return frontend_url.chomp('/') if frontend_url.present?
# Fallback to request.base_url if available
if defined?(request) && request&.respond_to?(:base_url) && request.base_url.present?
return request.base_url
end
# Last resort fallback
'http://localhost:3000'
end
def find_inbox(phone_number)
Inbox.joins('INNER JOIN channel_voice ON channel_voice.account_id = inboxes.account_id AND inboxes.channel_id = channel_voice.id')
.where('channel_voice.phone_number = ?', phone_number)
@@ -317,7 +379,19 @@ class Twilio::VoiceController < ActionController::Base
# Reuse if existing conversation for this call SID
existing = account.conversations.where("additional_attributes->>'call_sid' = ?", call_sid).first
return existing if existing
# If we found an existing conversation, check if it has a conference_sid
if existing
# For outbound calls, we need to ensure it has a conference_sid
if existing.additional_attributes['conference_sid'].blank?
# Create a conference name in the same format as inbound calls
conference_name = "conf_account_#{account.id}_conv_#{existing.display_id}"
existing.additional_attributes['conference_sid'] = conference_name
existing.save!
Rails.logger.info("🎧🎧🎧 ADDED CONFERENCE_SID to existing conversation: #{conference_name}")
end
return existing
end
# Ensure contact and inbox
contact = account.contacts.find_or_create_by(phone_number: phone_number) do |c|
@@ -329,8 +403,18 @@ class Twilio::VoiceController < ActionController::Base
# Create new conversation for this call
convo = account.conversations.create!(contact_inbox_id: contact_inbox.id, inbox_id: inbox.id, status: :open)
convo.additional_attributes = { 'call_sid' => call_sid, 'call_status' => 'in-progress' }
# Create a conference name using the same format for consistency
conference_name = "conf_account_#{account.id}_conv_#{convo.display_id}"
convo.additional_attributes = {
'call_sid' => call_sid,
'call_status' => 'in-progress',
'conference_sid' => conference_name
}
convo.save!
Rails.logger.info("🎧🎧🎧 Created new conversation with conference_sid: #{conference_name}")
convo
end
+6
View File
@@ -100,6 +100,8 @@ export default {
if (newVal) {
console.log('Incoming call data:', this.incomingCall);
this.showCallWidget = true;
} else {
this.showCallWidget = false;
}
}
},
@@ -110,6 +112,8 @@ export default {
if (newVal) {
console.log('Active call data:', this.activeCall);
this.showCallWidget = true;
} else {
this.showCallWidget = false;
}
}
},
@@ -244,6 +248,8 @@ export default {
:conversation-id="activeCall ? activeCall.conversationId : (incomingCall ? incomingCall.conversationId : null)"
:contact-name="activeCall ? activeCall.contactName : (incomingCall ? incomingCall.contactName : '')"
:contact-id="activeCall ? activeCall.contactId : (incomingCall ? incomingCall.contactId : null)"
:inbox-id="activeCall ? activeCall.inboxId : (incomingCall ? incomingCall.inboxId : null)"
:use-web-rtc="true"
@callEnded="handleCallEnded"
@callJoined="handleCallJoined"
@callRejected="handleCallRejected"
+865 -3
View File
@@ -5,6 +5,11 @@ class VoiceAPI extends ApiClient {
constructor() {
// Use 'voice' as the resource with accountScoped: true
super('voice', { accountScoped: true });
// Client-side Twilio device
this.device = null;
this.activeConnection = null;
this.initialized = false;
}
// Initiate a call to a contact
@@ -54,7 +59,13 @@ class VoiceAPI extends ApiClient {
}
// Join an incoming call as an agent (join the conference)
joinCall(callSid, conversationId) {
// This is used for the WebRTC client-side setup, not for phone calls anymore
joinCall(params) {
// Check if we have individual parameters or a params object
const conversationId = params.conversation_id || params.conversationId;
const callSid = params.call_sid || params.callSid;
const accountId = params.account_id;
if (!conversationId) {
throw new Error('Conversation ID is required to join a call');
}
@@ -63,10 +74,20 @@ class VoiceAPI extends ApiClient {
throw new Error('Call SID is required to join a call');
}
return axios.post(`${this.url}/join_call`, {
// Build request payload with proper naming convention
const payload = {
call_sid: callSid,
conversation_id: conversationId,
});
};
// Add account_id if provided
if (accountId) {
payload.account_id = accountId;
}
console.log('Calling join_call API endpoint with payload:', payload);
return axios.post(`${this.url}/join_call`, payload);
}
// Reject an incoming call as an agent (don't join the conference)
@@ -84,6 +105,847 @@ class VoiceAPI extends ApiClient {
conversation_id: conversationId,
});
}
// Client SDK methods
// Get a capability token for the Twilio Client
getToken(inboxId) {
console.log(`Requesting token for inbox ID: ${inboxId} at URL: ${this.url}/tokens`);
// Log the base URL for debugging
console.log(`Base URL: ${this.baseUrl()}`);
// Check if inboxId is valid
if (!inboxId) {
console.error('No inbox ID provided for token request');
return Promise.reject(new Error('Inbox ID is required'));
}
// Add more request details to help debugging
return axios.post(`${this.url}/tokens`, { inbox_id: inboxId }, {
headers: { 'Content-Type': 'application/json' },
}).catch(error => {
// Extract useful error details for debugging
const errorInfo = {
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
url: `${this.url}/tokens`,
inboxId,
};
console.error('Token request error details:', errorInfo);
// Try to extract a more useful error message from the HTML response if it's a 500 error
if (error.response?.status === 500 && typeof error.response.data === 'string') {
// Look for specific error patterns in the HTML
const htmlData = error.response.data;
// Check for common Ruby/Rails error patterns
const nameMatchResult = htmlData.match(/<h2>(.*?)<\/h2>/);
const detailsMatchResult = htmlData.match(/<pre>([\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();
File diff suppressed because it is too large Load Diff
@@ -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
@@ -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
@@ -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 {
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.apiKeySid.$error }">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL', 'API Key SID') }}
<input
v-model.trim="apiKeySid"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER', 'Enter your Twilio API Key SID')
"
@blur="v$.apiKeySid.$touch"
/>
<span v-if="v$.apiKeySid.$error" class="message">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.REQUIRED', 'API Key SID is required') }}
</span>
<span class="help-text">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.HELP', 'You can create API keys in the Twilio Console') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.apiKeySecret.$error }">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL', 'API Key Secret') }}
<input
v-model.trim="apiKeySecret"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER', 'Enter your Twilio API Key Secret')
"
@blur="v$.apiKeySecret.$touch"
/>
<span v-if="v$.apiKeySecret.$error" class="message">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.REQUIRED', 'API Key Secret is required') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label>
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.LABEL', 'TwiML App SID (Recommended)') }}
<input
v-model.trim="twimlAppSid"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.PLACEHOLDER', 'Enter your Twilio TwiML App SID (starts with AP)')
"
/>
<span class="help-text">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.HELP', 'Required for browser-based calling. Create a TwiML App in the Twilio Console with Voice URLs pointing to your Chatwoot instance.') }}
</span>
</label>
</div>
</div>
<!-- Add other provider configs here -->
@@ -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,
+146
View File
@@ -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
+17 -7
View File
@@ -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
+37 -16
View File
@@ -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'
+1
View File
@@ -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",
+51
View File
@@ -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: