612 lines
16 KiB
TypeScript
612 lines
16 KiB
TypeScript
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();
|
|
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,
|
|
};
|
|
}
|