/** * CallManager — central registry and factory for all calls. * * Replaces the scattered state across sipproxy.ts (rtpSessions), * calloriginator.ts (originatedCalls/callIdIndex), and * webrtcbridge.ts (sessions). * * Responsibilities: * - SIP message routing by Call-ID * - Factory methods for each call scenario * - Per-call provider selection * - Status aggregation for the dashboard * - Transfer and dynamic leg management */ import { Buffer } from 'node:buffer'; import { playAnnouncement, playAnnouncementToWebRtc, isAnnouncementReady } from '../announcement.ts'; import { SipMessage, buildSdp, parseSdpEndpoint, rewriteSdp, rewriteSipUri, generateTag, buildMwiBody, } from '../sip/index.ts'; import type { IEndpoint } from '../sip/index.ts'; import type { IAppConfig, IProviderConfig } from '../config.ts'; import { getProvider, getDevice, resolveOutboundRoute, resolveInboundRoute } from '../config.ts'; import { RtpPortPool } from './rtp-port-pool.ts'; import { Call } from './call.ts'; import { SipLeg } from './sip-leg.ts'; import type { ISipLegConfig } from './sip-leg.ts'; import { WebRtcLeg } from './webrtc-leg.ts'; import type { IWebRtcLegConfig } from './webrtc-leg.ts'; import type { ICallStatus, ICallHistoryEntry } from './types.ts'; import type { ProviderState } from '../providerstate.ts'; import { getRegisteredDevice, isKnownDeviceAddress, } from '../registrar.ts'; import { WebSocket } from 'ws'; import { SystemLeg } from './system-leg.ts'; import type { ISystemLegConfig } from './system-leg.ts'; import { PromptCache } from './prompt-cache.ts'; import { VoiceboxManager } from '../voicebox.ts'; import type { IVoicemailMessage } from '../voicebox.ts'; import { IvrEngine } from '../ivr.ts'; import type { IIvrConfig, TIvrAction, IVoiceboxConfig as IVoiceboxCfg } from '../config.ts'; // --------------------------------------------------------------------------- // CallManager config // --------------------------------------------------------------------------- export interface ICallManagerConfig { appConfig: IAppConfig; sendSip: (buf: Buffer, dest: IEndpoint) => void; log: (msg: string) => void; broadcastWs: (type: string, data: unknown) => void; getProviderState: (providerId: string) => ProviderState | undefined; getAllBrowserDeviceIds: () => string[]; sendToBrowserDevice: (deviceId: string, data: unknown) => boolean; getBrowserDeviceWs: (deviceId: string) => WebSocket | null; /** Prompt cache for IVR/voicemail audio playback. */ promptCache?: PromptCache; /** Voicebox manager for voicemail storage and retrieval. */ voiceboxManager?: VoiceboxManager; } // --------------------------------------------------------------------------- // CallManager // --------------------------------------------------------------------------- export class CallManager { private calls = new Map(); /** Maps SIP Call-ID -> Call (for routing incoming SIP messages). */ private sipCallIdIndex = new Map(); private portPool: RtpPortPool; private config: ICallManagerConfig; private nextCallNum = 0; /** Completed call history (most recent first, capped). */ private callHistory: ICallHistoryEntry[] = []; private static readonly MAX_HISTORY = 100; /** Standalone WebRTC legs created from webrtc-offer (before linked to a call). */ private standaloneWebRtcLegs = new Map(); /** Pending browser calls: callId -> { provider, number, ps, rtpAllocation }. */ private pendingBrowserCalls = new Map(); /** Passthrough calls: SIP Call-ID -> passthrough context. */ private passthroughCalls = new Map(); constructor(config: ICallManagerConfig) { this.config = config; const { min, max } = config.appConfig.proxy.rtpPortRange; this.portPool = new RtpPortPool(min, max, config.log); } // ------------------------------------------------------------------------- // SIP message routing // ------------------------------------------------------------------------- /** * Route an incoming SIP message to the correct call/leg. * Returns true if handled, false if this message doesn't belong to any call. */ routeSipMessage(msg: SipMessage, rinfo: { address: string; port: number }): boolean { // Check passthrough calls first — they need direction-based routing. const pt = this.passthroughCalls.get(msg.callId); if (pt) { this.handlePassthroughMessage(pt, msg, rinfo); return true; } // B2BUA calls — route by SIP Call-ID to the correct leg. const call = this.sipCallIdIndex.get(msg.callId); if (!call) return false; const leg = call.getLegBySipCallId(msg.callId); if (leg) { leg.handleSipMessage(msg, { address: rinfo.address, port: rinfo.port }); return true; } return true; } /** * Handle a SIP message for a passthrough call. * Determines direction by source IP and forwards to the other side with rewriting. */ private handlePassthroughMessage( pt: (typeof this.passthroughCalls extends Map ? V : never), msg: SipMessage, rinfo: { address: string; port: number }, ): void { const fromProvider = rinfo.address === pt.providerAddress; const lanIp = this.config.appConfig.proxy.lanIp; const lanPort = this.config.appConfig.proxy.lanPort; const pub = pt.ps.publicIp || lanIp; if (fromProvider) { // Provider -> device: rewrite and forward. if (msg.isRequest) { if (msg.isDialogEstablishing) { msg.prependHeader('Record-Route', ``); } if (msg.method === 'BYE') { this.config.sendSip(msg.serialize(), pt.deviceTarget); pt.call.hangup(); this.passthroughCalls.delete(msg.callId); return; } // INVITE retransmits — just forward. msg.setRequestUri(rewriteSipUri(msg.requestUri!, pt.deviceTarget.address, pt.deviceTarget.port)); } // Rewrite SDP (provider media → proxy LAN IP). if (msg.hasSdpBody) { const { body, original } = rewriteSdp(msg.body, lanIp, pt.rtpPort); msg.body = body; msg.updateContentLength(); if (original) pt.providerMedia = original; } this.config.sendSip(msg.serialize(), pt.deviceTarget); } else { // Device -> provider: rewrite and forward. if (msg.isRequest) { if (msg.method === 'BYE') { this.config.sendSip(msg.serialize(), pt.providerConfig.outboundProxy); pt.call.hangup(); this.passthroughCalls.delete(msg.callId); return; } } // Intercept busy/unavailable responses — route to voicemail if configured. // When the device rejects the call (486 Busy, 480 Unavailable, 600/603 Decline), // answer the provider's INVITE with our own SDP and start voicemail. if (msg.isResponse && msg.cseqMethod?.toUpperCase() === 'INVITE') { const code = msg.statusCode; if (code === 486 || code === 480 || code === 600 || code === 603) { const callId = pt.call.id; const boxId = this.findVoiceboxForCall(pt.call); if (boxId) { this.config.log(`[call-mgr] device responded ${code} — routing to voicemail box "${boxId}"`); // Build a 200 OK with our own SDP to answer the provider's INVITE. const sdpBody = buildSdp({ address: pub, port: pt.rtpPort, payloadTypes: pt.providerConfig.codecs || [9, 0, 8, 101], }); // We need to construct the 200 OK as if *we* are answering the provider. // The original INVITE from the provider used the passthrough SIP Call-ID. // Build a response using the forwarded INVITE's headers. const ok200 = SipMessage.createResponse(200, 'OK', msg, { body: sdpBody, contentType: 'application/sdp', contact: ``, }); this.config.sendSip(ok200.serialize(), pt.providerConfig.outboundProxy); // Now route to voicemail. this.routeToVoicemail(callId, boxId); return; } } } // Rewrite Contact. const contact = msg.getHeader('Contact'); if (contact) { const nc = rewriteSipUri(contact, pub, lanPort); if (nc !== contact) msg.setHeader('Contact', nc); } // Rewrite SDP (device media → proxy public IP). if (msg.hasSdpBody) { const { body, original } = rewriteSdp(msg.body, pub, pt.rtpPort); msg.body = body; msg.updateContentLength(); if (original) pt.deviceMedia = original; } // Start silence on 200 OK to INVITE. if (msg.isResponse && msg.statusCode === 200 && msg.cseqMethod?.toUpperCase() === 'INVITE') { if (pt.providerConfig.quirks.earlyMediaSilence && pt.providerMedia) { this.startPassthroughSilence({ pktReceived: 0, pktSent: 0, rtpSock: pt.rtpSock } as any, pt.providerMedia, pt.providerConfig); } } this.config.sendSip(msg.serialize(), pt.providerConfig.outboundProxy); } } // ------------------------------------------------------------------------- // Outbound call (click-to-call from UI) // ------------------------------------------------------------------------- /** * Start an outbound call from the dashboard. * Dials the device first (leg A), then the provider (leg B) when device answers. */ createOutboundCall(number: string, deviceId?: string, providerId?: string): Call | null { // Resolve provider via routing (or explicit providerId override). let provider: IProviderConfig | null; let dialNumber = number; if (providerId) { provider = getProvider(this.config.appConfig, providerId); } else { const routeResult = resolveOutboundRoute( this.config.appConfig, number, deviceId, (pid) => !!this.config.getProviderState(pid)?.registeredAor, ); if (routeResult) { provider = routeResult.provider; dialNumber = routeResult.transformedNumber; } else { provider = null; } } if (!provider) { this.config.log('[call-mgr] no provider found'); return null; } const ps = this.config.getProviderState(provider.id); if (!ps) { this.config.log(`[call-mgr] provider state not found for ${provider.id}`); return null; } const aor = ps.registeredAor; if (!aor) { this.config.log('[call-mgr] cannot originate — no registered AOR'); return null; } // Allocate RTP for device leg. const rtpA = this.portPool.allocate(); if (!rtpA) { this.config.log('[call-mgr] cannot originate — port pool exhausted'); return null; } const callId = `call-${Date.now()}-${(this.nextCallNum++).toString(36)}`; const call = new Call({ id: callId, direction: 'outbound', portPool: this.portPool, log: this.config.log, onChange: (c) => this.handleCallChange(c), }); call.calleeNumber = number; call.providerUsed = provider.displayName; this.calls.set(callId, call); const isBrowser = deviceId?.startsWith('browser-') ?? false; if (isBrowser && deviceId) { // Browser device — DON'T create WebRtcLeg yet. // The browser will send webrtc-offer (creating a standalone WebRtcLeg), // then webrtc-accept (which links the leg to this call and starts the provider). this.pendingBrowserCalls.set(callId, { provider, number: dialNumber, ps, rtpPort: rtpA.port, rtpSock: rtpA.sock, }); // Notify browser of incoming call. call.state = 'ringing'; this.config.sendToBrowserDevice(deviceId, { type: 'webrtc-incoming', callId, from: number, deviceId, }); this.config.log(`[call-mgr] ${callId} notified browser device ${deviceId}`); } else { // SIP device — create SipLeg with INVITE. const deviceTarget = this.resolveDeviceTarget(deviceId); if (!deviceTarget) { this.config.log('[call-mgr] cannot resolve device'); this.portPool.release(rtpA.port); this.calls.delete(callId); return null; } const sipLegConfig: ISipLegConfig = { role: 'device', lanIp: this.config.appConfig.proxy.lanIp, lanPort: this.config.appConfig.proxy.lanPort, getPublicIp: () => ps.publicIp, sendSip: this.config.sendSip, log: this.config.log, sipTarget: deviceTarget, rtpPort: rtpA.port, rtpSock: rtpA.sock, }; const legA = new SipLeg(`${callId}-dev`, sipLegConfig); legA.onConnected = (leg) => { this.config.log(`[call-mgr] ${callId} device answered — starting provider leg`); // Play announcement to the device while dialing the provider. if (isAnnouncementReady() && leg.remoteMedia) { playAnnouncement( (pkt) => leg.rtpSock.send(pkt, leg.remoteMedia!.port, leg.remoteMedia!.address), leg.ssrc, ); this.config.log(`[call-mgr] ${callId} playing announcement to device`); } // Start dialing provider in parallel with announcement. this.startProviderLeg(call, provider, dialNumber, ps); }; legA.onTerminated = (leg) => { call.handleLegTerminated(leg.id); }; legA.onStateChange = () => call.notifyLegStateChange(legA); call.addLeg(legA); const sipCallIdA = `${callId}-a`; legA.sendInvite({ fromUri: `sip:${number}@${this.config.appConfig.proxy.lanIp}`, fromDisplayName: `Mediated: ${number}`, toUri: `sip:user@${deviceTarget.address}`, callId: sipCallIdA, }); this.sipCallIdIndex.set(sipCallIdA, call); } return call; } /** * Browser accepted the call — link the standalone WebRtcLeg to the call * and start the provider leg. */ acceptBrowserCall(callId: string, sessionId?: string): boolean { const call = this.calls.get(callId); if (!call) { this.config.log(`[call-mgr] acceptBrowserCall: call ${callId} not found`); return false; } const pending = this.pendingBrowserCalls.get(callId); if (!pending) { this.config.log(`[call-mgr] acceptBrowserCall: no pending browser call for ${callId}`); return false; } // Find the standalone WebRtcLeg created from webrtc-offer. let webrtcLeg: WebRtcLeg | null = null; if (sessionId) { webrtcLeg = this.standaloneWebRtcLegs.get(sessionId) ?? null; } if (!webrtcLeg) { this.config.log(`[call-mgr] acceptBrowserCall: WebRTC session ${sessionId} not found — waiting`); // The offer might not have been processed yet. Retry briefly. return false; } // Remove from standalone tracking. this.standaloneWebRtcLegs.delete(sessionId!); this.pendingBrowserCalls.delete(callId); // Attach the WebRtcLeg to the call. webrtcLeg.onTerminated = (leg) => call.handleLegTerminated(leg.id); call.addLeg(webrtcLeg); this.config.log(`[call-mgr] ${callId} browser linked (session=${sessionId}) — starting provider leg`); // Play announcement to browser while dialing provider. // Uses the WebRtcLeg's shared fromSipCounters so that when provider audio // starts, seq/ts continue seamlessly (no jitter buffer discontinuity). if (isAnnouncementReady()) { const cancel = playAnnouncementToWebRtc( (pkt) => webrtcLeg.sendDirectToBrowser(pkt), webrtcLeg.fromSipSsrc, webrtcLeg.fromSipCounters, ); webrtcLeg.announcementCancel = cancel; this.config.log(`[call-mgr] ${callId} playing announcement to browser`); } // Start the provider leg in parallel. this.startProviderLeg(call, pending.provider, pending.number, pending.ps); return true; } // ------------------------------------------------------------------------- // Provider leg (leg B) // ------------------------------------------------------------------------- private startProviderLeg(call: Call, provider: IProviderConfig, number: string, ps: ProviderState): void { const rtpB = this.portPool.allocate(); if (!rtpB) { this.config.log(`[call-mgr] ${call.id} cannot start provider leg — port pool exhausted`); call.hangup(); return; } const pub = ps.publicIp || this.config.appConfig.proxy.lanIp; const aor = ps.registeredAor!; const sipLegConfig: ISipLegConfig = { role: 'provider', lanIp: this.config.appConfig.proxy.lanIp, lanPort: this.config.appConfig.proxy.lanPort, getPublicIp: () => ps.publicIp, sendSip: this.config.sendSip, log: this.config.log, provider, sipTarget: provider.outboundProxy, rtpPort: rtpB.port, rtpSock: rtpB.sock, payloadTypes: provider.codecs, getRegisteredAor: () => ps.registeredAor, getSipPassword: () => provider.password, }; const legB = new SipLeg(`${call.id}-prov`, sipLegConfig); legB.onConnected = async (leg) => { this.config.log(`[call-mgr] ${call.id} CONNECTED to provider`); // Set up transcoding between WebRTC and SIP legs if needed. const webrtcLeg = call.getLegByType('webrtc') as WebRtcLeg | null; if (webrtcLeg && leg.remoteMedia) { const sipPT = provider.codecs?.[0] ?? 9; await webrtcLeg.setupTranscoders(sipPT); // Browser→SIP: route transcoded audio through the SipLeg's socket // so the provider never sees the WebRtcLeg's port (avoids symmetric RTP double-path). webrtcLeg.remoteMedia = leg.remoteMedia; webrtcLeg.onSendToProvider = (data, dest) => { legB.rtpSock.send(data, dest.port, dest.address); }; // SIP→browser: provider RTP arrives at SipLeg's socket → onRtpReceived → // Call hub forwardRtp → WebRtcLeg.sendRtp → forwardToBrowser (transcodes to Opus). this.config.log(`[call-mgr] ${call.id} WebRTC<->SIP bridge: sip:${legB.rtpPort} <-> provider ${leg.remoteMedia.address}:${leg.remoteMedia.port}`); } call.notifyLegStateChange(leg); }; legB.onTerminated = (leg) => { call.handleLegTerminated(leg.id); }; legB.onStateChange = () => call.notifyLegStateChange(legB); call.addLeg(legB); const sipCallIdB = `${call.id}-b`; const destUri = `sip:${number}@${provider.domain}`; legB.sendInvite({ fromUri: aor, toUri: destUri, callId: sipCallIdB, }); this.sipCallIdIndex.set(sipCallIdB, call); call.state = 'ringing'; } // ------------------------------------------------------------------------- // Inbound call (provider -> device) // ------------------------------------------------------------------------- /** * Handle an inbound INVITE from a provider. * Uses passthrough routing (direction by source IP, not per-leg SIP Call-ID). */ createInboundCall(ps: ProviderState, invite: SipMessage, rinfo: IEndpoint): Call | null { const callId = `call-${Date.now()}-${(this.nextCallNum++).toString(36)}`; const provider = ps.config; const lanIp = this.config.appConfig.proxy.lanIp; const lanPort = this.config.appConfig.proxy.lanPort; const call = new Call({ id: callId, direction: 'inbound', portPool: this.portPool, log: this.config.log, onChange: (c) => this.handleCallChange(c), }); call.providerUsed = provider.displayName; const from = invite.getHeader('From'); call.callerNumber = from ? SipMessage.extractUri(from) || 'Unknown' : 'Unknown'; this.calls.set(callId, call); // Allocate a single RTP relay port (shared by both directions, like old passthrough). const rtpAlloc = this.portPool.allocate(); if (!rtpAlloc) { this.config.log('[call-mgr] cannot handle inbound — port pool exhausted'); this.calls.delete(callId); return null; } // Resolve inbound routing — determine target devices and browser ring. const calledNumber = SipMessage.extractUri(invite.requestUri || '') || ''; const routeResult = resolveInboundRoute(this.config.appConfig, provider.id, calledNumber, call.callerNumber); const targetDeviceIds = routeResult.deviceIds.length ? routeResult.deviceIds : this.config.appConfig.devices.map((d) => d.id); const deviceTarget = this.resolveFirstDevice(targetDeviceIds); if (!deviceTarget) { this.config.log('[call-mgr] cannot handle inbound — no device target'); this.portPool.release(rtpAlloc.port); this.calls.delete(callId); return null; } // Set up bidirectional RTP relay (like old passthrough). let deviceMedia: IEndpoint | null = null; let providerMedia: IEndpoint | null = null; rtpAlloc.sock.on('message', (data: Buffer, pktInfo: { address: string; port: number }) => { // Forward based on source address. if (deviceMedia && pktInfo.address === deviceMedia.address && pktInfo.port === deviceMedia.port) { if (providerMedia) rtpAlloc.sock.send(data, providerMedia.port, providerMedia.address); } else if (providerMedia && pktInfo.address === providerMedia.address && pktInfo.port === providerMedia.port) { if (deviceMedia) rtpAlloc.sock.send(data, deviceMedia.port, deviceMedia.address); } else if (isKnownDeviceAddress(pktInfo.address)) { if (!deviceMedia) deviceMedia = { address: pktInfo.address, port: pktInfo.port }; if (providerMedia) rtpAlloc.sock.send(data, providerMedia.port, providerMedia.address); } else { if (!providerMedia) providerMedia = { address: pktInfo.address, port: pktInfo.port }; if (deviceMedia) rtpAlloc.sock.send(data, deviceMedia.port, deviceMedia.address); } }); // Register as passthrough call (routes by source IP, not by leg). const ptCtx = { call, providerAddress: rinfo.address, providerPort: rinfo.port, providerConfig: provider, ps, deviceTarget, rtpPort: rtpAlloc.port, rtpSock: rtpAlloc.sock, deviceMedia, providerMedia, }; this.passthroughCalls.set(invite.callId, ptCtx); // Rewrite and forward the INVITE to the device. const fwdInvite = SipMessage.parse(invite.serialize())!; fwdInvite.setRequestUri(rewriteSipUri(fwdInvite.requestUri!, deviceTarget.address, deviceTarget.port)); fwdInvite.prependHeader('Record-Route', ``); if (fwdInvite.hasSdpBody) { const { body, original } = rewriteSdp(fwdInvite.body, lanIp, rtpAlloc.port); fwdInvite.body = body; fwdInvite.updateContentLength(); if (original) ptCtx.providerMedia = original; } this.config.sendSip(fwdInvite.serialize(), deviceTarget); // Notify browsers if route says so. if (routeResult.ringBrowsers) { const ids = this.config.getAllBrowserDeviceIds(); for (const deviceIdBrowser of ids) { this.config.sendToBrowserDevice(deviceIdBrowser, { type: 'webrtc-incoming', callId, from: call.callerNumber, deviceId: deviceIdBrowser, }); } if (ids.length) { this.config.log(`[call-mgr] notified ${ids.length} browser(s) of inbound call`); } } call.state = 'ringing'; // --- IVR / Voicemail routing --- if (routeResult.ivrMenuId && this.config.appConfig.ivr?.enabled) { // Route directly to IVR — don't ring devices. this.config.log(`[call-mgr] inbound call ${callId} routed to IVR menu "${routeResult.ivrMenuId}"`); // Respond 200 OK to the provider INVITE first. const okForProvider = SipMessage.createResponse(200, 'OK', invite, { body: fwdInvite.body, // rewritten SDP contentType: 'application/sdp', }); this.config.sendSip(okForProvider.serialize(), rinfo); this.routeToIvr(callId, this.config.appConfig.ivr); } else if (routeResult.voicemailBox) { // Route directly to voicemail — don't ring devices. this.config.log(`[call-mgr] inbound call ${callId} routed directly to voicemail box "${routeResult.voicemailBox}"`); const okForProvider = SipMessage.createResponse(200, 'OK', invite, { body: fwdInvite.body, contentType: 'application/sdp', }); this.config.sendSip(okForProvider.serialize(), rinfo); this.routeToVoicemail(callId, routeResult.voicemailBox); } else { // Normal ringing — start voicemail no-answer timer if applicable. const vm = this.config.voiceboxManager; if (vm) { // Find first voicebox for the target devices. const boxId = this.findVoiceboxForDevices(targetDeviceIds); if (boxId) { const box = vm.getBox(boxId); if (box?.enabled) { const timeoutSec = routeResult.noAnswerTimeout ?? box.noAnswerTimeoutSec ?? 25; setTimeout(() => { const c = this.calls.get(callId); if (c && c.state === 'ringing') { this.config.log(`[call-mgr] no answer after ${timeoutSec}s — routing to voicemail box "${boxId}"`); this.routeToVoicemail(callId, boxId); } }, timeoutSec * 1000); } } } } return call; } // ------------------------------------------------------------------------- // Passthrough call (device -> provider through proxy) // ------------------------------------------------------------------------- /** * Handle an outbound SIP message from a device that doesn't match any existing call. * Creates a passthrough Call if it's an INVITE, otherwise forwards raw. */ handlePassthroughOutbound(msg: SipMessage, rinfo: IEndpoint, provider: IProviderConfig, ps: ProviderState): boolean { if (msg.method !== 'INVITE') return false; const callId = `call-${Date.now()}-${(this.nextCallNum++).toString(36)}`; const call = new Call({ id: callId, direction: 'outbound', portPool: this.portPool, log: this.config.log, onChange: (c) => this.handleCallChange(c), }); call.providerUsed = provider.displayName; // Extract callee from Request-URI. const ruri = msg.requestUri || ''; call.calleeNumber = ruri; this.calls.set(callId, call); // Allocate RTP. const rtpAlloc = this.portPool.allocate(); if (!rtpAlloc) { this.config.log('[call-mgr] passthrough: port pool exhausted'); this.calls.delete(callId); return false; } // Create a passthrough "call" using the original SIP Call-ID for indexing. // Both the device and provider sides share the same SIP Call-ID. this.sipCallIdIndex.set(msg.callId, call); // Create device leg (tracks the device side). const devLegConfig: ISipLegConfig = { role: 'device', lanIp: this.config.appConfig.proxy.lanIp, lanPort: this.config.appConfig.proxy.lanPort, getPublicIp: () => ps.publicIp, sendSip: this.config.sendSip, log: this.config.log, sipTarget: { address: rinfo.address, port: rinfo.port }, rtpPort: rtpAlloc.port, rtpSock: rtpAlloc.sock, }; const devLeg = new SipLeg(`${callId}-dev`, devLegConfig); devLeg.acceptIncoming(msg); devLeg.onTerminated = (leg) => call.handleLegTerminated(leg.id); call.addLeg(devLeg); // Now forward the INVITE to the provider with SDP rewriting. const pub = ps.publicIp || this.config.appConfig.proxy.lanIp; if (msg.isDialogEstablishing) { msg.prependHeader('Record-Route', ``); } // Rewrite Contact. const contact = msg.getHeader('Contact'); if (contact) { const nc = rewriteSipUri(contact, pub, this.config.appConfig.proxy.lanPort); if (nc !== contact) msg.setHeader('Contact', nc); } // Rewrite SDP. if (msg.hasSdpBody) { const { body, original } = rewriteSdp(msg.body, pub, rtpAlloc.port); msg.body = body; msg.updateContentLength(); if (original) { devLeg.remoteMedia = original; } } this.config.sendSip(msg.serialize(), provider.outboundProxy); call.state = 'ringing'; return true; } /** * Handle an inbound SIP message from a provider for a passthrough call. * Rewrites and forwards to the device. */ handlePassthroughInbound(call: Call, msg: SipMessage, rinfo: IEndpoint, ps: ProviderState): void { const deviceLeg = call.getLegByType('sip-device') as SipLeg | null; if (!deviceLeg) return; const lanIp = this.config.appConfig.proxy.lanIp; const lanPort = this.config.appConfig.proxy.lanPort; // For responses, learn provider media from SDP. if (msg.isResponse && msg.hasSdpBody && deviceLeg.rtpPort) { const { body, original } = rewriteSdp(msg.body, lanIp, deviceLeg.rtpPort); msg.body = body; msg.updateContentLength(); if (original) { // Provider's media endpoint — set on the provider side. const provLeg = call.getLegByType('sip-provider') as SipLeg | null; if (provLeg) provLeg.remoteMedia = original; // For passthrough, the RTP relay needs both endpoints. // Since we use a single RTP socket, we store provider media // so the relay can forward. } } // For requests (like BYE), detect call termination. if (msg.isRequest) { if (msg.isDialogEstablishing) { msg.prependHeader('Record-Route', ``); } if (msg.method === 'BYE') { // Forward BYE to device, then clean up call. this.config.sendSip(msg.serialize(), deviceLeg.config.sipTarget); call.hangup(); return; } // Rewrite Request-URI. msg.setRequestUri(rewriteSipUri(msg.requestUri!, deviceLeg.config.sipTarget.address, deviceLeg.config.sipTarget.port)); } // Start silence if this is a 200 OK to INVITE. if (msg.isResponse && msg.statusCode === 200 && msg.cseqMethod?.toUpperCase() === 'INVITE') { // Silence and NAT priming happen in the SipLeg itself via quirks. // For passthrough, we manually trigger it if provider has earlyMediaSilence. const provider = ps.config; if (provider.quirks.earlyMediaSilence && deviceLeg.rtpSock) { const provMedia = parseSdpEndpoint(msg.body); if (provMedia) { this.startPassthroughSilence(deviceLeg, provMedia, provider); } } } this.config.sendSip(msg.serialize(), deviceLeg.config.sipTarget); } /** * Handle an outbound SIP response from device for a passthrough call. */ handlePassthroughOutboundResponse(call: Call, msg: SipMessage, rinfo: IEndpoint, provider: IProviderConfig, ps: ProviderState): void { const pub = ps.publicIp || this.config.appConfig.proxy.lanIp; const devLeg = call.getLegByType('sip-device') as SipLeg | null; // Rewrite Contact. const contact = msg.getHeader('Contact'); if (contact) { const nc = rewriteSipUri(contact, pub, this.config.appConfig.proxy.lanPort); if (nc !== contact) msg.setHeader('Contact', nc); } // Rewrite SDP (responses going to provider). if (msg.hasSdpBody && devLeg) { const { body, original } = rewriteSdp(msg.body, pub, devLeg.rtpPort); msg.body = body; msg.updateContentLength(); if (original) { devLeg.remoteMedia = original; } } // Detect BYE from device. if (msg.isRequest && msg.method === 'BYE') { this.config.sendSip(msg.serialize(), provider.outboundProxy); call.hangup(); return; } // Start silence on 200 OK to INVITE. if (msg.isResponse && msg.statusCode === 200 && msg.cseqMethod?.toUpperCase() === 'INVITE') { if (provider.quirks.earlyMediaSilence && devLeg) { const provMedia = devLeg.remoteMedia; // provider endpoint from SDP // Silence will be started by the provider leg or passthrough helper. } } this.config.sendSip(msg.serialize(), provider.outboundProxy); } private startPassthroughSilence(devLeg: SipLeg, provMedia: IEndpoint, provider: IProviderConfig): void { const PT = provider.quirks.silencePayloadType ?? 9; const MAX = provider.quirks.silenceMaxPackets ?? 250; const PAYLOAD = 160; let seq = Math.floor(Math.random() * 0xffff); let rtpTs = Math.floor(Math.random() * 0xffffffff); const ssrc = Math.floor(Math.random() * 0xffffffff); let count = 0; const timer = setInterval(() => { if (devLeg.pktReceived > 0 || devLeg.pktSent > 0 || count >= MAX) { clearInterval(timer); return; } const pkt = Buffer.alloc(12 + PAYLOAD); pkt[0] = 0x80; pkt[1] = PT; pkt.writeUInt16BE(seq & 0xffff, 2); pkt.writeUInt32BE(rtpTs >>> 0, 4); pkt.writeUInt32BE(ssrc >>> 0, 8); devLeg.rtpSock.send(pkt, provMedia.port, provMedia.address); seq++; rtpTs += PAYLOAD; count++; }, 20); } // ------------------------------------------------------------------------- // Hangup // ------------------------------------------------------------------------- hangup(callId: string): boolean { const call = this.calls.get(callId); if (!call) return false; call.hangup(); return true; } // ------------------------------------------------------------------------- // Dynamic leg management // ------------------------------------------------------------------------- /** * Add a new SIP device leg to an existing call. * This enables adding participants to a call dynamically. */ addDeviceToCall(callId: string, deviceId: string): boolean { const call = this.calls.get(callId); if (!call) return false; const deviceTarget = this.resolveDeviceTarget(deviceId); if (!deviceTarget) return false; const rtpAlloc = this.portPool.allocate(); if (!rtpAlloc) return false; // Find a provider state for SDP building. const providerLeg = call.getLegByType('sip-provider') as SipLeg | null; const provider = providerLeg?.config.provider; const sipLegConfig: ISipLegConfig = { role: 'device', lanIp: this.config.appConfig.proxy.lanIp, lanPort: this.config.appConfig.proxy.lanPort, getPublicIp: () => null, sendSip: this.config.sendSip, log: this.config.log, sipTarget: deviceTarget, rtpPort: rtpAlloc.port, rtpSock: rtpAlloc.sock, }; const legId = `${callId}-dev${call.legCount}`; const newLeg = new SipLeg(legId, sipLegConfig); newLeg.onConnected = () => call.notifyLegStateChange(newLeg); newLeg.onTerminated = (leg) => call.removeLeg(leg.id); newLeg.onStateChange = () => call.notifyLegStateChange(newLeg); call.addLeg(newLeg); const sipCallId = `${callId}-${legId}`; newLeg.sendInvite({ fromUri: `sip:${call.calleeNumber || 'conf'}@${this.config.appConfig.proxy.lanIp}`, fromDisplayName: `Conf: ${call.calleeNumber || 'conference'}`, toUri: `sip:user@${deviceTarget.address}`, callId: sipCallId, }); this.sipCallIdIndex.set(sipCallId, call); return true; } /** * Dial out via a provider and add the answered leg to an existing call. * This enables adding external participants by phone number. */ addExternalToCall(callId: string, number: string, providerId?: string): boolean { const call = this.calls.get(callId); if (!call) return false; // Resolve provider via routing (or explicit providerId override). let provider: IProviderConfig | null; let dialNumber = number; if (providerId) { provider = getProvider(this.config.appConfig, providerId); } else { const routeResult = resolveOutboundRoute( this.config.appConfig, number, undefined, (pid) => !!this.config.getProviderState(pid)?.registeredAor, ); if (routeResult) { provider = routeResult.provider; dialNumber = routeResult.transformedNumber; } else { provider = null; } } if (!provider) { this.config.log(`[call-mgr] addExternalToCall: no provider`); return false; } const ps = this.config.getProviderState(provider.id); if (!ps?.registeredAor) { this.config.log(`[call-mgr] addExternalToCall: provider ${provider.id} not registered`); return false; } const rtpAlloc = this.portPool.allocate(); if (!rtpAlloc) return false; const sipLegConfig: ISipLegConfig = { role: 'provider', lanIp: this.config.appConfig.proxy.lanIp, lanPort: this.config.appConfig.proxy.lanPort, getPublicIp: () => ps.publicIp, sendSip: this.config.sendSip, log: this.config.log, provider, sipTarget: provider.outboundProxy, rtpPort: rtpAlloc.port, rtpSock: rtpAlloc.sock, payloadTypes: provider.codecs, getRegisteredAor: () => ps.registeredAor, getSipPassword: () => provider.password, }; const legId = `${callId}-ext${call.legCount}`; const newLeg = new SipLeg(legId, sipLegConfig); newLeg.onConnected = (leg) => { this.config.log(`[call-mgr] ${callId} external ${number} answered`); call.notifyLegStateChange(leg); }; newLeg.onTerminated = (leg) => call.removeLeg(leg.id); newLeg.onStateChange = () => call.notifyLegStateChange(newLeg); call.addLeg(newLeg); const sipCallId = `${callId}-${legId}`; const destUri = `sip:${dialNumber}@${provider.domain}`; newLeg.sendInvite({ fromUri: ps.registeredAor, toUri: destUri, callId: sipCallId, }); this.sipCallIdIndex.set(sipCallId, call); this.config.log(`[call-mgr] ${callId} dialing external ${dialNumber} via ${provider.displayName}`); return true; } /** Remove a leg from a call by ID. */ removeLegFromCall(callId: string, legId: string): boolean { const call = this.calls.get(callId); if (!call) return false; const leg = call.getLeg(legId); if (!leg) return false; if (leg.type === 'sip-device' || leg.type === 'sip-provider') { (leg as SipLeg).sendHangup(); } call.removeLeg(legId); return true; } // ------------------------------------------------------------------------- // Transfer // ------------------------------------------------------------------------- /** * Transfer a leg from one call to another. * Detaches the leg from sourceCall and adds it to targetCall. */ transferLeg(sourceCallId: string, legId: string, targetCallId: string): boolean { const sourceCall = this.calls.get(sourceCallId); const targetCall = this.calls.get(targetCallId); if (!sourceCall || !targetCall) return false; const leg = sourceCall.detachLeg(legId); if (!leg) return false; targetCall.addLeg(leg); this.config.log(`[call-mgr] transferred leg ${legId} from ${sourceCallId} to ${targetCallId}`); // Clean up source call if empty. if (sourceCall.legCount === 0) { sourceCall.hangup(); } return true; } // ------------------------------------------------------------------------- // WebRTC signaling integration // ------------------------------------------------------------------------- /** * Handle a WebRTC offer from a browser. * Creates a standalone WebRtcLeg (not yet attached to a call). * The leg will be linked to a call when webrtc-accept arrives. */ async handleWebRtcOffer(sessionId: string, offerSdp: string, ws: WebSocket): Promise { // Check if there's already a WebRtcLeg for this session (in a call). for (const call of this.calls.values()) { for (const leg of call.getLegs()) { if (leg.type === 'webrtc' && (leg as WebRtcLeg).sessionId === sessionId) { await (leg as WebRtcLeg).handleOffer(offerSdp); return true; } } } // Create a standalone WebRtcLeg (will be linked to a call on webrtc-accept). const rtpAlloc = this.portPool.allocate(); if (!rtpAlloc) { this.config.log(`[call-mgr] webrtc-offer: port pool exhausted`); return false; } const webrtcLeg = new WebRtcLeg(`webrtc-${sessionId.slice(0, 8)}`, { ws, sessionId, rtpPort: rtpAlloc.port, rtpSock: rtpAlloc.sock, log: this.config.log, }); this.standaloneWebRtcLegs.set(sessionId, webrtcLeg); await webrtcLeg.handleOffer(offerSdp); this.config.log(`[call-mgr] standalone WebRtcLeg created for session ${sessionId.slice(0, 8)}`); return true; } /** Route an ICE candidate to the correct WebRtcLeg (in a call or standalone). */ async handleWebRtcIce(sessionId: string, candidate: any): Promise { // Check standalone legs first (most common during setup). const standalone = this.standaloneWebRtcLegs.get(sessionId); if (standalone) { await standalone.addIceCandidate(candidate); return true; } // Check legs in active calls. for (const call of this.calls.values()) { for (const leg of call.getLegs()) { if (leg.type === 'webrtc' && (leg as WebRtcLeg).sessionId === sessionId) { await (leg as WebRtcLeg).addIceCandidate(candidate); return true; } } } return false; } /** Handle WebRTC hangup. */ handleWebRtcHangup(sessionId: string): void { // Check standalone legs. const standalone = this.standaloneWebRtcLegs.get(sessionId); if (standalone) { standalone.teardown(); if (standalone.rtpPort) this.portPool.release(standalone.rtpPort); this.standaloneWebRtcLegs.delete(sessionId); return; } // Check legs in active calls. for (const call of this.calls.values()) { for (const leg of call.getLegs()) { if (leg.type === 'webrtc' && (leg as WebRtcLeg).sessionId === sessionId) { call.removeLeg(leg.id); return; } } } } // ------------------------------------------------------------------------- // Voicemail routing // ------------------------------------------------------------------------- /** * Route a call to voicemail. Cancels ringing devices, creates a SystemLeg, * plays the greeting, then starts recording. */ routeToVoicemail(callId: string, boxId: string): void { const call = this.calls.get(callId); if (!call) return; const vm = this.config.voiceboxManager; const pc = this.config.promptCache; if (!vm || !pc) { this.config.log(`[call-mgr] voicemail not available (manager or prompt cache missing)`); return; } const box = vm.getBox(boxId); if (!box) { this.config.log(`[call-mgr] voicebox "${boxId}" not found`); return; } // Cancel all ringing/device legs — keep only provider leg(s). const legsToRemove: string[] = []; for (const leg of call.getLegs()) { if (leg.type === 'sip-device' || leg.type === 'webrtc') { legsToRemove.push(leg.id); } } for (const legId of legsToRemove) { const leg = call.getLeg(legId); if (leg && (leg.type === 'sip-device' || leg.type === 'sip-provider')) { (leg as SipLeg).sendHangup(); // CANCEL ringing devices } call.removeLeg(legId); } // Cancel passthrough tracking for this call (if applicable). for (const [sipCallId, pt] of this.passthroughCalls) { if (pt.call === call) { // Keep the RTP socket — the SystemLeg will use it indirectly through the hub. this.passthroughCalls.delete(sipCallId); break; } } // Create a SystemLeg. const systemLegId = `${callId}-vm`; const systemLeg = new SystemLeg(systemLegId, { log: this.config.log, promptCache: pc, callerCodecPt: 9, // SIP callers use G.722 by default onDtmfDigit: (digit) => { // '#' during recording = stop and save. if (digit.digit === '#' && systemLeg.mode === 'voicemail-recording') { this.config.log(`[call-mgr] voicemail: caller pressed # — stopping recording`); systemLeg.stopRecording().then((result) => { if (result && result.durationMs > 500) { this.saveVoicemailMessage(boxId, call, result); } call.hangup(); }); } }, onRecordingComplete: (result) => { if (result.durationMs > 500) { this.saveVoicemailMessage(boxId, call, result); } }, }); call.addLeg(systemLeg); call.state = 'voicemail'; // Determine greeting prompt ID. const greetingPromptId = `voicemail-greeting-${boxId}`; const beepPromptId = 'voicemail-beep'; // Play greeting, then beep, then start recording. systemLeg.mode = 'voicemail-greeting'; const startSequence = () => { systemLeg.playPrompt(greetingPromptId, () => { // Greeting done — play beep. systemLeg.playPrompt(beepPromptId, () => { // Beep done — start recording. const recordDir = vm.getBoxDir(boxId); const fileId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; systemLeg.startRecording(recordDir, fileId); this.config.log(`[call-mgr] voicemail recording started for box "${boxId}"`); }); }); }; // Check if the greeting prompt is already cached; if not, generate it. if (pc.has(greetingPromptId)) { startSequence(); } else { // Generate the greeting on-the-fly. const wavPath = vm.getCustomGreetingWavPath(boxId); const generatePromise = wavPath ? pc.loadWavPrompt(greetingPromptId, wavPath) : pc.generatePrompt(greetingPromptId, vm.getGreetingText(boxId), vm.getGreetingVoice(boxId)); generatePromise.then(() => { if (call.state !== 'terminated') startSequence(); }); } } /** Save a voicemail message after recording completes. */ private saveVoicemailMessage(boxId: string, call: Call, result: import('./audio-recorder.ts').IRecordingResult): void { const vm = this.config.voiceboxManager; if (!vm) return; const fileName = result.filePath.split('/').pop() || 'unknown.wav'; const msg: IVoicemailMessage = { id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, boxId, callerNumber: call.callerNumber || 'Unknown', timestamp: Date.now(), durationMs: result.durationMs, fileName, heard: false, }; vm.saveMessage(msg); this.config.log(`[call-mgr] voicemail saved: ${msg.id} (${result.durationMs}ms) in box "${boxId}"`); // Send MWI NOTIFY to the associated device. this.sendMwiNotify(boxId); } /** Send MWI (Message Waiting Indicator) NOTIFY to a device for a voicebox. */ private sendMwiNotify(boxId: string): void { const vm = this.config.voiceboxManager; if (!vm) return; const reg = getRegisteredDevice(boxId); if (!reg?.contact) return; // Device not registered — skip. const newCount = vm.getUnheardCount(boxId); const totalCount = vm.getTotalCount(boxId); const oldCount = totalCount - newCount; const lanIp = this.config.appConfig.proxy.lanIp; const lanPort = this.config.appConfig.proxy.lanPort; const accountUri = `sip:${boxId}@${lanIp}`; const targetUri = `sip:${reg.aor || boxId}@${reg.contact.address}:${reg.contact.port}`; const mwi = buildMwiBody(newCount, oldCount, accountUri); const notify = SipMessage.createRequest('NOTIFY', targetUri, { via: { host: lanIp, port: lanPort }, from: { uri: accountUri }, to: { uri: targetUri }, contact: ``, body: mwi.body, contentType: mwi.contentType, extraHeaders: mwi.extraHeaders, }); this.config.sendSip(notify.serialize(), reg.contact); this.config.log(`[call-mgr] MWI NOTIFY sent to ${boxId}: ${newCount} new, ${oldCount} old`); } // ------------------------------------------------------------------------- // IVR routing // ------------------------------------------------------------------------- /** * Route a call to IVR. Creates a SystemLeg and starts the IVR engine. */ routeToIvr(callId: string, ivrConfig: IIvrConfig): void { const call = this.calls.get(callId); if (!call) return; const pc = this.config.promptCache; if (!pc) { this.config.log(`[call-mgr] IVR not available (prompt cache missing)`); return; } // Cancel all ringing device legs. const legsToRemove: string[] = []; for (const leg of call.getLegs()) { if (leg.type === 'sip-device' || leg.type === 'webrtc') { legsToRemove.push(leg.id); } } for (const legId of legsToRemove) { const leg = call.getLeg(legId); if (leg && (leg.type === 'sip-device' || leg.type === 'sip-provider')) { (leg as SipLeg).sendHangup(); } call.removeLeg(legId); } // Remove passthrough tracking. for (const [sipCallId, pt] of this.passthroughCalls) { if (pt.call === call) { this.passthroughCalls.delete(sipCallId); break; } } // Create SystemLeg for IVR. const systemLegId = `${callId}-ivr`; const systemLeg = new SystemLeg(systemLegId, { log: this.config.log, promptCache: pc, callerCodecPt: 9, }); call.addLeg(systemLeg); call.state = 'ivr'; systemLeg.mode = 'ivr'; // Create IVR engine. const ivrEngine = new IvrEngine( ivrConfig, systemLeg, (action: TIvrAction) => this.handleIvrAction(callId, action, ivrEngine, systemLeg), this.config.log, ); // Wire DTMF digits to the IVR engine. systemLeg.config.onDtmfDigit = (digit) => { ivrEngine.handleDigit(digit.digit); }; // Start the IVR. ivrEngine.start(); } /** Handle an action from the IVR engine. */ private handleIvrAction( callId: string, action: TIvrAction, ivrEngine: IvrEngine, systemLeg: SystemLeg, ): void { const call = this.calls.get(callId); if (!call) return; switch (action.type) { case 'route-extension': { // Tear down IVR and ring the target device. ivrEngine.destroy(); call.removeLeg(systemLeg.id); const extTarget = this.resolveDeviceTarget(action.extensionId); if (!extTarget) { this.config.log(`[call-mgr] IVR: extension "${action.extensionId}" not found — hanging up`); call.hangup(); break; } const rtpExt = this.portPool.allocate(); if (!rtpExt) { this.config.log(`[call-mgr] IVR: port pool exhausted — hanging up`); call.hangup(); break; } const ps = [...this.config.appConfig.providers] .map((p) => this.config.getProviderState(p.id)) .find((s) => s?.publicIp); const extLegConfig: ISipLegConfig = { role: 'device', lanIp: this.config.appConfig.proxy.lanIp, lanPort: this.config.appConfig.proxy.lanPort, getPublicIp: () => ps?.publicIp ?? null, sendSip: this.config.sendSip, log: this.config.log, sipTarget: extTarget, rtpPort: rtpExt.port, rtpSock: rtpExt.sock, }; const extLeg = new SipLeg(`${callId}-ext`, extLegConfig); extLeg.onTerminated = (leg) => call.handleLegTerminated(leg.id); extLeg.onStateChange = () => call.notifyLegStateChange(extLeg); call.addLeg(extLeg); call.state = 'ringing'; const sipCallIdExt = `${callId}-ext-${Date.now()}`; extLeg.sendInvite({ fromUri: `sip:${call.callerNumber || 'unknown'}@${this.config.appConfig.proxy.lanIp}`, fromDisplayName: call.callerNumber || 'Unknown', toUri: `sip:user@${extTarget.address}`, callId: sipCallIdExt, }); this.sipCallIdIndex.set(sipCallIdExt, call); this.config.log(`[call-mgr] IVR: ringing extension "${action.extensionId}"`); break; } case 'route-voicemail': { ivrEngine.destroy(); call.removeLeg(systemLeg.id); this.routeToVoicemail(callId, action.boxId); break; } case 'transfer': { ivrEngine.destroy(); call.removeLeg(systemLeg.id); // Resolve provider for outbound dial. const xferRoute = resolveOutboundRoute( this.config.appConfig, action.number, undefined, (pid) => !!this.config.getProviderState(pid)?.registeredAor, ); if (!xferRoute) { this.config.log(`[call-mgr] IVR: no provider for transfer to ${action.number} — hanging up`); call.hangup(); break; } const xferPs = this.config.getProviderState(xferRoute.provider.id); if (!xferPs) { call.hangup(); break; } this.startProviderLeg(call, xferRoute.provider, xferRoute.transformedNumber, xferPs); this.config.log(`[call-mgr] IVR: transferring to ${action.number} via ${xferRoute.provider.displayName}`); break; } case 'hangup': { ivrEngine.destroy(); call.hangup(); break; } default: break; } } /** Find the voicebox for a call (uses all device IDs or fallback to first enabled). */ private findVoiceboxForCall(call: Call): string | null { const allDeviceIds = this.config.appConfig.devices.map((d) => d.id); return this.findVoiceboxForDevices(allDeviceIds); } /** Find the first voicebox ID associated with a set of target device IDs. */ private findVoiceboxForDevices(deviceIds: string[]): string | null { const voiceboxes = this.config.appConfig.voiceboxes ?? []; for (const deviceId of deviceIds) { const box = voiceboxes.find((vb) => vb.id === deviceId); if (box?.enabled) return box.id; } // Fallback: first enabled voicebox. const first = voiceboxes.find((vb) => vb.enabled); return first?.id ?? null; } // ------------------------------------------------------------------------- // Status // ------------------------------------------------------------------------- getStatus(): ICallStatus[] { const result: ICallStatus[] = []; for (const call of this.calls.values()) { if (call.state !== 'terminated') { result.push(call.getStatus()); } } return result; } getHistory(): ICallHistoryEntry[] { return this.callHistory; } getCall(callId: string): Call | null { return this.calls.get(callId) ?? null; } getAllCalls(): Call[] { return [...this.calls.values()]; } // ------------------------------------------------------------------------- // Internal helpers // ------------------------------------------------------------------------- private handleCallChange(call: Call): void { this.config.broadcastWs('call-update', call.getStatus()); // Clean up terminated calls after a delay. if (call.state === 'terminated') { // Record in call history. const status = call.getStatus(); this.callHistory.unshift({ id: status.id, direction: status.direction, callerNumber: status.callerNumber, calleeNumber: status.calleeNumber, providerUsed: status.providerUsed, startedAt: status.createdAt, duration: status.duration, }); if (this.callHistory.length > CallManager.MAX_HISTORY) { this.callHistory.length = CallManager.MAX_HISTORY; } // Remove SIP Call-ID index entries. for (const [sipCallId, c] of this.sipCallIdIndex) { if (c === call) this.sipCallIdIndex.delete(sipCallId); } // Remove from calls map after delay (so UI can show "terminated"). setTimeout(() => this.calls.delete(call.id), 5000); } } private resolveDeviceTarget(deviceId?: string): IEndpoint | null { if (!deviceId) { // Default to first configured device. const d = this.config.appConfig.devices[0]; if (!d) return null; const reg = getRegisteredDevice(d.id); return reg?.contact || { address: d.expectedAddress, port: 5060 }; } const reg = getRegisteredDevice(deviceId); if (reg?.contact) return reg.contact; const dc = this.config.appConfig.devices.find((d) => d.id === deviceId); if (dc) return { address: dc.expectedAddress, port: 5060 }; return null; } private resolveFirstDevice(deviceIds: string[]): IEndpoint | null { for (const id of deviceIds) { const result = this.resolveDeviceTarget(id); if (result) return result; } // Fallback to first configured device. return this.resolveDeviceTarget(); } }