feat: add JsSIP SIP client and useSipPhone hook for Ozonetel softphone integration

This commit is contained in:
2026-03-17 18:03:31 +05:30
parent 4f9bdc7312
commit d5feaab75a
5 changed files with 425 additions and 0 deletions

157
src/hooks/use-sip-phone.ts Normal file
View File

@@ -0,0 +1,157 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { SIPClient } from '@/lib/sip-client';
import type { SIPConfig, ConnectionStatus, CallState } from '@/types/sip';
const DEFAULT_CONFIG: SIPConfig = {
displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? 'Helix Agent',
uri: import.meta.env.VITE_SIP_URI ?? '',
password: import.meta.env.VITE_SIP_PASSWORD ?? '',
wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? '',
stunServers: 'stun:stun.l.google.com:19302',
};
export const useSipPhone = (config?: Partial<SIPConfig>) => {
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected');
const [callState, setCallState] = useState<CallState>('idle');
const [callerNumber, setCallerNumber] = useState<string | null>(null);
const [isMuted, setIsMuted] = useState(false);
const [isOnHold, setIsOnHold] = useState(false);
const [callDuration, setCallDuration] = useState(0);
const [callStartTime, setCallStartTime] = useState<Date | null>(null);
const sipClientRef = useRef<SIPClient | null>(null);
const durationIntervalRef = useRef<number | null>(null);
// Call duration timer
useEffect(() => {
if (callState === 'active' && !callStartTime) {
setCallStartTime(new Date());
}
if (callState === 'active') {
durationIntervalRef.current = window.setInterval(() => {
if (callStartTime) {
setCallDuration(Math.floor((Date.now() - callStartTime.getTime()) / 1000));
}
}, 1000);
} else if (callState === 'idle' || callState === 'ended' || callState === 'failed') {
if (durationIntervalRef.current) {
clearInterval(durationIntervalRef.current);
durationIntervalRef.current = null;
}
setCallDuration(0);
setCallStartTime(null);
}
return () => {
if (durationIntervalRef.current) {
clearInterval(durationIntervalRef.current);
}
};
}, [callState, callStartTime]);
// Auto-reset to idle after ended/failed
useEffect(() => {
if (callState === 'ended' || callState === 'failed') {
const timer = setTimeout(() => {
setCallState('idle');
setCallerNumber(null);
setIsMuted(false);
setIsOnHold(false);
}, 2000);
return () => clearTimeout(timer);
}
}, [callState]);
const connect = useCallback(() => {
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
if (!mergedConfig.wsServer || !mergedConfig.uri) {
console.warn('SIP config incomplete — wsServer and uri required');
return;
}
if (sipClientRef.current) {
sipClientRef.current.disconnect();
}
setConnectionStatus('connecting');
const client = new SIPClient(
mergedConfig,
(status) => setConnectionStatus(status),
(state, number) => {
setCallState(state);
if (number) setCallerNumber(number);
},
);
sipClientRef.current = client;
client.connect();
}, [config]);
const disconnect = useCallback(() => {
sipClientRef.current?.disconnect();
sipClientRef.current = null;
setConnectionStatus('disconnected');
}, []);
const makeCall = useCallback((phoneNumber: string) => {
sipClientRef.current?.call(phoneNumber);
setCallerNumber(phoneNumber);
}, []);
const answer = useCallback(() => {
sipClientRef.current?.answer();
}, []);
const hangup = useCallback(() => {
sipClientRef.current?.hangup();
}, []);
const toggleMute = useCallback(() => {
if (isMuted) {
sipClientRef.current?.unmute();
} else {
sipClientRef.current?.mute();
}
setIsMuted(!isMuted);
}, [isMuted]);
const toggleHold = useCallback(() => {
if (isOnHold) {
sipClientRef.current?.unhold();
} else {
sipClientRef.current?.hold();
}
setIsOnHold(!isOnHold);
}, [isOnHold]);
// Cleanup on unmount
useEffect(() => {
return () => {
sipClientRef.current?.disconnect();
};
}, []);
return {
// State
connectionStatus,
callState,
callerNumber,
isMuted,
isOnHold,
callDuration,
isRegistered: connectionStatus === 'registered',
isInCall: ['ringing-in', 'ringing-out', 'active'].includes(callState),
// Actions
connect,
disconnect,
makeCall,
answer,
hangup,
toggleMute,
toggleHold,
};
};

214
src/lib/sip-client.ts Normal file
View 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;
}
}

11
src/types/sip.ts Normal file
View File

@@ -0,0 +1,11 @@
export type SIPConfig = {
displayName: string;
uri: string;
password: string;
wsServer: string;
stunServers: string;
};
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'registered' | 'error';
export type CallState = 'idle' | 'ringing-in' | 'ringing-out' | 'active' | 'ended' | 'failed';