diff --git a/package-lock.json b/package-lock.json index 855f731..273177d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@untitledui/file-icons": "^0.0.8", "@untitledui/icons": "^0.0.21", "input-otp": "^1.4.2", + "jssip": "^3.13.6", "motion": "^12.29.0", "qr-code-styling": "^1.9.2", "react": "^19.2.3", @@ -37,6 +38,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4.1.18", "@trivago/prettier-plugin-sort-imports": "^5.2.2", + "@types/jssip": "^3.5.3", "@types/node": "^24.10.9", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", @@ -3710,6 +3712,16 @@ "license": "MIT", "peer": true }, + "node_modules/@types/jssip": { + "version": "3.5.3", + "resolved": "http://localhost:4873/@types/jssip/-/jssip-3.5.3.tgz", + "integrity": "sha512-Rvw7hJPEJ12dlinAyzGpt3wxyPFMJemKRU4jTGRlRATZIdaIZUmpehsdU0oq9jIhx9bNNqIgzR+eR5Xe/U0/2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jssip": "*" + } + }, "node_modules/@types/node": { "version": "24.12.0", "resolved": "http://localhost:4873/@types/node/-/node-24.12.0.tgz", @@ -4423,6 +4435,15 @@ "node": ">=0.10.0" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "http://localhost:4873/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "http://localhost:4873/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4730,6 +4751,17 @@ "license": "MIT", "peer": true }, + "node_modules/jssip": { + "version": "3.13.6", + "resolved": "http://localhost:4873/jssip/-/jssip-3.13.6.tgz", + "integrity": "sha512-Bf1ndrSuqpO87/AG56WACR7kKcCvKOzaIQROu7JUMh0qFaGOV4NuR+wsnaXa7f3/d6xhwVczczFyt1ywJmTjPg==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "events": "^3.3.0", + "sdp-transform": "^2.14.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "http://localhost:4873/keyv/-/keyv-4.5.4.tgz", @@ -5638,6 +5670,15 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/sdp-transform": { + "version": "2.15.0", + "resolved": "http://localhost:4873/sdp-transform/-/sdp-transform-2.15.0.tgz", + "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "license": "MIT", + "bin": { + "sdp-verify": "checker.js" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "http://localhost:4873/semver/-/semver-7.7.4.tgz", diff --git a/package.json b/package.json index 27c943f..6319bfc 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@untitledui/file-icons": "^0.0.8", "@untitledui/icons": "^0.0.21", "input-otp": "^1.4.2", + "jssip": "^3.13.6", "motion": "^12.29.0", "qr-code-styling": "^1.9.2", "react": "^19.2.3", @@ -38,6 +39,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4.1.18", "@trivago/prettier-plugin-sort-imports": "^5.2.2", + "@types/jssip": "^3.5.3", "@types/node": "^24.10.9", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", diff --git a/src/hooks/use-sip-phone.ts b/src/hooks/use-sip-phone.ts new file mode 100644 index 0000000..ccfa76d --- /dev/null +++ b/src/hooks/use-sip-phone.ts @@ -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) => { + const [connectionStatus, setConnectionStatus] = useState('disconnected'); + const [callState, setCallState] = useState('idle'); + const [callerNumber, setCallerNumber] = useState(null); + const [isMuted, setIsMuted] = useState(false); + const [isOnHold, setIsOnHold] = useState(false); + const [callDuration, setCallDuration] = useState(0); + const [callStartTime, setCallStartTime] = useState(null); + + const sipClientRef = useRef(null); + const durationIntervalRef = useRef(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, + }; +}; diff --git a/src/lib/sip-client.ts b/src/lib/sip-client.ts new file mode 100644 index 0000000..fd8a7bf --- /dev/null +++ b/src/lib/sip-client.ts @@ -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; + } +} diff --git a/src/types/sip.ts b/src/types/sip.ts new file mode 100644 index 0000000..0acb177 --- /dev/null +++ b/src/types/sip.ts @@ -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';