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(null); const twilioDevice = shallowRef(null); const twilioCall = shallowRef(null); const isConnected = ref(false); const isOpen = ref(false); const currentCall = ref(null); const incomingCall = ref(null); const transcript = ref([]); const aiSuggestions = ref([]); const callHistory = ref([]); 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(); const response = await api.get('/voice/token'); const token = response.data.token; // Log the token payload to see what identity is being used try { const tokenPayload = JSON.parse(atob(token.split('.')[1])); } catch (e) { console.log('Could not parse token payload'); } twilioDevice.value = new Device(token, { logLevel: 3, codecPreferences: ['opus', 'pcmu'], enableImprovedSignalingErrorPrecision: true, edge: 'ashburn', }); // Device events twilioDevice.value.on('registered', () => { toast.success('Softphone ready'); }); twilioDevice.value.on('unregistered', () => { }); twilioDevice.value.on('error', (error) => { console.error('❌ Twilio Device error:', error); toast.error('Device error: ' + error.message); }); twilioDevice.value.on('incoming', (call: TwilioCall) => { 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); // Twilio Device will handle ringtone automatically }); // Register the device await twilioDevice.value.register(); } 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 with proper auth header socket.value = io(`${getBackendUrl()}/voice`, { auth: { token: token, }, transports: ['websocket', 'polling'], reconnection: true, reconnectionDelay: 1000, reconnectionDelayMax: 5000, reconnectionAttempts: 5, query: {}, // Explicitly set empty query to prevent token leaking }); // Connection events socket.value.on('connect', () => { isConnected.value = true; // Initialize Twilio Device after WebSocket connects // Suppress warnings by catching them before they log initializeTwilioDevice().catch(err => { // Device initialization errors are already shown to user via toast console.debug('Device init issue (non-critical):', err.message); }); }); socket.value.on('disconnect', () => { isConnected.value = false; }); socket.value.on('connect_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 = () => { // Play a simple beep tone using Web Audio API 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, }; }