422 lines
10 KiB
TypeScript
422 lines
10 KiB
TypeScript
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
|
import { io, Socket } from 'socket.io-client';
|
|
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 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);
|
|
|
|
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');
|
|
|
|
/**
|
|
* 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}:3000`;
|
|
}
|
|
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');
|
|
isConnected.value = true;
|
|
});
|
|
|
|
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', handleAiSuggestion);
|
|
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
|
|
*/
|
|
const initiateCall = async (toNumber: string) => {
|
|
if (!socket.value?.connected) {
|
|
toast.error('Not connected to voice service');
|
|
return;
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
socket.value!.emit('call:initiate', { toNumber }, (response: any) => {
|
|
if (response.success) {
|
|
resolve(response);
|
|
} else {
|
|
reject(new Error(response.error));
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Accept incoming call
|
|
*/
|
|
const acceptCall = async (callSid: string) => {
|
|
if (!socket.value?.connected) {
|
|
toast.error('Not connected to voice service');
|
|
return;
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
socket.value!.emit('call:accept', { callSid }, (response: any) => {
|
|
if (response.success) {
|
|
resolve(response);
|
|
} else {
|
|
reject(new Error(response.error));
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Reject incoming call
|
|
*/
|
|
const rejectCall = async (callSid: string) => {
|
|
if (!socket.value?.connected) {
|
|
toast.error('Not connected to voice service');
|
|
return;
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
socket.value!.emit('call:reject', { callSid }, (response: any) => {
|
|
if (response.success) {
|
|
resolve(response);
|
|
} else {
|
|
reject(new Error(response.error));
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* End active call
|
|
*/
|
|
const endCall = async (callSid: string) => {
|
|
if (!socket.value?.connected) {
|
|
toast.error('Not connected to voice service');
|
|
return;
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
socket.value!.emit('call:end', { callSid }, (response: any) => {
|
|
if (response.success) {
|
|
resolve(response);
|
|
} else {
|
|
reject(new Error(response.error));
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Send DTMF tone
|
|
*/
|
|
const sendDtmf = async (callSid: string, digit: string) => {
|
|
if (!socket.value?.connected) {
|
|
return;
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
socket.value!.emit('call:dtmf', { callSid, digit }, (response: any) => {
|
|
if (response.success) {
|
|
resolve(response);
|
|
} else {
|
|
reject(new Error(response.error));
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
// Event handlers
|
|
const handleIncomingCall = (data: Call) => {
|
|
console.log('Incoming call:', data);
|
|
incomingCall.value = data;
|
|
isOpen.value = true;
|
|
|
|
toast.info(`Incoming call from ${data.fromNumber}`, {
|
|
duration: 30000,
|
|
action: {
|
|
label: 'Answer',
|
|
onClick: () => {
|
|
acceptCall(data.callSid);
|
|
},
|
|
},
|
|
});
|
|
|
|
// Play ringtone
|
|
playRingtone();
|
|
};
|
|
|
|
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 }) => {
|
|
console.log('AI transcript:', data);
|
|
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) => {
|
|
console.log('AI suggestion:', data);
|
|
aiSuggestions.value.unshift(data);
|
|
|
|
// Keep only last 10 suggestions
|
|
if (aiSuggestions.value.length > 10) {
|
|
aiSuggestions.value = aiSuggestions.value.slice(0, 10);
|
|
}
|
|
};
|
|
|
|
const handleAiAction = (data: any) => {
|
|
console.log('AI action:', data);
|
|
toast.info(`AI: ${data.action}`);
|
|
};
|
|
|
|
// Ringtone management
|
|
let ringtoneAudio: HTMLAudioElement | null = null;
|
|
|
|
const playRingtone = () => {
|
|
try {
|
|
ringtoneAudio = new Audio('/ringtone.mp3');
|
|
ringtoneAudio.loop = true;
|
|
ringtoneAudio.play();
|
|
} catch (error) {
|
|
console.error('Failed to play ringtone:', 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,
|
|
callStatus,
|
|
transcript,
|
|
aiSuggestions,
|
|
callHistory,
|
|
|
|
// Methods
|
|
open,
|
|
close,
|
|
connect,
|
|
disconnect,
|
|
initiateCall,
|
|
acceptCall,
|
|
rejectCall,
|
|
endCall,
|
|
sendDtmf,
|
|
};
|
|
}
|