diff --git a/src/lib/supervisor-sip-client.ts b/src/lib/supervisor-sip-client.ts new file mode 100644 index 0000000..dbcdde8 --- /dev/null +++ b/src/lib/supervisor-sip-client.ts @@ -0,0 +1,197 @@ +import JsSIP from 'jssip'; + +// Lightweight SIP client for supervisor barge sessions. +// Separate from the agent's sip-client.ts — different lifecycle. +// Modeled on Ozonetel's kSip utility (CA-Admin/.../utils/ksip.tsx). +// +// DTMF mode mapping (from Ozonetel CA-Admin BargeinDrawerSip.tsx): +// "4" → Listen (supervisor hears all, nobody hears supervisor) +// "5" → Whisper/Training (agent hears supervisor, patient doesn't) +// "6" → Barge (both hear supervisor) + +type EventCallback = (...args: any[]) => void; +type SupervisorSipEvent = + | 'registered' + | 'registrationFailed' + | 'callReceived' + | 'callConnected' + | 'callEnded' + | 'callFailed'; + +type SupervisorSipConfig = { + domain: string; + port: string; + number: string; + password: string; +}; + +class SupervisorSipClient { + private ua: JsSIP.UA | null = null; + private session: JsSIP.RTCSession | null = null; + private config: SupervisorSipConfig | null = null; + private listeners = new Map>(); + private audioElement: HTMLAudioElement | null = null; + + init(config: SupervisorSipConfig): void { + this.config = config; + this.cleanup(); + + // Hidden audio element for remote call audio + this.audioElement = document.createElement('audio'); + this.audioElement.id = 'supervisor-remote-audio'; + this.audioElement.autoplay = true; + this.audioElement.setAttribute('playsinline', ''); + document.body.appendChild(this.audioElement); + + const socketUrl = `wss://${config.domain}:${config.port}`; + const socket = new JsSIP.WebSocketInterface(socketUrl); + + this.ua = new JsSIP.UA({ + sockets: [socket], + uri: `sip:${config.number}@${config.domain}`, + password: config.password, + registrar_server: `sip:${config.domain}`, + authorization_user: config.number, + session_timers: false, + register: false, + }); + + this.ua.on('registered', () => { + console.log('[SupervisorSIP] Registered'); + this.emit('registered'); + }); + + this.ua.on('registrationFailed', (e: any) => { + console.error('[SupervisorSIP] Registration failed:', e?.cause); + this.emit('registrationFailed', e?.cause); + }); + + this.ua.on('newRTCSession', (data: any) => { + const rtcSession = data.session as JsSIP.RTCSession; + if (rtcSession.direction !== 'incoming') return; + + console.log('[SupervisorSIP] Incoming call — auto-answering'); + this.session = rtcSession; + this.emit('callReceived'); + + rtcSession.on('accepted', () => { + console.log('[SupervisorSIP] Call accepted'); + this.emit('callConnected'); + }); + + rtcSession.on('confirmed', () => { + // Attach remote audio stream + const connection = rtcSession.connection; + if (connection && this.audioElement) { + // Modern browsers: track event + connection.addEventListener('track', (event: RTCTrackEvent) => { + if (event.streams[0] && this.audioElement) { + this.audioElement.srcObject = event.streams[0]; + } + }); + // Fallback: getRemoteStreams (older browsers/JsSIP versions) + const remoteStreams = (connection as any).getRemoteStreams?.(); + if (remoteStreams?.[0] && this.audioElement) { + this.audioElement.srcObject = remoteStreams[0]; + } + } + }); + + rtcSession.on('ended', () => { + console.log('[SupervisorSIP] Call ended'); + this.session = null; + this.emit('callEnded'); + }); + + rtcSession.on('failed', (e: any) => { + console.error('[SupervisorSIP] Call failed:', e?.cause); + this.session = null; + this.emit('callFailed', e?.cause); + }); + + // Auto-answer with audio + rtcSession.answer({ + mediaConstraints: { audio: true, video: false }, + }); + }); + + this.ua.start(); + } + + register(): void { + this.ua?.register(); + } + + isRegistered(): boolean { + return this.ua?.isRegistered() ?? false; + } + + isCallActive(): boolean { + return this.session?.isEstablished() ?? false; + } + + sendDTMF(digit: string): void { + if (!this.session?.isEstablished()) { + console.warn('[SupervisorSIP] Cannot send DTMF — no active session'); + return; + } + console.log(`[SupervisorSIP] Sending DTMF: ${digit}`); + this.session.sendDTMF(digit, { + duration: 160, + interToneGap: 1200, + }); + } + + hangup(): void { + if (this.session) { + try { + this.session.terminate(); + } catch { + // Session may already be ended + } + this.session = null; + } + } + + close(): void { + this.hangup(); + if (this.ua) { + try { + this.ua.unregister({ all: true }); + this.ua.stop(); + } catch { + // UA may already be stopped + } + this.ua = null; + } + this.cleanup(); + this.listeners.clear(); + } + + on(event: SupervisorSipEvent, callback: EventCallback): void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + this.listeners.get(event)!.add(callback); + } + + off(event: SupervisorSipEvent, callback: EventCallback): void { + this.listeners.get(event)?.delete(callback); + } + + private emit(event: string, ...args: any[]): void { + this.listeners.get(event)?.forEach(cb => { + try { cb(...args); } catch (e) { console.error(`[SupervisorSIP] Event error [${event}]:`, e); } + }); + } + + private cleanup(): void { + if (this.audioElement) { + this.audioElement.srcObject = null; + this.audioElement.remove(); + this.audioElement = null; + } + } +} + +export const supervisorSip = new SupervisorSipClient();