Files
siprouter/ts/call/call-manager.ts
Juergen Kunz f3e1c96872 initial commit — SIP B2BUA + WebRTC bridge with Rust codec engine
Full-featured SIP router with multi-provider trunking, browser softphone
via WebRTC, real-time Opus/G.722/PCM transcoding in Rust, RNNoise ML
noise suppression, Kokoro neural TTS announcements, and a Lit-based
web dashboard with live call monitoring and REST API.
2026-04-09 23:03:55 +00:00

1140 lines
39 KiB
TypeScript

/**
* 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,
} from '../sip/index.ts';
import type { IEndpoint } from '../sip/index.ts';
import type { IAppConfig, IProviderConfig } from '../config.ts';
import { getProvider, getProviderForOutbound, getDevicesForInbound } 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';
// ---------------------------------------------------------------------------
// 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;
}
// ---------------------------------------------------------------------------
// CallManager
// ---------------------------------------------------------------------------
export class CallManager {
private calls = new Map<string, Call>();
/** Maps SIP Call-ID -> Call (for routing incoming SIP messages). */
private sipCallIdIndex = new Map<string, Call>();
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<string, WebRtcLeg>();
/** Pending browser calls: callId -> { provider, number, ps, rtpAllocation }. */
private pendingBrowserCalls = new Map<string, {
provider: IProviderConfig;
number: string;
ps: ProviderState;
rtpPort: number;
rtpSock: import('node:dgram').Socket;
}>();
/** Passthrough calls: SIP Call-ID -> passthrough context. */
private passthroughCalls = new Map<string, {
call: Call;
providerAddress: string;
providerPort: number;
providerConfig: IProviderConfig;
ps: ProviderState;
deviceTarget: IEndpoint;
rtpPort: number;
rtpSock: import('node:dgram').Socket;
deviceMedia: IEndpoint | null;
providerMedia: IEndpoint | null;
}>();
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<string, infer V> ? 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', `<sip:${lanIp}:${lanPort};lr>`);
}
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;
}
}
// 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.
const provider = providerId
? getProvider(this.config.appConfig, providerId)
: getProviderForOutbound(this.config.appConfig);
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,
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, number, 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 target device.
const deviceConfigs = getDevicesForInbound(this.config.appConfig, provider.id);
const deviceTarget = this.resolveFirstDevice(deviceConfigs.map((d) => d.id));
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', `<sip:${lanIp}:${lanPort};lr>`);
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 configured.
if (this.config.appConfig.routing.ringBrowsers?.[provider.id]) {
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';
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', `<sip:${this.config.appConfig.proxy.lanIp}:${this.config.appConfig.proxy.lanPort};lr>`);
}
// 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', `<sip:${lanIp}:${lanPort};lr>`);
}
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.
const provider = providerId
? getProvider(this.config.appConfig, providerId)
: getProviderForOutbound(this.config.appConfig);
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:${number}@${provider.domain}`;
newLeg.sendInvite({
fromUri: ps.registeredAor,
toUri: destUri,
callId: sipCallId,
});
this.sipCallIdIndex.set(sipCallId, call);
this.config.log(`[call-mgr] ${callId} dialing external ${number} 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<boolean> {
// 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<boolean> {
// 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;
}
}
}
}
// -------------------------------------------------------------------------
// 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();
}
}