mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: add JsSIP SIP client and useSipPhone hook for Ozonetel softphone integration
This commit is contained in:
214
src/lib/sip-client.ts
Normal file
214
src/lib/sip-client.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import JsSIP from 'jssip';
|
||||
import type { UAConfiguration, RTCSessionEvent, CallOptions } from 'jssip/lib/UA';
|
||||
import type { RTCSession, PeerConnectionEvent, EndEvent, CallListener } from 'jssip/lib/RTCSession';
|
||||
import type { SIPConfig, ConnectionStatus, CallState } from '@/types/sip';
|
||||
|
||||
export class SIPClient {
|
||||
private ua: JsSIP.UA | null = null;
|
||||
private currentSession: RTCSession | null = null;
|
||||
private audioElement: HTMLAudioElement | null = null;
|
||||
|
||||
constructor(
|
||||
private config: SIPConfig,
|
||||
private onConnectionChange: (status: ConnectionStatus) => void,
|
||||
private onCallStateChange: (state: CallState, callerNumber?: string) => void,
|
||||
) {}
|
||||
|
||||
connect(): void {
|
||||
const socket = new JsSIP.WebSocketInterface(this.config.wsServer);
|
||||
|
||||
const configuration: UAConfiguration = {
|
||||
sockets: [socket],
|
||||
uri: this.config.uri,
|
||||
password: this.config.password,
|
||||
display_name: this.config.displayName,
|
||||
register: true,
|
||||
register_expires: 120,
|
||||
};
|
||||
|
||||
this.ua = new JsSIP.UA(configuration);
|
||||
|
||||
this.ua.on('connected', () => {
|
||||
this.onConnectionChange('connected');
|
||||
});
|
||||
|
||||
this.ua.on('disconnected', () => {
|
||||
this.onConnectionChange('disconnected');
|
||||
});
|
||||
|
||||
this.ua.on('registered', () => {
|
||||
this.onConnectionChange('registered');
|
||||
});
|
||||
|
||||
this.ua.on('unregistered', () => {
|
||||
this.onConnectionChange('disconnected');
|
||||
});
|
||||
|
||||
this.ua.on('registrationFailed', () => {
|
||||
this.onConnectionChange('error');
|
||||
});
|
||||
|
||||
this.ua.on('newRTCSession', (data: RTCSessionEvent) => {
|
||||
const session = data.session;
|
||||
this.currentSession = session;
|
||||
|
||||
// Extract caller number
|
||||
const remoteUri = session.remote_identity?.uri?.toString() ?? '';
|
||||
const callerNumber = remoteUri.replace('sip:', '').split('@')[0] || 'Unknown';
|
||||
|
||||
// Setup audio
|
||||
session.on('peerconnection', (e: PeerConnectionEvent) => {
|
||||
const pc = e.peerconnection;
|
||||
pc.ontrack = (event: RTCTrackEvent) => {
|
||||
if (!this.audioElement) {
|
||||
this.audioElement = document.createElement('audio');
|
||||
this.audioElement.autoplay = true;
|
||||
document.body.appendChild(this.audioElement);
|
||||
}
|
||||
this.audioElement.srcObject = event.streams[0];
|
||||
};
|
||||
});
|
||||
|
||||
session.on('accepted', (() => {
|
||||
this.onCallStateChange('active', callerNumber);
|
||||
}) as CallListener);
|
||||
|
||||
session.on('confirmed', () => {
|
||||
this.onCallStateChange('active', callerNumber);
|
||||
});
|
||||
|
||||
session.on('progress', (() => {
|
||||
if (session.direction === 'outgoing') {
|
||||
this.onCallStateChange('ringing-out', callerNumber);
|
||||
}
|
||||
}) as CallListener);
|
||||
|
||||
session.on('failed', (_e: EndEvent) => {
|
||||
this.onCallStateChange('failed');
|
||||
this.currentSession = null;
|
||||
this.cleanupAudio();
|
||||
});
|
||||
|
||||
session.on('ended', (_e: EndEvent) => {
|
||||
this.onCallStateChange('ended');
|
||||
this.currentSession = null;
|
||||
this.cleanupAudio();
|
||||
});
|
||||
|
||||
if (session.direction === 'incoming') {
|
||||
this.onCallStateChange('ringing-in', callerNumber);
|
||||
}
|
||||
});
|
||||
|
||||
this.ua.start();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.hangup();
|
||||
if (this.ua) {
|
||||
this.ua.stop();
|
||||
this.ua = null;
|
||||
}
|
||||
this.cleanupAudio();
|
||||
}
|
||||
|
||||
call(phoneNumber: string): void {
|
||||
if (!this.ua || !this.ua.isRegistered()) {
|
||||
throw new Error('SIP not registered');
|
||||
}
|
||||
|
||||
const host = this.config.uri.split('@')[1];
|
||||
const target = `sip:${phoneNumber}@${host}`;
|
||||
|
||||
const options: CallOptions = {
|
||||
mediaConstraints: { audio: true, video: false },
|
||||
pcConfig: {
|
||||
iceServers: this.parseStunServers(this.config.stunServers),
|
||||
iceTransportPolicy: 'all',
|
||||
},
|
||||
rtcOfferConstraints: {
|
||||
offerToReceiveAudio: true,
|
||||
offerToReceiveVideo: false,
|
||||
},
|
||||
};
|
||||
|
||||
this.ua.call(target, options);
|
||||
}
|
||||
|
||||
answer(): void {
|
||||
if (this.currentSession && this.currentSession.direction === 'incoming') {
|
||||
this.currentSession.answer({
|
||||
mediaConstraints: { audio: true, video: false },
|
||||
pcConfig: {
|
||||
iceServers: this.parseStunServers(this.config.stunServers),
|
||||
iceTransportPolicy: 'all',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hangup(): void {
|
||||
if (this.currentSession) {
|
||||
this.currentSession.terminate();
|
||||
this.currentSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
mute(): void {
|
||||
if (this.currentSession) {
|
||||
this.currentSession.mute({ audio: true });
|
||||
}
|
||||
}
|
||||
|
||||
unmute(): void {
|
||||
if (this.currentSession) {
|
||||
this.currentSession.unmute({ audio: true });
|
||||
}
|
||||
}
|
||||
|
||||
hold(): void {
|
||||
if (this.currentSession) {
|
||||
this.currentSession.hold();
|
||||
}
|
||||
}
|
||||
|
||||
unhold(): void {
|
||||
if (this.currentSession) {
|
||||
this.currentSession.unhold();
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.ua?.isConnected() ?? false;
|
||||
}
|
||||
|
||||
isRegistered(): boolean {
|
||||
return this.ua?.isRegistered() ?? false;
|
||||
}
|
||||
|
||||
private cleanupAudio(): void {
|
||||
if (this.audioElement) {
|
||||
this.audioElement.srcObject = null;
|
||||
this.audioElement.remove();
|
||||
this.audioElement = null;
|
||||
}
|
||||
}
|
||||
|
||||
private parseStunServers(stunConfig: string): RTCIceServer[] {
|
||||
const servers: RTCIceServer[] = [];
|
||||
const lines = stunConfig.split('\n').filter((line) => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split(',');
|
||||
const urls = parts[0].trim();
|
||||
|
||||
if (parts.length === 3) {
|
||||
servers.push({ urls: [urls], username: parts[1].trim(), credential: parts[2].trim() });
|
||||
} else {
|
||||
servers.push({ urls: [urls] });
|
||||
}
|
||||
}
|
||||
|
||||
return servers;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user