mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-03 20:48:07 +00:00
995 lines
33 KiB
JavaScript
995 lines
33 KiB
JavaScript
/* global axios */
|
|
import ApiClient from '../ApiClient';
|
|
|
|
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
|
|
initiateCall(contactId) {
|
|
if (!contactId) {
|
|
throw new Error('Contact ID is required to initiate a call');
|
|
}
|
|
|
|
// Based on the route definition, the correct URL path is /api/v1/accounts/{accountId}/contacts/{contactId}/call
|
|
// The endpoint is defined in the contacts namespace, not voice namespace
|
|
return axios.post(
|
|
`${this.baseUrl().replace('/voice', '')}/contacts/${contactId}/call`
|
|
);
|
|
}
|
|
|
|
// End an active call
|
|
endCall(callSid, conversationId) {
|
|
if (!conversationId) {
|
|
throw new Error('Conversation ID is required to end a call');
|
|
}
|
|
|
|
if (!callSid) {
|
|
throw new Error('Call SID is required to end a call');
|
|
}
|
|
|
|
// Validate call SID format - Twilio call SID starts with 'CA' or 'TJ'
|
|
if (!callSid.startsWith('CA') && !callSid.startsWith('TJ')) {
|
|
throw new Error(
|
|
'Invalid call SID format. Expected Twilio call SID starting with CA or TJ.'
|
|
);
|
|
}
|
|
|
|
return axios.post(`${this.url}/end_call`, {
|
|
call_sid: callSid,
|
|
conversation_id: conversationId,
|
|
id: conversationId,
|
|
});
|
|
}
|
|
|
|
// End the client-side WebRTC call connection
|
|
endClientCall() {
|
|
try {
|
|
if (this.activeConnection) {
|
|
console.log('📞 Ending client WebRTC call connection');
|
|
this.activeConnection.disconnect();
|
|
this.activeConnection = null;
|
|
}
|
|
|
|
if (this.device && this.device.state === 'busy') {
|
|
console.log('📞 Disconnecting all device connections');
|
|
this.device.disconnectAll();
|
|
}
|
|
} catch (error) {
|
|
console.warn('⚠️ Error ending client call:', error);
|
|
// Clear the connection reference even if disconnect failed
|
|
this.activeConnection = null;
|
|
}
|
|
}
|
|
|
|
// Get call status
|
|
getCallStatus(callSid) {
|
|
if (!callSid) {
|
|
throw new Error('Call SID is required to get call status');
|
|
}
|
|
|
|
return axios.get(`${this.url}/call_status`, {
|
|
params: { call_sid: callSid },
|
|
});
|
|
}
|
|
|
|
// Join an incoming call as an agent (join the conference)
|
|
// 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');
|
|
}
|
|
|
|
if (!callSid) {
|
|
throw new Error('Call SID is required to join a 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)
|
|
rejectCall(callSid, conversationId) {
|
|
if (!conversationId) {
|
|
throw new Error('Conversation ID is required to reject a call');
|
|
}
|
|
|
|
if (!callSid) {
|
|
throw new Error('Call SID is required to reject a call');
|
|
}
|
|
|
|
return axios.post(`${this.url}/reject_call`, {
|
|
call_sid: callSid,
|
|
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.'
|
|
);
|
|
}
|
|
|
|
// Store the TwiML endpoint URL for later use
|
|
this.twimlEndpoint = response.data.twiml_endpoint;
|
|
|
|
// Step 2: Create Twilio Device with better options
|
|
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
|
|
disableAudioContextSounds: true, // Disable browser audio context for sounds
|
|
// Add explicit edge parameter - this helps avoid connectivity issues
|
|
edge: ['ashburn', 'sydney', 'roaming'],
|
|
// Explicitly set codec preferences
|
|
codecPreferences: ['opus', 'pcmu'],
|
|
// Add the account ID to any calls made by this device
|
|
appParams: {
|
|
account_id: response.data.account_id,
|
|
},
|
|
};
|
|
|
|
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',
|
|
});
|
|
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');
|
|
}
|
|
|
|
try {
|
|
// IMPORTANT: Do NOT try to register if already registered
|
|
// Only check state is ready
|
|
if (this.device.state !== 'ready' && this.device.state !== 'registered') {
|
|
// Don't try to register again if already registered
|
|
}
|
|
|
|
// This is CRITICAL for Twilio - params must be formatted exactly right
|
|
// and passed directly in the format Twilio expects
|
|
const params = {
|
|
// REQUIRED: Twilio Voice JS SDK expects 'To' parameter to be a properly formatted string
|
|
To: `${conferenceParams.To}`,
|
|
|
|
// Additional params for our server
|
|
account_id: conferenceParams.account_id,
|
|
is_agent: 'true',
|
|
};
|
|
|
|
// Check To parameter exists - fail if missing
|
|
if (!params.To) {
|
|
throw new Error('Missing To parameter for conference');
|
|
}
|
|
|
|
// Make sure 'To' is explicitly a string
|
|
const stringifiedTo = String(params.To);
|
|
console.log(
|
|
'🎯 CRITICAL CONFERENCE CONNECTION: Connecting agent to conference with To=',
|
|
stringifiedTo
|
|
);
|
|
|
|
// Follow Twilio documentation format - params should be nested under 'params' property
|
|
console.log(
|
|
'🎯 TRYING CONNECTION: Using documented format with params property'
|
|
);
|
|
|
|
// Just use the minimal required parameters
|
|
const connection = this.device.connect({
|
|
params: {
|
|
To: stringifiedTo, // Conference ID
|
|
is_agent: 'true', // Flag to indicate agent is joining
|
|
},
|
|
});
|
|
|
|
console.log(
|
|
'🎯 CONFERENCE CONNECTION RESULT:',
|
|
connection ? 'Success' : 'Failed'
|
|
);
|
|
this.activeConnection = connection;
|
|
|
|
if (connection && typeof connection.then === 'function') {
|
|
// It's a Promise - newer Twilio SDK version
|
|
connection
|
|
.then(resolvedConnection => {
|
|
this.activeConnection = resolvedConnection;
|
|
try {
|
|
if (typeof resolvedConnection.on === 'function') {
|
|
resolvedConnection.on('accept', () => {
|
|
// Connection accepted
|
|
});
|
|
}
|
|
} catch (listenerError) {
|
|
// Could not add listeners to Promise connection
|
|
}
|
|
})
|
|
.catch(connError => {
|
|
// WebRTC Promise connection error
|
|
});
|
|
} else {
|
|
// It's a synchronous connection - older Twilio SDK
|
|
}
|
|
return connection;
|
|
} catch (error) {
|
|
// Error joining conference
|
|
}
|
|
}
|
|
|
|
// 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();
|