650 lines
18 KiB
TypeScript
650 lines
18 KiB
TypeScript
import { ref, computed, onMounted, onUnmounted, shallowRef, watch } 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();
|
|
|
|
// 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 () => {
|
|
// Prevent re-initialization if device already exists and is registered
|
|
if (twilioDevice.value) {
|
|
console.log('Twilio Device already exists, skipping initialization');
|
|
return;
|
|
}
|
|
|
|
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]));
|
|
console.log('📱 Twilio Device identity:', tokenPayload.grants?.identity || tokenPayload.sub);
|
|
} catch (e) {
|
|
console.log('Could not parse token payload');
|
|
}
|
|
|
|
console.log('📱 Creating new Twilio Device...');
|
|
twilioDevice.value = new Device(token, {
|
|
logLevel: 1, // Reduce log level (1 = errors only, 3 = debug)
|
|
codecPreferences: ['opus', 'pcmu'],
|
|
enableImprovedSignalingErrorPrecision: true,
|
|
edge: 'ashburn',
|
|
});
|
|
|
|
// Device events
|
|
twilioDevice.value.on('registered', () => {
|
|
console.log('✅ Twilio Device registered successfully');
|
|
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 call event!', {
|
|
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);
|
|
|
|
// 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 = async () => {
|
|
// Guard against multiple connection attempts
|
|
if (socket.value) {
|
|
console.log('Softphone: Socket already exists, skipping connection');
|
|
return;
|
|
}
|
|
|
|
// Check if user is authenticated
|
|
if (!auth.isAuthenticated.value) {
|
|
// Try to verify authentication first
|
|
const isValid = await auth.checkAuth();
|
|
if (!isValid) {
|
|
console.log('Softphone: User not authenticated, skipping connection');
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Get WebSocket token from BFF (this retrieves the token from HTTP-only cookie server-side)
|
|
const wsAuth = await $fetch('/api/auth/ws-token');
|
|
|
|
if (!wsAuth.token) {
|
|
console.log('Softphone: No WebSocket token available');
|
|
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: wsAuth.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;
|
|
} catch (error: any) {
|
|
console.error('Softphone: Failed to connect:', error);
|
|
toast.error('Failed to initialize voice service');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
};
|
|
|
|
// Only set up auto-connect and watchers once (not for every component that uses this composable)
|
|
// Use a module-level flag to track if watchers are already set up
|
|
if (process.client && !isInitialized.value && !socket.value) {
|
|
// Auto-connect if authenticated
|
|
if (auth.isAuthenticated.value) {
|
|
connect();
|
|
}
|
|
|
|
// Watch for authentication changes to connect/disconnect
|
|
watch(() => auth.isAuthenticated.value, async (isAuth) => {
|
|
if (isAuth && !isInitialized.value && !socket.value) {
|
|
await connect();
|
|
} else if (!isAuth && isInitialized.value) {
|
|
disconnect();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Cleanup on unmount - only stop ringtone, don't disconnect shared socket
|
|
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,
|
|
};
|
|
}
|