mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-14 20:12:25 +00:00
feat(frontend): supervisor SIP client — JsSIP wrapper for barge sessions
Separate from agent sip-client.ts — different lifecycle (on-demand per barge session, not persistent). Auto-answers incoming Ozonetel calls. DTMF mode switching: 4=listen, 5=whisper, 6=barge. Event-driven with registered/callConnected/callEnded events. Audio via hidden <audio> element. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
197
src/lib/supervisor-sip-client.ts
Normal file
197
src/lib/supervisor-sip-client.ts
Normal file
@@ -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<string, Set<EventCallback>>();
|
||||||
|
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();
|
||||||
Reference in New Issue
Block a user