feat(routing): add rule-based SIP routing for inbound and outbound calls with dashboard route management

This commit is contained in:
2026-04-10 08:22:12 +00:00
parent f3e1c96872
commit fd3a408cc2
13 changed files with 893 additions and 114 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: 'siprouter',
version: '1.8.0',
version: '1.9.0',
description: 'undefined'
}

View File

@@ -1,10 +1,13 @@
/**
* TTS announcement module — pre-generates audio announcements using Kokoro TTS
* TTS announcement module — pre-generates audio announcements using espeak-ng
* and caches them as encoded RTP packets for playback during call setup.
*
* On startup, generates the announcement WAV via the Rust tts-engine binary
* (Kokoro neural TTS), encodes each 20ms frame to G.722 (for SIP) and Opus
* (for WebRTC) via the Rust transcoder, and caches the packets.
* On startup, generates the announcement WAV via espeak-ng (formant-based TTS
* with highly accurate pronunciation), encodes each 20ms frame to G.722 (for
* SIP) and Opus (for WebRTC) via the Rust transcoder, and caches the packets.
*
* Falls back to the Rust tts-engine (Kokoro neural TTS) if espeak-ng is not
* installed, and disables announcements if neither is available.
*/
import { execSync } from 'node:child_process';
@@ -35,35 +38,62 @@ export interface IAnnouncementCache {
let cachedAnnouncement: IAnnouncementCache | null = null;
const TTS_DIR = path.join(process.cwd(), '.nogit', 'tts');
const KOKORO_MODEL = 'kokoro-v1.0.onnx';
const KOKORO_VOICES = 'voices.bin';
const KOKORO_VOICE = 'af_bella'; // American female, clear and natural
const ANNOUNCEMENT_TEXT = "Hello. I'm connecting your call now.";
const CACHE_WAV = path.join(TTS_DIR, 'announcement.wav');
// Kokoro fallback constants.
const KOKORO_MODEL = 'kokoro-v1.0.onnx';
const KOKORO_VOICES = 'voices.bin';
const KOKORO_VOICE = 'af_bella';
// ---------------------------------------------------------------------------
// Initialization
// ---------------------------------------------------------------------------
/**
* Pre-generate the announcement audio and encode to G.722 frames.
* Must be called after the codec bridge is initialized.
* Check if espeak-ng is available on the system.
*/
export async function initAnnouncement(log: (msg: string) => void): Promise<boolean> {
function isEspeakAvailable(): boolean {
try {
execSync('which espeak-ng', { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
/**
* Generate announcement WAV via espeak-ng (primary engine).
* Returns true on success.
*/
function generateViaEspeak(wavPath: string, text: string, log: (msg: string) => void): boolean {
log('[tts] generating announcement audio via espeak-ng...');
try {
execSync(
`espeak-ng -v en-us -s 150 -w "${wavPath}" "${text}"`,
{ timeout: 10000, stdio: 'pipe' },
);
log('[tts] espeak-ng WAV generated');
return true;
} catch (e: any) {
log(`[tts] espeak-ng failed: ${e.message}`);
return false;
}
}
/**
* Generate announcement WAV via Kokoro TTS (fallback engine).
* Returns true on success.
*/
function generateViaKokoro(wavPath: string, text: string, log: (msg: string) => void): boolean {
const modelPath = path.join(TTS_DIR, KOKORO_MODEL);
const voicesPath = path.join(TTS_DIR, KOKORO_VOICES);
// Check if Kokoro model files exist.
if (!fs.existsSync(modelPath)) {
log('[tts] Kokoro model not found at ' + modelPath + ' — announcements disabled');
return false;
}
if (!fs.existsSync(voicesPath)) {
log('[tts] Kokoro voices not found at ' + voicesPath + ' — announcements disabled');
if (!fs.existsSync(modelPath) || !fs.existsSync(voicesPath)) {
log('[tts] Kokoro model/voices not found — Kokoro fallback unavailable');
return false;
}
// Find tts-engine binary.
const root = process.cwd();
const ttsBinPaths = [
path.join(root, 'dist_rust', 'tts-engine'),
@@ -72,53 +102,117 @@ export async function initAnnouncement(log: (msg: string) => void): Promise<bool
];
const ttsBin = ttsBinPaths.find((p) => fs.existsSync(p));
if (!ttsBin) {
log('[tts] tts-engine binary not found — announcements disabled');
log('[tts] tts-engine binary not found — Kokoro fallback unavailable');
return false;
}
log('[tts] generating announcement audio via Kokoro TTS (fallback)...');
try {
execSync(
`"${ttsBin}" --model "${modelPath}" --voices "${voicesPath}" --voice "${KOKORO_VOICE}" --output "${wavPath}" --text "${text}"`,
{ timeout: 120000, stdio: 'pipe' },
);
log('[tts] Kokoro WAV generated');
return true;
} catch (e: any) {
log(`[tts] Kokoro failed: ${e.message}`);
return false;
}
}
/**
* Read a WAV file and detect its sample rate from the fmt chunk.
* Returns { pcm, sampleRate } or null on failure.
*/
function readWavWithRate(wavPath: string): { pcm: Buffer; sampleRate: number } | null {
const wav = fs.readFileSync(wavPath);
if (wav.length < 44) return null;
if (wav.toString('ascii', 0, 4) !== 'RIFF') return null;
if (wav.toString('ascii', 8, 12) !== 'WAVE') return null;
let sampleRate = 22050; // default
let offset = 12;
let pcm: Buffer | null = null;
while (offset < wav.length - 8) {
const chunkId = wav.toString('ascii', offset, offset + 4);
const chunkSize = wav.readUInt32LE(offset + 4);
if (chunkId === 'fmt ') {
sampleRate = wav.readUInt32LE(offset + 12);
}
if (chunkId === 'data') {
pcm = wav.subarray(offset + 8, offset + 8 + chunkSize);
}
offset += 8 + chunkSize;
if (offset % 2 !== 0) offset++;
}
if (!pcm) return null;
return { pcm, sampleRate };
}
/**
* Pre-generate the announcement audio and encode to G.722 + Opus frames.
* Must be called after the codec bridge is initialized.
*
* Engine priority: espeak-ng → Kokoro → disabled.
*/
export async function initAnnouncement(log: (msg: string) => void): Promise<boolean> {
fs.mkdirSync(TTS_DIR, { recursive: true });
try {
// Generate WAV if not cached.
if (!fs.existsSync(CACHE_WAV)) {
log('[tts] generating announcement audio via Kokoro TTS...');
execSync(
`"${ttsBin}" --model "${modelPath}" --voices "${voicesPath}" --voice "${KOKORO_VOICE}" --output "${CACHE_WAV}" --text "${ANNOUNCEMENT_TEXT}"`,
{ timeout: 120000, stdio: 'pipe' },
);
log('[tts] announcement WAV generated');
let generated = false;
// Try espeak-ng first.
if (isEspeakAvailable()) {
generated = generateViaEspeak(CACHE_WAV, ANNOUNCEMENT_TEXT, log);
} else {
log('[tts] espeak-ng not installed — trying Kokoro fallback');
}
// Fall back to Kokoro.
if (!generated) {
generated = generateViaKokoro(CACHE_WAV, ANNOUNCEMENT_TEXT, log);
}
if (!generated) {
log('[tts] no TTS engine available — announcements disabled');
return false;
}
}
// Read WAV and extract raw PCM.
const wav = fs.readFileSync(CACHE_WAV);
const pcm = extractPcmFromWav(wav);
if (!pcm) {
// Read WAV and extract raw PCM + sample rate.
const result = readWavWithRate(CACHE_WAV);
if (!result) {
log('[tts] failed to parse WAV file');
return false;
}
const { pcm, sampleRate } = result;
// Wait for codec bridge to be ready.
if (!isCodecReady()) {
log('[tts] codec bridge not ready — will retry');
return false;
}
// Kokoro outputs 24000 Hz, 16-bit mono.
// We encode in chunks: 20ms at 24000 Hz = 480 samples = 960 bytes of PCM.
// The Rust encoder will resample to 16kHz internally for G.722.
const SAMPLE_RATE = 24000;
const FRAME_SAMPLES = Math.floor(SAMPLE_RATE * 0.02); // 480 samples per 20ms
// Encode in 20ms chunks. The Rust encoder resamples to each codec's native rate.
const FRAME_SAMPLES = Math.floor(sampleRate * 0.02);
const FRAME_BYTES = FRAME_SAMPLES * 2; // 16-bit = 2 bytes per sample
const totalFrames = Math.floor(pcm.length / FRAME_BYTES);
const g722Frames: Buffer[] = [];
const opusFrames: Buffer[] = [];
log(`[tts] encoding ${totalFrames} frames (${FRAME_SAMPLES} samples/frame @ ${SAMPLE_RATE}Hz)...`);
log(`[tts] encoding ${totalFrames} frames (${FRAME_SAMPLES} samples/frame @ ${sampleRate}Hz)...`);
for (let i = 0; i < totalFrames; i++) {
const framePcm = pcm.subarray(i * FRAME_BYTES, (i + 1) * FRAME_BYTES);
const pcmBuf = Buffer.from(framePcm);
const [g722, opus] = await Promise.all([
encodePcm(pcmBuf, SAMPLE_RATE, 9), // G.722 for SIP devices
encodePcm(pcmBuf, SAMPLE_RATE, 111), // Opus for WebRTC browsers
encodePcm(pcmBuf, sampleRate, 9), // G.722 for SIP devices
encodePcm(pcmBuf, sampleRate, 111), // Opus for WebRTC browsers
]);
if (g722) g722Frames.push(g722);
if (opus) opusFrames.push(opus);
@@ -236,26 +330,3 @@ export function isAnnouncementReady(): boolean {
return cachedAnnouncement !== null && cachedAnnouncement.g722Frames.length > 0;
}
// ---------------------------------------------------------------------------
// WAV parsing
// ---------------------------------------------------------------------------
function extractPcmFromWav(wav: Buffer): Buffer | null {
// Minimal WAV parser — find the "data" chunk.
if (wav.length < 44) return null;
if (wav.toString('ascii', 0, 4) !== 'RIFF') return null;
if (wav.toString('ascii', 8, 12) !== 'WAVE') return null;
let offset = 12;
while (offset < wav.length - 8) {
const chunkId = wav.toString('ascii', offset, offset + 4);
const chunkSize = wav.readUInt32LE(offset + 4);
if (chunkId === 'data') {
return wav.subarray(offset + 8, offset + 8 + chunkSize);
}
offset += 8 + chunkSize;
// Word-align.
if (offset % 2 !== 0) offset++;
}
return null;
}

View File

@@ -25,7 +25,7 @@ import {
} 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 { 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';
@@ -211,10 +211,27 @@ export class CallManager {
* 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);
// 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;
@@ -259,7 +276,7 @@ export class CallManager {
// then webrtc-accept (which links the leg to this call and starts the provider).
this.pendingBrowserCalls.set(callId, {
provider,
number,
number: dialNumber,
ps,
rtpPort: rtpA.port,
rtpSock: rtpA.sock,
@@ -312,7 +329,7 @@ export class CallManager {
}
// Start dialing provider in parallel with announcement.
this.startProviderLeg(call, provider, number, ps);
this.startProviderLeg(call, provider, dialNumber, ps);
};
legA.onTerminated = (leg) => {
@@ -505,9 +522,13 @@ export class CallManager {
return null;
}
// Resolve target device.
const deviceConfigs = getDevicesForInbound(this.config.appConfig, provider.id);
const deviceTarget = this.resolveFirstDevice(deviceConfigs.map((d) => d.id));
// 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);
@@ -563,8 +584,8 @@ export class CallManager {
this.config.sendSip(fwdInvite.serialize(), deviceTarget);
// Notify browsers if configured.
if (this.config.appConfig.routing.ringBrowsers?.[provider.id]) {
// Notify browsers if route says so.
if (routeResult.ringBrowsers) {
const ids = this.config.getAllBrowserDeviceIds();
for (const deviceIdBrowser of ids) {
this.config.sendToBrowserDevice(deviceIdBrowser, {
@@ -868,10 +889,27 @@ export class CallManager {
const call = this.calls.get(callId);
if (!call) return false;
// Resolve provider.
const provider = providerId
? getProvider(this.config.appConfig, providerId)
: getProviderForOutbound(this.config.appConfig);
// 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;
@@ -915,7 +953,7 @@ export class CallManager {
call.addLeg(newLeg);
const sipCallId = `${callId}-${legId}`;
const destUri = `sip:${number}@${provider.domain}`;
const destUri = `sip:${dialNumber}@${provider.domain}`;
newLeg.sendInvite({
fromUri: ps.registeredAor,
toUri: destUri,
@@ -923,7 +961,7 @@ export class CallManager {
});
this.sipCallIdIndex.set(sipCallId, call);
this.config.log(`[call-mgr] ${callId} dialing external ${number} via ${provider.displayName}`);
this.config.log(`[call-mgr] ${callId} dialing external ${dialNumber} via ${provider.displayName}`);
return true;
}

View File

@@ -39,10 +39,85 @@ export interface IDeviceConfig {
extension: string;
}
// ---------------------------------------------------------------------------
// Match/Action routing model
// ---------------------------------------------------------------------------
/**
* Criteria for matching a call to a route.
* All specified fields must match (AND logic).
* Omitted fields are wildcards (match anything).
*/
export interface ISipRouteMatch {
/** Whether this route applies to inbound or outbound calls. */
direction: 'inbound' | 'outbound';
/**
* Match the dialed/called number (To/Request-URI for inbound DID, dialed digits for outbound).
* Supports: exact string, prefix with trailing '*' (e.g. "+4930*"), or regex ("/^\\+49/").
*/
numberPattern?: string;
/** Match caller ID / From number (same pattern syntax). */
callerPattern?: string;
/** For inbound: match by source provider ID. */
sourceProvider?: string;
/** For outbound: match by source device ID (or 'browser' for browser devices). */
sourceDevice?: string;
}
/** What to do when a route matches. */
export interface ISipRouteAction {
// --- Inbound actions (ring targets) ---
/** Device IDs to ring. Empty/omitted = ring all devices. */
targets?: string[];
/** Also ring connected browser clients. Default false. */
ringBrowsers?: boolean;
// --- Outbound actions (provider selection) ---
/** Provider ID to use for outbound. */
provider?: string;
/** Failover provider IDs, tried in order if primary is unregistered. */
failoverProviders?: string[];
// --- Number transformation (outbound) ---
/** Strip this prefix from the dialed number before sending to provider. */
stripPrefix?: string;
/** Prepend this prefix to the dialed number before sending to provider. */
prependPrefix?: string;
}
/** A single routing rule — the core unit of the match/action model. */
export interface ISipRoute {
/** Unique identifier for this route. */
id: string;
/** Human-readable name shown in the UI. */
name: string;
/** Higher priority routes are evaluated first. Default 0. */
priority: number;
/** Disabled routes are skipped during evaluation. Default true. */
enabled: boolean;
/** Criteria that a call must match for this route to apply. */
match: ISipRouteMatch;
/** What to do when the route matches. */
action: ISipRouteAction;
}
export interface IRoutingConfig {
outbound: { default: string };
inbound: Record<string, string[]>;
ringBrowsers?: Record<string, boolean>;
routes: ISipRoute[];
}
export interface IProxyConfig {
@@ -118,8 +193,9 @@ export function loadConfig(): IAppConfig {
d.extension ??= '100';
}
cfg.routing ??= { outbound: { default: cfg.providers[0].id }, inbound: {} };
cfg.routing.outbound ??= { default: cfg.providers[0].id };
cfg.routing ??= { routes: [] };
cfg.routing.routes ??= [];
cfg.contacts ??= [];
for (const c of cfg.contacts) {
c.starred ??= false;
@@ -128,6 +204,141 @@ export function loadConfig(): IAppConfig {
return cfg;
}
// ---------------------------------------------------------------------------
// Pattern matching
// ---------------------------------------------------------------------------
/**
* Test a value against a pattern string.
* - undefined/empty pattern: matches everything (wildcard)
* - Prefix: "pattern*" matches values starting with "pattern"
* - Regex: "/pattern/" or "/pattern/i" compiles as RegExp
* - Otherwise: exact match
*/
export function matchesPattern(pattern: string | undefined, value: string): boolean {
if (!pattern) return true;
// Prefix match: "+49*"
if (pattern.endsWith('*')) {
return value.startsWith(pattern.slice(0, -1));
}
// Regex match: "/^\\+49/" or "/pattern/i"
if (pattern.startsWith('/')) {
const lastSlash = pattern.lastIndexOf('/');
if (lastSlash > 0) {
const re = new RegExp(pattern.slice(1, lastSlash), pattern.slice(lastSlash + 1));
return re.test(value);
}
}
// Exact match.
return value === pattern;
}
// ---------------------------------------------------------------------------
// Route resolution
// ---------------------------------------------------------------------------
/** Result of resolving an outbound route. */
export interface IOutboundRouteResult {
provider: IProviderConfig;
transformedNumber: string;
}
/** Result of resolving an inbound route. */
export interface IInboundRouteResult {
/** Device IDs to ring (empty = all devices). */
deviceIds: string[];
ringBrowsers: boolean;
}
/**
* Resolve which provider to use for an outbound call, and transform the number.
*
* @param cfg - app config
* @param dialedNumber - the number being dialed
* @param sourceDeviceId - optional device originating the call
* @param isProviderRegistered - callback to check if a provider is currently registered
*/
export function resolveOutboundRoute(
cfg: IAppConfig,
dialedNumber: string,
sourceDeviceId?: string,
isProviderRegistered?: (providerId: string) => boolean,
): IOutboundRouteResult | null {
const routes = cfg.routing.routes
.filter((r) => r.enabled && r.match.direction === 'outbound')
.sort((a, b) => b.priority - a.priority);
for (const route of routes) {
const m = route.match;
if (!matchesPattern(m.numberPattern, dialedNumber)) continue;
if (m.sourceDevice && m.sourceDevice !== sourceDeviceId) continue;
// Find a registered provider (primary + failovers).
const candidates = [route.action.provider, ...(route.action.failoverProviders || [])].filter(Boolean) as string[];
for (const pid of candidates) {
const provider = getProvider(cfg, pid);
if (!provider) continue;
if (isProviderRegistered && !isProviderRegistered(pid)) continue;
// Apply number transformation.
let num = dialedNumber;
if (route.action.stripPrefix && num.startsWith(route.action.stripPrefix)) {
num = num.slice(route.action.stripPrefix.length);
}
if (route.action.prependPrefix) {
num = route.action.prependPrefix + num;
}
return { provider, transformedNumber: num };
}
// Route matched but no provider is available — continue to next route.
}
// Fallback: first available provider.
const fallback = cfg.providers[0];
return fallback ? { provider: fallback, transformedNumber: dialedNumber } : null;
}
/**
* Resolve which devices/browsers to ring for an inbound call.
*
* @param cfg - app config
* @param providerId - the provider the call is coming from
* @param calledNumber - the DID / called number (from Request-URI)
* @param callerNumber - the caller ID (from From header)
*/
export function resolveInboundRoute(
cfg: IAppConfig,
providerId: string,
calledNumber: string,
callerNumber: string,
): IInboundRouteResult {
const routes = cfg.routing.routes
.filter((r) => r.enabled && r.match.direction === 'inbound')
.sort((a, b) => b.priority - a.priority);
for (const route of routes) {
const m = route.match;
if (m.sourceProvider && m.sourceProvider !== providerId) continue;
if (!matchesPattern(m.numberPattern, calledNumber)) continue;
if (!matchesPattern(m.callerPattern, callerNumber)) continue;
return {
deviceIds: route.action.targets || [],
ringBrowsers: route.action.ringBrowsers ?? false,
};
}
// Fallback: ring all devices + browsers.
return { deviceIds: [], ringBrowsers: true };
}
// ---------------------------------------------------------------------------
// Lookup helpers
// ---------------------------------------------------------------------------
@@ -140,14 +351,19 @@ export function getDevice(cfg: IAppConfig, id: string): IDeviceConfig | null {
return cfg.devices.find((d) => d.id === id) ?? null;
}
/**
* @deprecated Use resolveOutboundRoute() instead. Kept for backward compat.
*/
export function getProviderForOutbound(cfg: IAppConfig): IProviderConfig | null {
const id = cfg.routing?.outbound?.default;
if (!id) return cfg.providers[0] || null;
return getProvider(cfg, id) || cfg.providers[0] || null;
const result = resolveOutboundRoute(cfg, '');
return result?.provider ?? null;
}
/**
* @deprecated Use resolveInboundRoute() instead. Kept for backward compat.
*/
export function getDevicesForInbound(cfg: IAppConfig, providerId: string): IDeviceConfig[] {
const ids = cfg.routing.inbound[providerId];
if (!ids?.length) return cfg.devices; // fallback: ring all devices
return ids.map((id) => getDevice(cfg, id)).filter(Boolean) as IDeviceConfig[];
const result = resolveInboundRoute(cfg, providerId, '', '');
if (!result.deviceIds.length) return cfg.devices;
return result.deviceIds.map((id) => getDevice(cfg, id)).filter(Boolean) as IDeviceConfig[];
}

View File

@@ -221,11 +221,12 @@ async function handleRequest(
// Remove a provider.
if (updates.removeProvider) {
cfg.providers = (cfg.providers || []).filter((p: any) => p.id !== updates.removeProvider);
// Clean up routing references.
if (cfg.routing?.inbound) delete cfg.routing.inbound[updates.removeProvider];
if (cfg.routing?.ringBrowsers) delete cfg.routing.ringBrowsers[updates.removeProvider];
if (cfg.routing?.outbound?.default === updates.removeProvider) {
cfg.routing.outbound.default = cfg.providers[0]?.id || '';
// Clean up routing references — remove routes that reference this provider.
if (cfg.routing?.routes) {
cfg.routing.routes = cfg.routing.routes.filter((r: any) =>
r.match?.sourceProvider !== updates.removeProvider &&
r.action?.provider !== updates.removeProvider
);
}
}
@@ -236,8 +237,9 @@ async function handleRequest(
}
}
if (updates.routing) {
if (updates.routing.inbound) cfg.routing.inbound = { ...cfg.routing.inbound, ...updates.routing.inbound };
if (updates.routing.ringBrowsers) cfg.routing.ringBrowsers = { ...cfg.routing.ringBrowsers, ...updates.routing.ringBrowsers };
if (updates.routing.routes) {
cfg.routing.routes = updates.routing.routes;
}
}
if (updates.contacts !== undefined) cfg.contacts = updates.contacts;

View File

@@ -20,7 +20,7 @@ import { Buffer } from 'node:buffer';
import { SipMessage } from './sip/index.ts';
import type { IEndpoint } from './sip/index.ts';
import { loadConfig, getProviderForOutbound } from './config.ts';
import { loadConfig, resolveOutboundRoute } from './config.ts';
import type { IAppConfig, IProviderConfig } from './config.ts';
import {
initProviderStates,
@@ -151,8 +151,8 @@ sock.on('message', (data: Buffer, rinfo: dgram.RemoteInfo) => {
logPacket(`UP->DN RAW (unparsed) from ${rinfo.address}:${rinfo.port}`, data);
} else {
// From device, forward to default provider.
const dp = getProviderForOutbound(appConfig);
if (dp) sock.send(data, dp.outboundProxy.port, dp.outboundProxy.address);
const dp = resolveOutboundRoute(appConfig, '');
if (dp) sock.send(data, dp.provider.outboundProxy.port, dp.provider.outboundProxy.address);
}
return;
}
@@ -189,11 +189,22 @@ sock.on('message', (data: Buffer, rinfo: dgram.RemoteInfo) => {
// 5. New outbound call from device (passthrough).
if (!ps && msg.isRequest && msg.method === 'INVITE') {
logPacket(`[new outbound] INVITE from ${rinfo.address}:${rinfo.port}`, data);
const provider = getProviderForOutbound(appConfig);
if (provider) {
const provState = providerStates.get(provider.id);
const dialedNumber = SipMessage.extractUri(msg.requestUri || '') || '';
const routeResult = resolveOutboundRoute(
appConfig,
dialedNumber,
undefined,
(pid) => !!providerStates.get(pid)?.registeredAor,
);
if (routeResult) {
const provState = providerStates.get(routeResult.provider.id);
if (provState) {
callManager.handlePassthroughOutbound(msg, { address: rinfo.address, port: rinfo.port }, provider, provState);
// Apply number transformation to the INVITE if needed.
if (routeResult.transformedNumber !== dialedNumber) {
const newUri = msg.requestUri?.replace(dialedNumber, routeResult.transformedNumber);
if (newUri) msg.setRequestUri(newUri);
}
callManager.handlePassthroughOutbound(msg, { address: rinfo.address, port: rinfo.port }, routeResult.provider, provState);
}
}
return;
@@ -212,8 +223,8 @@ sock.on('message', (data: Buffer, rinfo: dgram.RemoteInfo) => {
} else {
// From device -> forward to provider.
logPacket(`[fallback outbound] from ${rinfo.address}:${rinfo.port}`, data);
const provider = getProviderForOutbound(appConfig);
if (provider) sock.send(msg.serialize(), provider.outboundProxy.port, provider.outboundProxy.address);
const fallback = resolveOutboundRoute(appConfig, '');
if (fallback) sock.send(msg.serialize(), fallback.provider.outboundProxy.port, fallback.provider.outboundProxy.address);
}
} catch (e: any) {
log(`[err] ${e?.stack || e}`);