WIP - twilio integration
This commit is contained in:
421
frontend/composables/useSoftphone.ts
Normal file
421
frontend/composables/useSoftphone.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user