diff --git a/changelog.md b/changelog.md index ae3e74a..5a048e6 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-10 - 1.9.0 - feat(routing) +add rule-based SIP routing for inbound and outbound calls with dashboard route management + +- Replaces provider/device routing config with prioritized match/action routes for inbound and outbound call handling +- Adds outbound route resolution with provider failover and number transformation, and applies it in the call manager and SIP proxy +- Adds a dashboard Routes view and updates provider editing to manage inbound routing through route definitions +- Improves announcement generation by preferring espeak-ng, falling back to Kokoro, and detecting WAV sample rates dynamically + ## 2026-04-09 - 1.8.0 - feat(providerstate) sync provider registrations when config is reloaded after save diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 1cf05de..d69f5cf 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: 'siprouter', - version: '1.8.0', + version: '1.9.0', description: 'undefined' } diff --git a/ts/announcement.ts b/ts/announcement.ts index a2f7598..559f8a0 100644 --- a/ts/announcement.ts +++ b/ts/announcement.ts @@ -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 { +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 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 { + 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; -} diff --git a/ts/call/call-manager.ts b/ts/call/call-manager.ts index 3d842fc..d1b13a8 100644 --- a/ts/call/call-manager.ts +++ b/ts/call/call-manager.ts @@ -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; } diff --git a/ts/config.ts b/ts/config.ts index 548d6fa..4cfb5ef 100644 --- a/ts/config.ts +++ b/ts/config.ts @@ -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; - ringBrowsers?: Record; + 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[]; } diff --git a/ts/frontend.ts b/ts/frontend.ts index 5c8a089..c91f8d3 100644 --- a/ts/frontend.ts +++ b/ts/frontend.ts @@ -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; diff --git a/ts/sipproxy.ts b/ts/sipproxy.ts index 5cdfd8b..ee46842 100644 --- a/ts/sipproxy.ts +++ b/ts/sipproxy.ts @@ -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}`); diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 1cf05de..d69f5cf 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: 'siprouter', - version: '1.8.0', + version: '1.9.0', description: 'undefined' } diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index 465b83b..4a6815d 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -6,6 +6,7 @@ export * from './sipproxy-view-phone.js'; export * from './sipproxy-view-contacts.js'; export * from './sipproxy-view-providers.js'; export * from './sipproxy-view-log.js'; +export * from './sipproxy-view-routes.js'; // Sub-components (used within views) export * from './sipproxy-devices.js'; diff --git a/ts_web/elements/sipproxy-app.ts b/ts_web/elements/sipproxy-app.ts index aca2849..d656a33 100644 --- a/ts_web/elements/sipproxy-app.ts +++ b/ts_web/elements/sipproxy-app.ts @@ -8,11 +8,13 @@ import { SipproxyViewPhone } from './sipproxy-view-phone.js'; import { SipproxyViewContacts } from './sipproxy-view-contacts.js'; import { SipproxyViewProviders } from './sipproxy-view-providers.js'; import { SipproxyViewLog } from './sipproxy-view-log.js'; +import { SipproxyViewRoutes } from './sipproxy-view-routes.js'; const VIEW_TABS = [ { name: 'Overview', iconName: 'lucide:layoutDashboard', element: SipproxyViewOverview }, { name: 'Calls', iconName: 'lucide:phone', element: SipproxyViewCalls }, { name: 'Phone', iconName: 'lucide:headset', element: SipproxyViewPhone }, + { name: 'Routes', iconName: 'lucide:route', element: SipproxyViewRoutes }, { name: 'Contacts', iconName: 'lucide:contactRound', element: SipproxyViewContacts }, { name: 'Providers', iconName: 'lucide:server', element: SipproxyViewProviders }, { name: 'Log', iconName: 'lucide:scrollText', element: SipproxyViewLog }, diff --git a/ts_web/elements/sipproxy-view-providers.ts b/ts_web/elements/sipproxy-view-providers.ts index bf27ad6..bcb5fcd 100644 --- a/ts_web/elements/sipproxy-view-providers.ts +++ b/ts_web/elements/sipproxy-view-providers.ts @@ -367,8 +367,14 @@ export class SipproxyViewProviders extends DeesElement { registerIntervalSec: String(provider.registerIntervalSec ?? 300), codecs: (provider.codecs || []).join(', '), earlyMediaSilence: provider.quirks?.earlyMediaSilence ?? false, - inboundDevices: [...(cfg.routing?.inbound?.[providerId] || [])] as string[], - ringBrowsers: cfg.routing?.ringBrowsers?.[providerId] ?? false, + inboundDevices: (() => { + const route = (cfg.routing?.routes || []).find((r: any) => r.match?.direction === 'inbound' && r.match?.sourceProvider === providerId); + return route?.action?.targets ? [...route.action.targets] : []; + })() as string[], + ringBrowsers: (() => { + const route = (cfg.routing?.routes || []).find((r: any) => r.match?.direction === 'inbound' && r.match?.sourceProvider === providerId); + return route?.action?.ringBrowsers ?? false; + })(), }; await DeesModal.createAndShow({ @@ -484,6 +490,28 @@ export class SipproxyViewProviders extends DeesElement { .map((s: string) => parseInt(s.trim(), 10)) .filter((n: number) => !isNaN(n)); + // Build updated routes: update/create the inbound route for this provider. + const currentRoutes = [...(cfg.routing?.routes || [])]; + const existingIdx = currentRoutes.findIndex((r: any) => + r.match?.direction === 'inbound' && r.match?.sourceProvider === providerId + ); + const inboundRoute = { + id: `inbound-${providerId}`, + name: `Inbound from ${formData.displayName.trim() || providerId}`, + priority: 0, + enabled: true, + match: { direction: 'inbound' as const, sourceProvider: providerId }, + action: { + targets: formData.inboundDevices.length ? formData.inboundDevices : undefined, + ringBrowsers: formData.ringBrowsers, + }, + }; + if (existingIdx >= 0) { + currentRoutes[existingIdx] = { ...currentRoutes[existingIdx], ...inboundRoute }; + } else { + currentRoutes.push(inboundRoute); + } + const updates: any = { providers: [{ id: providerId, @@ -500,10 +528,7 @@ export class SipproxyViewProviders extends DeesElement { earlyMediaSilence: formData.earlyMediaSilence, }, }] as any[], - routing: { - inbound: { [providerId]: formData.inboundDevices }, - ringBrowsers: { [providerId]: formData.ringBrowsers }, - }, + routing: { routes: currentRoutes }, }; // Only send password if it was changed (not the masked placeholder). diff --git a/ts_web/elements/sipproxy-view-routes.ts b/ts_web/elements/sipproxy-view-routes.ts new file mode 100644 index 0000000..5f99dd1 --- /dev/null +++ b/ts_web/elements/sipproxy-view-routes.ts @@ -0,0 +1,405 @@ +import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js'; +import { deesCatalog } from '../plugins.js'; +import { appState, type IAppState } from '../state/appstate.js'; +import { viewHostCss } from './shared/index.js'; + +const { DeesModal, DeesToast } = deesCatalog; + +interface ISipRoute { + id: string; + name: string; + priority: number; + enabled: boolean; + match: { + direction: 'inbound' | 'outbound'; + numberPattern?: string; + callerPattern?: string; + sourceProvider?: string; + sourceDevice?: string; + }; + action: { + targets?: string[]; + ringBrowsers?: boolean; + provider?: string; + failoverProviders?: string[]; + stripPrefix?: string; + prependPrefix?: string; + }; +} + +@customElement('sipproxy-view-routes') +export class SipproxyViewRoutes extends DeesElement { + @state() accessor appData: IAppState = appState.getState(); + @state() accessor config: any = null; + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + css` + .view-section { margin-bottom: 24px; } + `, + ]; + + connectedCallback() { + super.connectedCallback(); + appState.subscribe((_k, s) => { this.appData = s; }); + this.loadConfig(); + } + + private async loadConfig() { + try { + this.config = await appState.apiGetConfig(); + } catch { + // Will show empty table. + } + } + + public render(): TemplateResult { + const cfg = this.config; + const routes: ISipRoute[] = cfg?.routing?.routes || []; + const sorted = [...routes].sort((a, b) => b.priority - a.priority); + + const tiles: any[] = [ + { + id: 'total', + title: 'Total Routes', + value: routes.length, + type: 'number', + icon: 'lucide:route', + description: `${routes.filter((r) => r.enabled).length} active`, + }, + { + id: 'inbound', + title: 'Inbound', + value: routes.filter((r) => r.match.direction === 'inbound').length, + type: 'number', + icon: 'lucide:phoneIncoming', + description: 'Incoming call routes', + }, + { + id: 'outbound', + title: 'Outbound', + value: routes.filter((r) => r.match.direction === 'outbound').length, + type: 'number', + icon: 'lucide:phoneOutgoing', + description: 'Outgoing call routes', + }, + ]; + + return html` +
+ +
+ +
+ +
+ `; + } + + private getColumns() { + return [ + { + key: 'priority', + header: 'Priority', + sortable: true, + renderer: (val: number) => + html`${val}`, + }, + { + key: 'name', + header: 'Name', + sortable: true, + }, + { + key: 'match', + header: 'Direction', + renderer: (_val: any, row: ISipRoute) => { + const dir = row.match.direction; + const color = dir === 'inbound' ? '#60a5fa' : '#4ade80'; + const bg = dir === 'inbound' ? '#1e3a5f' : '#1a3c2a'; + return html`${dir}`; + }, + }, + { + key: 'match', + header: 'Match', + renderer: (_val: any, row: ISipRoute) => { + const m = row.match; + const parts: string[] = []; + if (m.sourceProvider) parts.push(`provider: ${m.sourceProvider}`); + if (m.sourceDevice) parts.push(`device: ${m.sourceDevice}`); + if (m.numberPattern) parts.push(`number: ${m.numberPattern}`); + if (m.callerPattern) parts.push(`caller: ${m.callerPattern}`); + if (!parts.length) return html`catch-all`; + return html`${parts.join(', ')}`; + }, + }, + { + key: 'action', + header: 'Action', + renderer: (_val: any, row: ISipRoute) => { + const a = row.action; + if (row.match.direction === 'outbound') { + const parts: string[] = []; + if (a.provider) parts.push(`\u2192 ${a.provider}`); + if (a.failoverProviders?.length) parts.push(`(failover: ${a.failoverProviders.join(', ')})`); + if (a.stripPrefix) parts.push(`strip: ${a.stripPrefix}`); + if (a.prependPrefix) parts.push(`prepend: ${a.prependPrefix}`); + return html`${parts.join(' ')}`; + } else { + const parts: string[] = []; + if (a.targets?.length) parts.push(`ring: ${a.targets.join(', ')}`); + else parts.push('ring: all devices'); + if (a.ringBrowsers) parts.push('+ browsers'); + return html`${parts.join(' ')}`; + } + }, + }, + { + key: 'enabled', + header: 'Status', + renderer: (val: boolean) => { + const color = val ? '#4ade80' : '#71717a'; + const bg = val ? '#1a3c2a' : '#3f3f46'; + return html`${val ? 'Active' : 'Disabled'}`; + }, + }, + ]; + } + + private getDataActions() { + return [ + { + name: 'Add', + iconName: 'lucide:plus' as any, + type: ['header'] as any, + actionFunc: async () => { + await this.openRouteEditor(null); + }, + }, + { + name: 'Edit', + iconName: 'lucide:pencil' as any, + type: ['inRow'] as any, + actionFunc: async ({ item }: { item: ISipRoute }) => { + await this.openRouteEditor(item); + }, + }, + { + name: 'Toggle', + iconName: 'lucide:toggleLeft' as any, + type: ['inRow'] as any, + actionFunc: async ({ item }: { item: ISipRoute }) => { + const cfg = this.config; + const routes = (cfg?.routing?.routes || []).map((r: ISipRoute) => + r.id === item.id ? { ...r, enabled: !r.enabled } : r, + ); + const result = await appState.apiSaveConfig({ routing: { routes } }); + if (result.ok) { + DeesToast.success(item.enabled ? 'Route disabled' : 'Route enabled'); + await this.loadConfig(); + } + }, + }, + { + name: 'Delete', + iconName: 'lucide:trash2' as any, + type: ['inRow'] as any, + actionFunc: async ({ item }: { item: ISipRoute }) => { + const cfg = this.config; + const routes = (cfg?.routing?.routes || []).filter((r: ISipRoute) => r.id !== item.id); + const result = await appState.apiSaveConfig({ routing: { routes } }); + if (result.ok) { + DeesToast.success('Route deleted'); + await this.loadConfig(); + } + }, + }, + ]; + } + + private async openRouteEditor(existing: ISipRoute | null) { + const cfg = this.config; + const providers = cfg?.providers || []; + const devices = cfg?.devices || []; + + const formData: ISipRoute = existing + ? JSON.parse(JSON.stringify(existing)) + : { + id: `route-${Date.now()}`, + name: '', + priority: 0, + enabled: true, + match: { direction: 'outbound' as const }, + action: {}, + }; + + await DeesModal.createAndShow({ + heading: existing ? `Edit Route: ${existing.name}` : 'New Route', + width: 'small', + showCloseButton: true, + content: html` +
+ { formData.name = (e.target as any).value; }} + > + + { formData.match.direction = e.detail.key; }} + > + + { formData.priority = parseInt((e.target as any).value, 10) || 0; }} + > + + { formData.enabled = e.detail; }} + > + +
+
Match Criteria
+
+ + { formData.match.numberPattern = (e.target as any).value || undefined; }} + > + + { formData.match.callerPattern = (e.target as any).value || undefined; }} + > + + ({ option: p.displayName || p.id, key: p.id })), + ]} + @selectedOption=${(e: CustomEvent) => { formData.match.sourceProvider = e.detail.key || undefined; }} + > + +
+
Action
+
+ + ({ option: p.displayName || p.id, key: p.id })), + ]} + @selectedOption=${(e: CustomEvent) => { formData.action.provider = e.detail.key || undefined; }} + > + + { + const v = (e.target as any).value.trim(); + formData.action.targets = v ? v.split(',').map((s: string) => s.trim()) : undefined; + }} + > + + { formData.action.ringBrowsers = e.detail; }} + > + + { formData.action.stripPrefix = (e.target as any).value || undefined; }} + > + + { formData.action.prependPrefix = (e.target as any).value || undefined; }} + > +
+ `, + menuOptions: [ + { + name: 'Cancel', + iconName: 'lucide:x', + action: async (modalRef: any) => { modalRef.destroy(); }, + }, + { + name: 'Save', + iconName: 'lucide:check', + action: async (modalRef: any) => { + if (!formData.name.trim()) { + DeesToast.error('Route name is required'); + return; + } + + // Clean up empty optional fields. + if (!formData.match.numberPattern) delete formData.match.numberPattern; + if (!formData.match.callerPattern) delete formData.match.callerPattern; + if (!formData.match.sourceProvider) delete formData.match.sourceProvider; + if (!formData.match.sourceDevice) delete formData.match.sourceDevice; + if (!formData.action.provider) delete formData.action.provider; + if (!formData.action.stripPrefix) delete formData.action.stripPrefix; + if (!formData.action.prependPrefix) delete formData.action.prependPrefix; + if (!formData.action.targets?.length) delete formData.action.targets; + if (!formData.action.ringBrowsers) delete formData.action.ringBrowsers; + + const currentRoutes = [...(cfg?.routing?.routes || [])]; + const idx = currentRoutes.findIndex((r: any) => r.id === formData.id); + if (idx >= 0) { + currentRoutes[idx] = formData; + } else { + currentRoutes.push(formData); + } + + const result = await appState.apiSaveConfig({ routing: { routes: currentRoutes } }); + if (result.ok) { + modalRef.destroy(); + DeesToast.success(existing ? 'Route updated' : 'Route created'); + await this.loadConfig(); + } else { + DeesToast.error('Failed to save route'); + } + }, + }, + ], + }); + } +} diff --git a/ts_web/router.ts b/ts_web/router.ts index 24a323a..7a34131 100644 --- a/ts_web/router.ts +++ b/ts_web/router.ts @@ -3,7 +3,7 @@ * Maps URL paths to views in dees-simple-appdash. */ -const VIEWS = ['overview', 'calls', 'phone', 'contacts', 'providers', 'log'] as const; +const VIEWS = ['overview', 'calls', 'phone', 'routes', 'contacts', 'providers', 'log'] as const; type TViewSlug = (typeof VIEWS)[number]; class AppRouter {