Add twilio softphone with integrated AI assistant

This commit is contained in:
Francisco Gaona
2026-01-04 08:48:43 +01:00
parent 16907aadf8
commit 5f3fcef1ec
42 changed files with 5689 additions and 923 deletions

View File

@@ -0,0 +1,629 @@
import { ref, computed, onMounted, onUnmounted, shallowRef } from 'vue';
import { io, Socket } from 'socket.io-client';
import { Device, Call as TwilioCall } from '@twilio/voice-sdk';
import { useAuth } from './useAuth';
import { toast } from 'vue-sonner';
interface Call {
callSid: string;
direction: 'inbound' | 'outbound';
fromNumber: string;
toNumber: string;
status: string;
startedAt?: string;
duration?: number;
}
interface CallTranscript {
text: string;
isFinal: boolean;
timestamp: number;
}
interface AiSuggestion {
type: 'response' | 'action' | 'insight';
text: string;
data?: any;
}
// Module-level shared state for global access
const socket = ref<Socket | null>(null);
const twilioDevice = shallowRef<Device | null>(null);
const twilioCall = shallowRef<TwilioCall | null>(null);
const isConnected = ref(false);
const isOpen = ref(false);
const currentCall = ref<Call | null>(null);
const incomingCall = ref<Call | null>(null);
const transcript = ref<CallTranscript[]>([]);
const aiSuggestions = ref<AiSuggestion[]>([]);
const callHistory = ref<Call[]>([]);
const isInitialized = ref(false);
const isMuted = ref(false);
const volume = ref(100);
export function useSoftphone() {
const auth = useAuth();
// Get token and tenantId from localStorage
const getToken = () => {
if (typeof window === 'undefined') return null;
return localStorage.getItem('token');
};
const getTenantId = () => {
if (typeof window === 'undefined') return null;
return localStorage.getItem('tenantId');
};
// Computed properties
const isInCall = computed(() => currentCall.value !== null);
const hasIncomingCall = computed(() => incomingCall.value !== null);
const callStatus = computed(() => currentCall.value?.status || 'idle');
/**
* Request microphone permission explicitly
*/
const requestMicrophonePermission = async () => {
try {
// Check if mediaDevices is supported
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
toast.error('Microphone access requires HTTPS. Please access the app via https:// or use localhost for testing.');
console.error('navigator.mediaDevices not available. This typically means the page is not served over HTTPS.');
return false;
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Stop the stream immediately, we just wanted the permission
stream.getTracks().forEach(track => track.stop());
return true;
} catch (error: any) {
console.error('Microphone permission denied:', error);
if (error.name === 'NotAllowedError') {
toast.error('Microphone access denied. Please allow microphone access in your browser settings.');
} else if (error.name === 'NotFoundError') {
toast.error('No microphone found. Please connect a microphone and try again.');
} else {
toast.error('Microphone access is required for calls. Please ensure you are using HTTPS or localhost.');
}
return false;
}
};
/**
* Initialize Twilio Device
*/
const initializeTwilioDevice = async () => {
try {
// First, explicitly request microphone permission
const hasPermission = await requestMicrophonePermission();
if (!hasPermission) {
return;
}
const { api } = useApi();
console.log('Requesting Twilio token from /api/voice/token...');
const response = await api.get('/voice/token');
const token = response.data.token;
console.log('Token received, creating Device...');
// Log the token payload to see what identity is being used
try {
const tokenPayload = JSON.parse(atob(token.split('.')[1]));
console.log('Token identity:', tokenPayload.sub);
console.log('Token grants:', tokenPayload.grants);
} catch (e) {
console.log('Could not parse token payload');
}
twilioDevice.value = new Device(token, {
logLevel: 1,
codecPreferences: ['opus', 'pcmu'],
enableImprovedSignalingErrorPrecision: true,
edge: 'ashburn',
});
// Device events
twilioDevice.value.on('registered', () => {
console.log('✓ Twilio Device registered - ready to receive calls');
toast.success('Softphone ready');
});
twilioDevice.value.on('unregistered', () => {
console.log('⚠ Twilio Device unregistered');
});
twilioDevice.value.on('error', (error) => {
console.error('❌ Twilio Device error:', error);
toast.error('Device error: ' + error.message);
});
twilioDevice.value.on('incoming', (call: TwilioCall) => {
console.log('🔔 Twilio Device INCOMING event received:', call.parameters);
console.log('Call parameters:', {
CallSid: call.parameters.CallSid,
From: call.parameters.From,
To: call.parameters.To,
});
twilioCall.value = call;
// Update state
incomingCall.value = {
callSid: call.parameters.CallSid || '',
direction: 'inbound',
fromNumber: call.parameters.From || '',
toNumber: call.parameters.To || '',
status: 'ringing',
};
// Open softphone dialog
isOpen.value = true;
// Show notification
toast.info(`Incoming call from ${incomingCall.value.fromNumber}`, {
duration: 30000,
});
// Setup call handlers
setupCallHandlers(call);
// Play ringtone
playRingtone();
});
// Register the device
console.log('Registering Twilio Device...');
await twilioDevice.value.register();
console.log('✓ Twilio Device register() completed');
console.log('Device identity:', twilioDevice.value.identity);
console.log('Device state:', twilioDevice.value.state);
} catch (error: any) {
console.error('Failed to initialize Twilio Device:', error);
toast.error('Failed to initialize voice device: ' + error.message);
}
};
/**
* Setup handlers for a Twilio call
*/
const setupCallHandlers = (call: TwilioCall) => {
call.on('accept', () => {
console.log('Call accepted');
currentCall.value = {
callSid: call.parameters.CallSid || '',
direction: twilioCall.value === call ? 'inbound' : 'outbound',
fromNumber: call.parameters.From || '',
toNumber: call.parameters.To || '',
status: 'in-progress',
startedAt: new Date().toISOString(),
};
incomingCall.value = null;
});
call.on('disconnect', () => {
console.log('Call disconnected');
currentCall.value = null;
twilioCall.value = null;
});
call.on('cancel', () => {
console.log('Call cancelled');
incomingCall.value = null;
twilioCall.value = null;
});
call.on('reject', () => {
console.log('Call rejected');
incomingCall.value = null;
twilioCall.value = null;
});
call.on('error', (error) => {
console.error('Call error:', error);
toast.error('Call error: ' + error.message);
});
};
/**
* Initialize WebSocket connection
*/
const connect = () => {
const token = getToken();
if (socket.value?.connected || !token) {
return;
}
// Use same pattern as useApi to preserve subdomain for multi-tenant
const getBackendUrl = () => {
if (typeof window !== 'undefined') {
const currentHost = window.location.hostname;
const protocol = window.location.protocol;
return `${protocol}//${currentHost}`;
}
return 'http://localhost:3000';
};
// Connect to /voice namespace
socket.value = io(`${getBackendUrl()}/voice`, {
auth: {
token: token,
},
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: 5,
});
// Connection events
socket.value.on('connect', () => {
console.log('🔌 Softphone WebSocket connected');
console.log('📋 Token payload (check userId):', parseJwt(token));
isConnected.value = true;
// Initialize Twilio Device after WebSocket connects
initializeTwilioDevice();
});
socket.value.on('disconnect', () => {
console.log('Softphone WebSocket disconnected');
isConnected.value = false;
});
socket.value.on('connect_error', (error) => {
console.error('Softphone connection error:', error);
toast.error('Failed to connect to voice service');
});
// Call events
socket.value.on('call:incoming', handleIncomingCall);
socket.value.on('call:initiated', handleCallInitiated);
socket.value.on('call:accepted', handleCallAccepted);
socket.value.on('call:rejected', handleCallRejected);
socket.value.on('call:ended', handleCallEnded);
socket.value.on('call:update', handleCallUpdate);
socket.value.on('call:error', handleCallError);
socket.value.on('call:state', handleCallState);
// AI events
socket.value.on('ai:transcript', handleAiTranscript);
socket.value.on('ai:suggestion', (data: any) => {
console.log('🎯 AI Suggestion received:', data.text);
handleAiSuggestion(data);
});
socket.value.on('ai:action', handleAiAction);
isInitialized.value = true;
};
/**
* Disconnect WebSocket
*/
const disconnect = () => {
if (socket.value) {
socket.value.disconnect();
socket.value = null;
isConnected.value = false;
isInitialized.value = false;
}
};
/**
* Open softphone dialog
*/
const open = () => {
if (!isInitialized.value) {
connect();
}
isOpen.value = true;
};
/**
* Close softphone dialog
*/
const close = () => {
isOpen.value = false;
};
/**
* Initiate outbound call using Twilio Device
*/
const initiateCall = async (toNumber: string) => {
if (!twilioDevice.value) {
toast.error('Voice device not initialized');
return;
}
try {
// Make call using Twilio Device
const call = await twilioDevice.value.connect({
params: {
To: toNumber,
}
});
twilioCall.value = call;
setupCallHandlers(call);
toast.success('Calling ' + toNumber);
} catch (error: any) {
console.error('Failed to initiate call:', error);
toast.error('Failed to initiate call: ' + error.message);
throw error;
}
};
/**
* Accept incoming call
*/
const acceptCall = async (callSid: string) => {
console.log('📞 Accepting call - callSid:', callSid);
console.log('twilioCall.value:', twilioCall.value);
if (!twilioCall.value) {
console.error('❌ No incoming call to accept - twilioCall.value is null');
toast.error('No incoming call');
return;
}
try {
console.log('Calling twilioCall.value.accept()...');
await twilioCall.value.accept();
console.log('✓ Call accepted successfully');
toast.success('Call accepted');
} catch (error: any) {
console.error('❌ Failed to accept call:', error);
toast.error('Failed to accept call: ' + error.message);
}
};
/**
* Reject incoming call
*/
const rejectCall = async (callSid: string) => {
if (!twilioCall.value) {
toast.error('No incoming call');
return;
}
try {
twilioCall.value.reject();
incomingCall.value = null;
twilioCall.value = null;
toast.info('Call rejected');
} catch (error: any) {
console.error('Failed to reject call:', error);
toast.error('Failed to reject call: ' + error.message);
}
};
/**
* End active call
*/
const endCall = async (callSid: string) => {
if (!twilioCall.value) {
toast.error('No active call');
return;
}
try {
twilioCall.value.disconnect();
currentCall.value = null;
twilioCall.value = null;
toast.info('Call ended');
} catch (error: any) {
console.error('Failed to end call:', error);
toast.error('Failed to end call: ' + error.message);
}
};
/**
* Toggle mute
*/
const toggleMute = () => {
if (!twilioCall.value) return;
isMuted.value = !isMuted.value;
twilioCall.value.mute(isMuted.value);
};
/**
* Send DTMF tone
*/
const sendDtmf = async (callSid: string, digit: string) => {
if (!twilioCall.value) {
return;
}
twilioCall.value.sendDigits(digit);
};
// Event handlers
const handleIncomingCall = (data: Call) => {
// Socket.IO notification that a call is coming
// The actual call object will come from Twilio Device SDK's 'incoming' event
console.log('Socket.IO call notification:', data);
// Don't set incomingCall here - wait for the Device SDK incoming event
};
const handleCallInitiated = (data: any) => {
console.log('Call initiated:', data);
currentCall.value = {
callSid: data.callSid,
direction: 'outbound',
fromNumber: '',
toNumber: data.toNumber,
status: data.status,
};
transcript.value = [];
aiSuggestions.value = [];
};
const handleCallAccepted = (data: any) => {
console.log('Call accepted:', data);
if (incomingCall.value?.callSid === data.callSid) {
currentCall.value = incomingCall.value;
if (currentCall.value) {
currentCall.value.status = 'in-progress';
}
incomingCall.value = null;
}
stopRingtone();
};
const handleCallRejected = (data: any) => {
console.log('Call rejected:', data);
if (incomingCall.value?.callSid === data.callSid) {
incomingCall.value = null;
}
stopRingtone();
};
const handleCallEnded = (data: any) => {
console.log('Call ended:', data);
if (currentCall.value?.callSid === data.callSid) {
currentCall.value = null;
}
if (incomingCall.value?.callSid === data.callSid) {
incomingCall.value = null;
}
stopRingtone();
toast.info('Call ended');
};
const handleCallUpdate = (data: any) => {
console.log('Call update:', data);
if (currentCall.value?.callSid === data.callSid) {
currentCall.value = { ...currentCall.value, ...data };
}
};
const handleCallError = (data: any) => {
console.error('Call error:', data);
toast.error(data.message || 'Call error occurred');
};
const handleCallState = (data: Call) => {
console.log('Call state:', data);
if (data.status === 'in-progress') {
currentCall.value = data;
}
};
const handleAiTranscript = (data: { transcript: string; isFinal: boolean }) => {
transcript.value.push({
text: data.transcript,
isFinal: data.isFinal,
timestamp: Date.now(),
});
// Keep only last 50 transcript items
if (transcript.value.length > 50) {
transcript.value = transcript.value.slice(-50);
}
};
const handleAiSuggestion = (data: AiSuggestion) => {
aiSuggestions.value.unshift(data);
// Keep only last 10 suggestions
if (aiSuggestions.value.length > 10) {
aiSuggestions.value = aiSuggestions.value.slice(0, 10);
}
};
// Helper to parse JWT (for debugging)
const parseJwt = (token: string) => {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch (e) {
return null;
}
};
const handleAiAction = (data: any) => {
console.log('AI action:', data);
toast.info(`AI: ${data.action}`);
};
// Ringtone management
let ringtoneAudio: HTMLAudioElement | null = null;
const playRingtone = () => {
// Optional: Play a simple beep tone using Web Audio API
// This is a nice-to-have enhancement but not required for incoming calls to work
try {
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
// Phone ringtone frequency (440 Hz)
oscillator.frequency.value = 440;
oscillator.type = 'sine';
const now = audioContext.currentTime;
gainNode.gain.setValueAtTime(0.15, now);
gainNode.gain.setValueAtTime(0, now + 0.5);
gainNode.gain.setValueAtTime(0.15, now + 1.0);
gainNode.gain.setValueAtTime(0, now + 1.5);
oscillator.start(now);
oscillator.stop(now + 2);
} catch (error) {
// Silent fail - incoming call still works without audio
console.debug('Audio notification skipped:', error);
}
};
const stopRingtone = () => {
if (ringtoneAudio) {
ringtoneAudio.pause();
ringtoneAudio = null;
}
};
// Auto-connect on mount if token is available
onMounted(() => {
if (getToken() && !isInitialized.value) {
connect();
}
});
// Cleanup on unmount
onUnmounted(() => {
stopRingtone();
});
return {
// State
isOpen,
isConnected,
isInCall,
hasIncomingCall,
currentCall,
incomingCall,
transcript,
aiSuggestions,
callStatus,
callHistory,
isMuted,
volume,
// Actions
open,
close,
initiateCall,
acceptCall,
rejectCall,
endCall,
sendDtmf,
toggleMute,
connect,
disconnect,
};
}