chore: fixes
This commit is contained in:
@@ -65,7 +65,7 @@ class VoiceAPI extends ApiClient {
|
||||
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');
|
||||
}
|
||||
@@ -79,12 +79,12 @@ class VoiceAPI extends ApiClient {
|
||||
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);
|
||||
@@ -286,14 +286,24 @@ class VoiceAPI extends ApiClient {
|
||||
throw new Error('Voice is not enabled for this inbox. Check your Twilio configuration.');
|
||||
}
|
||||
|
||||
// Step 2: Create Twilio Device
|
||||
// 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
|
||||
// The prebuilt hold music usually interrupts the actual call
|
||||
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);
|
||||
@@ -467,19 +477,6 @@ class VoiceAPI extends ApiClient {
|
||||
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.');
|
||||
@@ -703,165 +700,73 @@ class VoiceAPI extends ApiClient {
|
||||
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}');
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// 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(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();
|
||||
} catch (listenerError) {
|
||||
// Could not add listeners to Promise connection
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}).catch(connError => {
|
||||
// WebRTC Promise connection error
|
||||
});
|
||||
} else {
|
||||
// It's a synchronous connection - older Twilio SDK
|
||||
}
|
||||
return connection;
|
||||
} catch (error) {
|
||||
// Error joining conference
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1044,6 +1044,7 @@ export default {
|
||||
|
||||
// Join a call using the Twilio Client - this is the only option for agents now
|
||||
const joinCallWithWebRTC = async () => {
|
||||
// This is the critical method where an agent joins an incoming call
|
||||
try {
|
||||
// 1. Ensure Twilio device is initialized
|
||||
if (!isWebRTCInitialized.value) {
|
||||
@@ -1075,7 +1076,7 @@ export default {
|
||||
};
|
||||
let accountId = extractAccountId();
|
||||
|
||||
// --- Step 4: Inform server agent is joining (non-blocking, but store response) ---
|
||||
// --- Step 4: Inform server agent is joining and get conference_sid ---
|
||||
let serverResponse = null;
|
||||
try {
|
||||
const response = await VoiceAPI.joinCall({
|
||||
@@ -1084,41 +1085,49 @@ export default {
|
||||
account_id: accountId,
|
||||
});
|
||||
serverResponse = response.data;
|
||||
|
||||
// Process the server response to get the conference_sid
|
||||
if (serverResponse && serverResponse.conference_sid) {
|
||||
// Save the conference_sid in the incomingCall data
|
||||
if (!incomingCall.value.conference_sid) {
|
||||
// Save the conference_sid in only the key places needed
|
||||
incomingCall.value.conference_sid = serverResponse.conference_sid;
|
||||
|
||||
// Also set the 'To' parameter required by Twilio
|
||||
incomingCall.value.To = serverResponse.conference_sid;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} catch (apiError) {
|
||||
// Continue anyway, as we might still be able to join the conference
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5. Proactively fix audio issues
|
||||
await fixAudioBeforeCall();
|
||||
|
||||
// --- Conference ID extraction helper ---
|
||||
// Simple conference ID extraction - using ONE source of truth
|
||||
const extractConferenceId = () => {
|
||||
// Priority: incomingCall.conference_sid > serverResponse.conference_sid > alt server fields > generated
|
||||
let confId = incomingCall.value?.conference_sid;
|
||||
if (!confId && serverResponse) {
|
||||
confId =
|
||||
serverResponse.conference_sid ||
|
||||
serverResponse.conferenceId ||
|
||||
serverResponse.conference_name;
|
||||
}
|
||||
if (!confId && incomingCall.value) {
|
||||
const isOutbound = incomingCall.value.isOutbound === true;
|
||||
if (isOutbound && incomingCall.value.conference_sid) {
|
||||
confId = incomingCall.value.conference_sid;
|
||||
}
|
||||
if (!confId && accountId && conversationId) {
|
||||
confId = `conf_account_${accountId}_conv_${conversationId}`;
|
||||
}
|
||||
// Get conference_sid from incoming call data - this is the SINGLE source of truth
|
||||
const confId = incomingCall.value?.conference_sid;
|
||||
|
||||
if (!confId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return confId;
|
||||
};
|
||||
const conferenceId = extractConferenceId();
|
||||
if (!conferenceId) return false;
|
||||
|
||||
// --- Twilio requires 'To' (capital T) and lowercase account_id ---
|
||||
// Ensure conferenceId is a string
|
||||
const conferenceIdString = String(conferenceId);
|
||||
|
||||
// Simple params object with the required fields in the correct format
|
||||
const enhancedParams = {
|
||||
To: conferenceId,
|
||||
To: conferenceIdString, // CAPITAL T is required for Twilio and MUST be a string
|
||||
account_id: accountId,
|
||||
is_agent: 'true' // Flag that this is an agent joining
|
||||
};
|
||||
|
||||
// 6. Re-initialize device if needed
|
||||
@@ -1142,7 +1151,7 @@ export default {
|
||||
if (window.activeAudioStream) {
|
||||
window.activeAudioStream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
|
||||
// Request a new stream with HIGH-QUALITY audio
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
@@ -1155,15 +1164,15 @@ export default {
|
||||
sampleSize: { ideal: 16 }
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Save the stream globally
|
||||
window.activeAudioStream = stream;
|
||||
|
||||
|
||||
// Ensure tracks are active and enabled
|
||||
stream.getAudioTracks().forEach(track => {
|
||||
track.enabled = true;
|
||||
});
|
||||
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Fall back to basic audio to ensure we at least have something
|
||||
|
||||
@@ -203,17 +203,20 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
conversationId: data.conversation_id,
|
||||
inboxId: data.inbox_id,
|
||||
inboxName: data.inbox_name,
|
||||
inboxAvatarUrl: data.inbox_avatar_url, // Inbox avatar URL
|
||||
inboxPhoneNumber: data.inbox_phone_number, // Inbox phone number
|
||||
contactName: data.contact_name || 'Unknown Caller', // Add fallback name
|
||||
inboxAvatarUrl: data.inbox_avatar_url,
|
||||
inboxPhoneNumber: data.inbox_phone_number,
|
||||
contactName: data.contact_name || 'Unknown Caller',
|
||||
contactId: data.contact_id,
|
||||
accountId: data.account_id,
|
||||
isOutbound: data.is_outbound || false, // 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
|
||||
phoneNumber: data.phone_number, // Include phone number for display in the UI
|
||||
avatarUrl: data.avatar_url // Include avatar URL for display in the UI
|
||||
isOutbound: data.is_outbound || false,
|
||||
// CRITICAL: Use 'conference_sid' in camelCase format to match field names
|
||||
conference_sid: data.conference_sid,
|
||||
conferenceId: data.conference_sid, // Add aliases for consistency
|
||||
conferenceSid: data.conference_sid, // Add aliases for consistency
|
||||
requiresAgentJoin: data.requires_agent_join || false,
|
||||
callDirection: data.call_direction,
|
||||
phoneNumber: data.phone_number,
|
||||
avatarUrl: data.avatar_url
|
||||
};
|
||||
|
||||
// Update store
|
||||
@@ -234,37 +237,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
inboxId: data.inbox_id,
|
||||
timestamp: data.timestamp || Date.now()
|
||||
};
|
||||
|
||||
// Update store with call status change
|
||||
// Only dispatch to Vuex; Vuex handles widget and call state
|
||||
this.app.$store.dispatch('calls/handleCallStatusChanged', normalizedPayload);
|
||||
|
||||
// For terminal statuses, clear the active call to close the widget
|
||||
if (['ended', 'missed', 'completed', 'failed', 'busy', 'no_answer'].includes(data.status)) {
|
||||
// Clear active call for terminal statuses
|
||||
this.app.$store.dispatch('calls/clearActiveCall');
|
||||
|
||||
// Ensure window.app.$data exists before modifying it
|
||||
if (window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
|
||||
// Update conversation list to show current status
|
||||
if (data.conversation_id) {
|
||||
this.app.$store.dispatch('updateConversationLastActivity', {
|
||||
conversationId: data.conversation_id,
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Also ensure that the conversation gets refreshed
|
||||
this.app.$store.dispatch('fetchConversation', {
|
||||
id: data.conversation_id
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Update active call for non-terminal statuses
|
||||
this.app.$store.dispatch('calls/setActiveCall', normalizedPayload);
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -12,36 +12,41 @@ const getters = {
|
||||
|
||||
const actions = {
|
||||
// This action will handle both message updates and direct call status changes
|
||||
/**
|
||||
* Handles all call status changes from ActionCable.
|
||||
* Closes the widget and clears state for terminal statuses.
|
||||
* Only this action should manipulate widget visibility for call end.
|
||||
*/
|
||||
handleCallStatusChanged({ state, dispatch }, { callSid, status }) {
|
||||
// Check if this is the active call
|
||||
// Debug logging for conference call widget close issue
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[CALL DEBUG] handleCallStatusChanged invoked', { callSid, status, activeCall: state.activeCall });
|
||||
const isActiveCall = callSid === state.activeCall?.callSid;
|
||||
const isOutboundCall = state.activeCall?.isOutbound === true;
|
||||
|
||||
// If this is the active call and it has ended or was missed, close the widget
|
||||
if (isActiveCall &&
|
||||
(status === 'ended' || status === 'missed' || status === 'completed')) {
|
||||
console.log('Call status changed to:', status, 'isOutbound:', isOutboundCall);
|
||||
|
||||
// Clear the active call
|
||||
const terminalStatuses = [
|
||||
'ended',
|
||||
'missed',
|
||||
'completed',
|
||||
'failed',
|
||||
'busy',
|
||||
'no_answer',
|
||||
];
|
||||
if (isActiveCall && terminalStatuses.includes(status)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[CALL DEBUG] Terminal status match. Closing widget.', { callSid, status, activeCall: state.activeCall });
|
||||
// Clean up active call state
|
||||
dispatch('clearActiveCall');
|
||||
|
||||
// Force update app state to hide widget
|
||||
// Hide floating widget reactively
|
||||
if (window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
|
||||
// Emit event to notify components
|
||||
// Emit event for any listeners
|
||||
if (window.app) {
|
||||
window.app.$emit('callEnded');
|
||||
}
|
||||
|
||||
// For outbound calls, also clear any pending state
|
||||
if (isOutboundCall) {
|
||||
// Additional cleanup for outbound calls
|
||||
if (window.globalCallStatus) {
|
||||
window.globalCallStatus.active = false;
|
||||
window.globalCallStatus.incoming = false;
|
||||
}
|
||||
// Outbound call cleanup
|
||||
if (state.activeCall?.isOutbound && window.globalCallStatus) {
|
||||
window.globalCallStatus.active = false;
|
||||
window.globalCallStatus.incoming = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user