chore: inbound and outbound calls that work
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+51
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user