Files
neo/frontend/composables/useSoftphone.ts
2026-01-03 07:55:07 +01:00

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,
};
}