feat(proxy-engine): add Rust-based outbound calling, WebRTC bridging, and voicemail handling

This commit is contained in:
2026-04-10 11:36:18 +00:00
parent ad253f823f
commit 239e2ac81d
42 changed files with 3360 additions and 6444 deletions

View File

@@ -8,7 +8,15 @@
import fs from 'node:fs';
import path from 'node:path';
import type { IEndpoint } from './sip/index.ts';
// ---------------------------------------------------------------------------
// Shared types (previously in ts/sip/types.ts, now inlined)
// ---------------------------------------------------------------------------
export interface IEndpoint {
address: string;
port: number;
}
// ---------------------------------------------------------------------------
// Config interfaces
@@ -319,175 +327,5 @@ 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;
/** If set, route directly to this voicemail box (skip ringing). */
voicemailBox?: string;
/** If set, route to this IVR menu (skip ringing). */
ivrMenuId?: string;
/** Override for no-answer timeout in seconds. */
noAnswerTimeout?: number;
}
/**
* 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,
voicemailBox: route.action.voicemailBox,
ivrMenuId: route.action.ivrMenuId,
noAnswerTimeout: route.action.noAnswerTimeout,
};
}
// Fallback: ring all devices + browsers.
return { deviceIds: [], ringBrowsers: true };
}
// ---------------------------------------------------------------------------
// Lookup helpers
// ---------------------------------------------------------------------------
export function getProvider(cfg: IAppConfig, id: string): IProviderConfig | null {
return cfg.providers.find((p) => p.id === id) ?? null;
}
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 result = resolveOutboundRoute(cfg, '');
return result?.provider ?? null;
}
/**
* @deprecated Use resolveInboundRoute() instead. Kept for backward compat.
*/
export function getDevicesForInbound(cfg: IAppConfig, providerId: string): 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[];
}
// Route resolution, pattern matching, and provider/device lookup
// are now handled entirely by the Rust proxy-engine.