feat(routing): add rule-based SIP routing for inbound and outbound calls with dashboard route management
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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)
|
## 2026-04-09 - 1.8.0 - feat(providerstate)
|
||||||
sync provider registrations when config is reloaded after save
|
sync provider registrations when config is reloaded after save
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: 'siprouter',
|
name: 'siprouter',
|
||||||
version: '1.8.0',
|
version: '1.9.0',
|
||||||
description: 'undefined'
|
description: 'undefined'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
* and caches them as encoded RTP packets for playback during call setup.
|
||||||
*
|
*
|
||||||
* On startup, generates the announcement WAV via the Rust tts-engine binary
|
* On startup, generates the announcement WAV via espeak-ng (formant-based TTS
|
||||||
* (Kokoro neural TTS), encodes each 20ms frame to G.722 (for SIP) and Opus
|
* with highly accurate pronunciation), encodes each 20ms frame to G.722 (for
|
||||||
* (for WebRTC) via the Rust transcoder, and caches the packets.
|
* 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';
|
import { execSync } from 'node:child_process';
|
||||||
@@ -35,35 +38,62 @@ export interface IAnnouncementCache {
|
|||||||
let cachedAnnouncement: IAnnouncementCache | null = null;
|
let cachedAnnouncement: IAnnouncementCache | null = null;
|
||||||
|
|
||||||
const TTS_DIR = path.join(process.cwd(), '.nogit', 'tts');
|
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 ANNOUNCEMENT_TEXT = "Hello. I'm connecting your call now.";
|
||||||
const CACHE_WAV = path.join(TTS_DIR, 'announcement.wav');
|
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
|
// Initialization
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-generate the announcement audio and encode to G.722 frames.
|
* Check if espeak-ng is available on the system.
|
||||||
* Must be called after the codec bridge is initialized.
|
|
||||||
*/
|
*/
|
||||||
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 modelPath = path.join(TTS_DIR, KOKORO_MODEL);
|
||||||
const voicesPath = path.join(TTS_DIR, KOKORO_VOICES);
|
const voicesPath = path.join(TTS_DIR, KOKORO_VOICES);
|
||||||
|
|
||||||
// Check if Kokoro model files exist.
|
if (!fs.existsSync(modelPath) || !fs.existsSync(voicesPath)) {
|
||||||
if (!fs.existsSync(modelPath)) {
|
log('[tts] Kokoro model/voices not found — Kokoro fallback unavailable');
|
||||||
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');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find tts-engine binary.
|
|
||||||
const root = process.cwd();
|
const root = process.cwd();
|
||||||
const ttsBinPaths = [
|
const ttsBinPaths = [
|
||||||
path.join(root, 'dist_rust', 'tts-engine'),
|
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));
|
const ttsBin = ttsBinPaths.find((p) => fs.existsSync(p));
|
||||||
if (!ttsBin) {
|
if (!ttsBin) {
|
||||||
log('[tts] tts-engine binary not found — announcements disabled');
|
log('[tts] tts-engine binary not found — Kokoro fallback unavailable');
|
||||||
return false;
|
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 {
|
try {
|
||||||
// Generate WAV if not cached.
|
// Generate WAV if not cached.
|
||||||
if (!fs.existsSync(CACHE_WAV)) {
|
if (!fs.existsSync(CACHE_WAV)) {
|
||||||
log('[tts] generating announcement audio via Kokoro TTS...');
|
let generated = false;
|
||||||
execSync(
|
|
||||||
`"${ttsBin}" --model "${modelPath}" --voices "${voicesPath}" --voice "${KOKORO_VOICE}" --output "${CACHE_WAV}" --text "${ANNOUNCEMENT_TEXT}"`,
|
// Try espeak-ng first.
|
||||||
{ timeout: 120000, stdio: 'pipe' },
|
if (isEspeakAvailable()) {
|
||||||
);
|
generated = generateViaEspeak(CACHE_WAV, ANNOUNCEMENT_TEXT, log);
|
||||||
log('[tts] announcement WAV generated');
|
} 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.
|
// Read WAV and extract raw PCM + sample rate.
|
||||||
const wav = fs.readFileSync(CACHE_WAV);
|
const result = readWavWithRate(CACHE_WAV);
|
||||||
const pcm = extractPcmFromWav(wav);
|
if (!result) {
|
||||||
if (!pcm) {
|
|
||||||
log('[tts] failed to parse WAV file');
|
log('[tts] failed to parse WAV file');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { pcm, sampleRate } = result;
|
||||||
|
|
||||||
// Wait for codec bridge to be ready.
|
// Wait for codec bridge to be ready.
|
||||||
if (!isCodecReady()) {
|
if (!isCodecReady()) {
|
||||||
log('[tts] codec bridge not ready — will retry');
|
log('[tts] codec bridge not ready — will retry');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kokoro outputs 24000 Hz, 16-bit mono.
|
// Encode in 20ms chunks. The Rust encoder resamples to each codec's native rate.
|
||||||
// We encode in chunks: 20ms at 24000 Hz = 480 samples = 960 bytes of PCM.
|
const FRAME_SAMPLES = Math.floor(sampleRate * 0.02);
|
||||||
// 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
|
|
||||||
const FRAME_BYTES = FRAME_SAMPLES * 2; // 16-bit = 2 bytes per sample
|
const FRAME_BYTES = FRAME_SAMPLES * 2; // 16-bit = 2 bytes per sample
|
||||||
const totalFrames = Math.floor(pcm.length / FRAME_BYTES);
|
const totalFrames = Math.floor(pcm.length / FRAME_BYTES);
|
||||||
|
|
||||||
const g722Frames: Buffer[] = [];
|
const g722Frames: Buffer[] = [];
|
||||||
const opusFrames: 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++) {
|
for (let i = 0; i < totalFrames; i++) {
|
||||||
const framePcm = pcm.subarray(i * FRAME_BYTES, (i + 1) * FRAME_BYTES);
|
const framePcm = pcm.subarray(i * FRAME_BYTES, (i + 1) * FRAME_BYTES);
|
||||||
const pcmBuf = Buffer.from(framePcm);
|
const pcmBuf = Buffer.from(framePcm);
|
||||||
const [g722, opus] = await Promise.all([
|
const [g722, opus] = await Promise.all([
|
||||||
encodePcm(pcmBuf, SAMPLE_RATE, 9), // G.722 for SIP devices
|
encodePcm(pcmBuf, sampleRate, 9), // G.722 for SIP devices
|
||||||
encodePcm(pcmBuf, SAMPLE_RATE, 111), // Opus for WebRTC browsers
|
encodePcm(pcmBuf, sampleRate, 111), // Opus for WebRTC browsers
|
||||||
]);
|
]);
|
||||||
if (g722) g722Frames.push(g722);
|
if (g722) g722Frames.push(g722);
|
||||||
if (opus) opusFrames.push(opus);
|
if (opus) opusFrames.push(opus);
|
||||||
@@ -236,26 +330,3 @@ export function isAnnouncementReady(): boolean {
|
|||||||
return cachedAnnouncement !== null && cachedAnnouncement.g722Frames.length > 0;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
} from '../sip/index.ts';
|
} from '../sip/index.ts';
|
||||||
import type { IEndpoint } from '../sip/index.ts';
|
import type { IEndpoint } from '../sip/index.ts';
|
||||||
import type { IAppConfig, IProviderConfig } from '../config.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 { RtpPortPool } from './rtp-port-pool.ts';
|
||||||
import { Call } from './call.ts';
|
import { Call } from './call.ts';
|
||||||
import { SipLeg } from './sip-leg.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.
|
* Dials the device first (leg A), then the provider (leg B) when device answers.
|
||||||
*/
|
*/
|
||||||
createOutboundCall(number: string, deviceId?: string, providerId?: string): Call | null {
|
createOutboundCall(number: string, deviceId?: string, providerId?: string): Call | null {
|
||||||
// Resolve provider.
|
// Resolve provider via routing (or explicit providerId override).
|
||||||
const provider = providerId
|
let provider: IProviderConfig | null;
|
||||||
? getProvider(this.config.appConfig, providerId)
|
let dialNumber = number;
|
||||||
: getProviderForOutbound(this.config.appConfig);
|
|
||||||
|
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) {
|
if (!provider) {
|
||||||
this.config.log('[call-mgr] no provider found');
|
this.config.log('[call-mgr] no provider found');
|
||||||
return null;
|
return null;
|
||||||
@@ -259,7 +276,7 @@ export class CallManager {
|
|||||||
// then webrtc-accept (which links the leg to this call and starts the provider).
|
// then webrtc-accept (which links the leg to this call and starts the provider).
|
||||||
this.pendingBrowserCalls.set(callId, {
|
this.pendingBrowserCalls.set(callId, {
|
||||||
provider,
|
provider,
|
||||||
number,
|
number: dialNumber,
|
||||||
ps,
|
ps,
|
||||||
rtpPort: rtpA.port,
|
rtpPort: rtpA.port,
|
||||||
rtpSock: rtpA.sock,
|
rtpSock: rtpA.sock,
|
||||||
@@ -312,7 +329,7 @@ export class CallManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start dialing provider in parallel with announcement.
|
// Start dialing provider in parallel with announcement.
|
||||||
this.startProviderLeg(call, provider, number, ps);
|
this.startProviderLeg(call, provider, dialNumber, ps);
|
||||||
};
|
};
|
||||||
|
|
||||||
legA.onTerminated = (leg) => {
|
legA.onTerminated = (leg) => {
|
||||||
@@ -505,9 +522,13 @@ export class CallManager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve target device.
|
// Resolve inbound routing — determine target devices and browser ring.
|
||||||
const deviceConfigs = getDevicesForInbound(this.config.appConfig, provider.id);
|
const calledNumber = SipMessage.extractUri(invite.requestUri || '') || '';
|
||||||
const deviceTarget = this.resolveFirstDevice(deviceConfigs.map((d) => d.id));
|
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) {
|
if (!deviceTarget) {
|
||||||
this.config.log('[call-mgr] cannot handle inbound — no device target');
|
this.config.log('[call-mgr] cannot handle inbound — no device target');
|
||||||
this.portPool.release(rtpAlloc.port);
|
this.portPool.release(rtpAlloc.port);
|
||||||
@@ -563,8 +584,8 @@ export class CallManager {
|
|||||||
|
|
||||||
this.config.sendSip(fwdInvite.serialize(), deviceTarget);
|
this.config.sendSip(fwdInvite.serialize(), deviceTarget);
|
||||||
|
|
||||||
// Notify browsers if configured.
|
// Notify browsers if route says so.
|
||||||
if (this.config.appConfig.routing.ringBrowsers?.[provider.id]) {
|
if (routeResult.ringBrowsers) {
|
||||||
const ids = this.config.getAllBrowserDeviceIds();
|
const ids = this.config.getAllBrowserDeviceIds();
|
||||||
for (const deviceIdBrowser of ids) {
|
for (const deviceIdBrowser of ids) {
|
||||||
this.config.sendToBrowserDevice(deviceIdBrowser, {
|
this.config.sendToBrowserDevice(deviceIdBrowser, {
|
||||||
@@ -868,10 +889,27 @@ export class CallManager {
|
|||||||
const call = this.calls.get(callId);
|
const call = this.calls.get(callId);
|
||||||
if (!call) return false;
|
if (!call) return false;
|
||||||
|
|
||||||
// Resolve provider.
|
// Resolve provider via routing (or explicit providerId override).
|
||||||
const provider = providerId
|
let provider: IProviderConfig | null;
|
||||||
? getProvider(this.config.appConfig, providerId)
|
let dialNumber = number;
|
||||||
: getProviderForOutbound(this.config.appConfig);
|
|
||||||
|
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) {
|
if (!provider) {
|
||||||
this.config.log(`[call-mgr] addExternalToCall: no provider`);
|
this.config.log(`[call-mgr] addExternalToCall: no provider`);
|
||||||
return false;
|
return false;
|
||||||
@@ -915,7 +953,7 @@ export class CallManager {
|
|||||||
call.addLeg(newLeg);
|
call.addLeg(newLeg);
|
||||||
|
|
||||||
const sipCallId = `${callId}-${legId}`;
|
const sipCallId = `${callId}-${legId}`;
|
||||||
const destUri = `sip:${number}@${provider.domain}`;
|
const destUri = `sip:${dialNumber}@${provider.domain}`;
|
||||||
newLeg.sendInvite({
|
newLeg.sendInvite({
|
||||||
fromUri: ps.registeredAor,
|
fromUri: ps.registeredAor,
|
||||||
toUri: destUri,
|
toUri: destUri,
|
||||||
@@ -923,7 +961,7 @@ export class CallManager {
|
|||||||
});
|
});
|
||||||
this.sipCallIdIndex.set(sipCallId, call);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
238
ts/config.ts
238
ts/config.ts
@@ -39,10 +39,85 @@ export interface IDeviceConfig {
|
|||||||
extension: string;
|
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 {
|
export interface IRoutingConfig {
|
||||||
outbound: { default: string };
|
routes: ISipRoute[];
|
||||||
inbound: Record<string, string[]>;
|
|
||||||
ringBrowsers?: Record<string, boolean>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IProxyConfig {
|
export interface IProxyConfig {
|
||||||
@@ -118,8 +193,9 @@ export function loadConfig(): IAppConfig {
|
|||||||
d.extension ??= '100';
|
d.extension ??= '100';
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.routing ??= { outbound: { default: cfg.providers[0].id }, inbound: {} };
|
cfg.routing ??= { routes: [] };
|
||||||
cfg.routing.outbound ??= { default: cfg.providers[0].id };
|
cfg.routing.routes ??= [];
|
||||||
|
|
||||||
cfg.contacts ??= [];
|
cfg.contacts ??= [];
|
||||||
for (const c of cfg.contacts) {
|
for (const c of cfg.contacts) {
|
||||||
c.starred ??= false;
|
c.starred ??= false;
|
||||||
@@ -128,6 +204,141 @@ export function loadConfig(): IAppConfig {
|
|||||||
return cfg;
|
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
|
// Lookup helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -140,14 +351,19 @@ export function getDevice(cfg: IAppConfig, id: string): IDeviceConfig | null {
|
|||||||
return cfg.devices.find((d) => d.id === id) ?? 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 {
|
export function getProviderForOutbound(cfg: IAppConfig): IProviderConfig | null {
|
||||||
const id = cfg.routing?.outbound?.default;
|
const result = resolveOutboundRoute(cfg, '');
|
||||||
if (!id) return cfg.providers[0] || null;
|
return result?.provider ?? null;
|
||||||
return getProvider(cfg, id) || cfg.providers[0] || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use resolveInboundRoute() instead. Kept for backward compat.
|
||||||
|
*/
|
||||||
export function getDevicesForInbound(cfg: IAppConfig, providerId: string): IDeviceConfig[] {
|
export function getDevicesForInbound(cfg: IAppConfig, providerId: string): IDeviceConfig[] {
|
||||||
const ids = cfg.routing.inbound[providerId];
|
const result = resolveInboundRoute(cfg, providerId, '', '');
|
||||||
if (!ids?.length) return cfg.devices; // fallback: ring all devices
|
if (!result.deviceIds.length) return cfg.devices;
|
||||||
return ids.map((id) => getDevice(cfg, id)).filter(Boolean) as IDeviceConfig[];
|
return result.deviceIds.map((id) => getDevice(cfg, id)).filter(Boolean) as IDeviceConfig[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,11 +221,12 @@ async function handleRequest(
|
|||||||
// Remove a provider.
|
// Remove a provider.
|
||||||
if (updates.removeProvider) {
|
if (updates.removeProvider) {
|
||||||
cfg.providers = (cfg.providers || []).filter((p: any) => p.id !== updates.removeProvider);
|
cfg.providers = (cfg.providers || []).filter((p: any) => p.id !== updates.removeProvider);
|
||||||
// Clean up routing references.
|
// Clean up routing references — remove routes that reference this provider.
|
||||||
if (cfg.routing?.inbound) delete cfg.routing.inbound[updates.removeProvider];
|
if (cfg.routing?.routes) {
|
||||||
if (cfg.routing?.ringBrowsers) delete cfg.routing.ringBrowsers[updates.removeProvider];
|
cfg.routing.routes = cfg.routing.routes.filter((r: any) =>
|
||||||
if (cfg.routing?.outbound?.default === updates.removeProvider) {
|
r.match?.sourceProvider !== updates.removeProvider &&
|
||||||
cfg.routing.outbound.default = cfg.providers[0]?.id || '';
|
r.action?.provider !== updates.removeProvider
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,8 +237,9 @@ async function handleRequest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (updates.routing) {
|
if (updates.routing) {
|
||||||
if (updates.routing.inbound) cfg.routing.inbound = { ...cfg.routing.inbound, ...updates.routing.inbound };
|
if (updates.routing.routes) {
|
||||||
if (updates.routing.ringBrowsers) cfg.routing.ringBrowsers = { ...cfg.routing.ringBrowsers, ...updates.routing.ringBrowsers };
|
cfg.routing.routes = updates.routing.routes;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (updates.contacts !== undefined) cfg.contacts = updates.contacts;
|
if (updates.contacts !== undefined) cfg.contacts = updates.contacts;
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { Buffer } from 'node:buffer';
|
|||||||
|
|
||||||
import { SipMessage } from './sip/index.ts';
|
import { SipMessage } from './sip/index.ts';
|
||||||
import type { IEndpoint } 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 type { IAppConfig, IProviderConfig } from './config.ts';
|
||||||
import {
|
import {
|
||||||
initProviderStates,
|
initProviderStates,
|
||||||
@@ -151,8 +151,8 @@ sock.on('message', (data: Buffer, rinfo: dgram.RemoteInfo) => {
|
|||||||
logPacket(`UP->DN RAW (unparsed) from ${rinfo.address}:${rinfo.port}`, data);
|
logPacket(`UP->DN RAW (unparsed) from ${rinfo.address}:${rinfo.port}`, data);
|
||||||
} else {
|
} else {
|
||||||
// From device, forward to default provider.
|
// From device, forward to default provider.
|
||||||
const dp = getProviderForOutbound(appConfig);
|
const dp = resolveOutboundRoute(appConfig, '');
|
||||||
if (dp) sock.send(data, dp.outboundProxy.port, dp.outboundProxy.address);
|
if (dp) sock.send(data, dp.provider.outboundProxy.port, dp.provider.outboundProxy.address);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -189,11 +189,22 @@ sock.on('message', (data: Buffer, rinfo: dgram.RemoteInfo) => {
|
|||||||
// 5. New outbound call from device (passthrough).
|
// 5. New outbound call from device (passthrough).
|
||||||
if (!ps && msg.isRequest && msg.method === 'INVITE') {
|
if (!ps && msg.isRequest && msg.method === 'INVITE') {
|
||||||
logPacket(`[new outbound] INVITE from ${rinfo.address}:${rinfo.port}`, data);
|
logPacket(`[new outbound] INVITE from ${rinfo.address}:${rinfo.port}`, data);
|
||||||
const provider = getProviderForOutbound(appConfig);
|
const dialedNumber = SipMessage.extractUri(msg.requestUri || '') || '';
|
||||||
if (provider) {
|
const routeResult = resolveOutboundRoute(
|
||||||
const provState = providerStates.get(provider.id);
|
appConfig,
|
||||||
|
dialedNumber,
|
||||||
|
undefined,
|
||||||
|
(pid) => !!providerStates.get(pid)?.registeredAor,
|
||||||
|
);
|
||||||
|
if (routeResult) {
|
||||||
|
const provState = providerStates.get(routeResult.provider.id);
|
||||||
if (provState) {
|
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;
|
return;
|
||||||
@@ -212,8 +223,8 @@ sock.on('message', (data: Buffer, rinfo: dgram.RemoteInfo) => {
|
|||||||
} else {
|
} else {
|
||||||
// From device -> forward to provider.
|
// From device -> forward to provider.
|
||||||
logPacket(`[fallback outbound] from ${rinfo.address}:${rinfo.port}`, data);
|
logPacket(`[fallback outbound] from ${rinfo.address}:${rinfo.port}`, data);
|
||||||
const provider = getProviderForOutbound(appConfig);
|
const fallback = resolveOutboundRoute(appConfig, '');
|
||||||
if (provider) sock.send(msg.serialize(), provider.outboundProxy.port, provider.outboundProxy.address);
|
if (fallback) sock.send(msg.serialize(), fallback.provider.outboundProxy.port, fallback.provider.outboundProxy.address);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
log(`[err] ${e?.stack || e}`);
|
log(`[err] ${e?.stack || e}`);
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: 'siprouter',
|
name: 'siprouter',
|
||||||
version: '1.8.0',
|
version: '1.9.0',
|
||||||
description: 'undefined'
|
description: 'undefined'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export * from './sipproxy-view-phone.js';
|
|||||||
export * from './sipproxy-view-contacts.js';
|
export * from './sipproxy-view-contacts.js';
|
||||||
export * from './sipproxy-view-providers.js';
|
export * from './sipproxy-view-providers.js';
|
||||||
export * from './sipproxy-view-log.js';
|
export * from './sipproxy-view-log.js';
|
||||||
|
export * from './sipproxy-view-routes.js';
|
||||||
|
|
||||||
// Sub-components (used within views)
|
// Sub-components (used within views)
|
||||||
export * from './sipproxy-devices.js';
|
export * from './sipproxy-devices.js';
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { SipproxyViewPhone } from './sipproxy-view-phone.js';
|
|||||||
import { SipproxyViewContacts } from './sipproxy-view-contacts.js';
|
import { SipproxyViewContacts } from './sipproxy-view-contacts.js';
|
||||||
import { SipproxyViewProviders } from './sipproxy-view-providers.js';
|
import { SipproxyViewProviders } from './sipproxy-view-providers.js';
|
||||||
import { SipproxyViewLog } from './sipproxy-view-log.js';
|
import { SipproxyViewLog } from './sipproxy-view-log.js';
|
||||||
|
import { SipproxyViewRoutes } from './sipproxy-view-routes.js';
|
||||||
|
|
||||||
const VIEW_TABS = [
|
const VIEW_TABS = [
|
||||||
{ name: 'Overview', iconName: 'lucide:layoutDashboard', element: SipproxyViewOverview },
|
{ name: 'Overview', iconName: 'lucide:layoutDashboard', element: SipproxyViewOverview },
|
||||||
{ name: 'Calls', iconName: 'lucide:phone', element: SipproxyViewCalls },
|
{ name: 'Calls', iconName: 'lucide:phone', element: SipproxyViewCalls },
|
||||||
{ name: 'Phone', iconName: 'lucide:headset', element: SipproxyViewPhone },
|
{ name: 'Phone', iconName: 'lucide:headset', element: SipproxyViewPhone },
|
||||||
|
{ name: 'Routes', iconName: 'lucide:route', element: SipproxyViewRoutes },
|
||||||
{ name: 'Contacts', iconName: 'lucide:contactRound', element: SipproxyViewContacts },
|
{ name: 'Contacts', iconName: 'lucide:contactRound', element: SipproxyViewContacts },
|
||||||
{ name: 'Providers', iconName: 'lucide:server', element: SipproxyViewProviders },
|
{ name: 'Providers', iconName: 'lucide:server', element: SipproxyViewProviders },
|
||||||
{ name: 'Log', iconName: 'lucide:scrollText', element: SipproxyViewLog },
|
{ name: 'Log', iconName: 'lucide:scrollText', element: SipproxyViewLog },
|
||||||
|
|||||||
@@ -367,8 +367,14 @@ export class SipproxyViewProviders extends DeesElement {
|
|||||||
registerIntervalSec: String(provider.registerIntervalSec ?? 300),
|
registerIntervalSec: String(provider.registerIntervalSec ?? 300),
|
||||||
codecs: (provider.codecs || []).join(', '),
|
codecs: (provider.codecs || []).join(', '),
|
||||||
earlyMediaSilence: provider.quirks?.earlyMediaSilence ?? false,
|
earlyMediaSilence: provider.quirks?.earlyMediaSilence ?? false,
|
||||||
inboundDevices: [...(cfg.routing?.inbound?.[providerId] || [])] as string[],
|
inboundDevices: (() => {
|
||||||
ringBrowsers: cfg.routing?.ringBrowsers?.[providerId] ?? false,
|
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({
|
await DeesModal.createAndShow({
|
||||||
@@ -484,6 +490,28 @@ export class SipproxyViewProviders extends DeesElement {
|
|||||||
.map((s: string) => parseInt(s.trim(), 10))
|
.map((s: string) => parseInt(s.trim(), 10))
|
||||||
.filter((n: number) => !isNaN(n));
|
.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 = {
|
const updates: any = {
|
||||||
providers: [{
|
providers: [{
|
||||||
id: providerId,
|
id: providerId,
|
||||||
@@ -500,10 +528,7 @@ export class SipproxyViewProviders extends DeesElement {
|
|||||||
earlyMediaSilence: formData.earlyMediaSilence,
|
earlyMediaSilence: formData.earlyMediaSilence,
|
||||||
},
|
},
|
||||||
}] as any[],
|
}] as any[],
|
||||||
routing: {
|
routing: { routes: currentRoutes },
|
||||||
inbound: { [providerId]: formData.inboundDevices },
|
|
||||||
ringBrowsers: { [providerId]: formData.ringBrowsers },
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only send password if it was changed (not the masked placeholder).
|
// Only send password if it was changed (not the masked placeholder).
|
||||||
|
|||||||
405
ts_web/elements/sipproxy-view-routes.ts
Normal file
405
ts_web/elements/sipproxy-view-routes.ts
Normal file
@@ -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`
|
||||||
|
<div class="view-section">
|
||||||
|
<dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="view-section">
|
||||||
|
<dees-table
|
||||||
|
heading1="Call Routes"
|
||||||
|
heading2="${routes.length} configured"
|
||||||
|
dataName="routes"
|
||||||
|
.data=${sorted}
|
||||||
|
.rowKey=${'id'}
|
||||||
|
.columns=${this.getColumns()}
|
||||||
|
.dataActions=${this.getDataActions()}
|
||||||
|
></dees-table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getColumns() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'priority',
|
||||||
|
header: 'Priority',
|
||||||
|
sortable: true,
|
||||||
|
renderer: (val: number) =>
|
||||||
|
html`<span style="font-weight:600;color:#94a3b8">${val}</span>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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`<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;background:${bg};color:${color}">${dir}</span>`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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`<span style="color:#64748b;font-style:italic">catch-all</span>`;
|
||||||
|
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.82rem">${parts.join(', ')}</span>`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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`<span style="font-family:'JetBrains Mono',monospace;font-size:.82rem">${parts.join(' ')}</span>`;
|
||||||
|
} 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`<span style="font-family:'JetBrains Mono',monospace;font-size:.82rem">${parts.join(' ')}</span>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'enabled',
|
||||||
|
header: 'Status',
|
||||||
|
renderer: (val: boolean) => {
|
||||||
|
const color = val ? '#4ade80' : '#71717a';
|
||||||
|
const bg = val ? '#1a3c2a' : '#3f3f46';
|
||||||
|
return html`<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;background:${bg};color:${color}">${val ? 'Active' : 'Disabled'}</span>`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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`
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'name'} .label=${'Route Name'} .value=${formData.name}
|
||||||
|
@input=${(e: Event) => { formData.name = (e.target as any).value; }}
|
||||||
|
></dees-input-text>
|
||||||
|
|
||||||
|
<dees-input-dropdown
|
||||||
|
.key=${'direction'} .label=${'Direction'}
|
||||||
|
.selectedOption=${formData.match.direction === 'inbound'
|
||||||
|
? { option: 'inbound', key: 'inbound' }
|
||||||
|
: { option: 'outbound', key: 'outbound' }}
|
||||||
|
.options=${[
|
||||||
|
{ option: 'inbound', key: 'inbound' },
|
||||||
|
{ option: 'outbound', key: 'outbound' },
|
||||||
|
]}
|
||||||
|
@selectedOption=${(e: CustomEvent) => { formData.match.direction = e.detail.key; }}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'priority'} .label=${'Priority (higher = matched first)'}
|
||||||
|
.value=${String(formData.priority)}
|
||||||
|
@input=${(e: Event) => { formData.priority = parseInt((e.target as any).value, 10) || 0; }}
|
||||||
|
></dees-input-text>
|
||||||
|
|
||||||
|
<dees-input-checkbox
|
||||||
|
.key=${'enabled'} .label=${'Enabled'} .value=${formData.enabled}
|
||||||
|
@newValue=${(e: CustomEvent) => { formData.enabled = e.detail; }}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
|
||||||
|
<div style="margin-top:8px;padding-top:12px;border-top:1px solid #334155;">
|
||||||
|
<div style="font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;font-weight:600;">Match Criteria</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'numberPattern'}
|
||||||
|
.label=${'Number Pattern'}
|
||||||
|
.description=${'Exact, prefix with * (e.g. +49*), or /regex/'}
|
||||||
|
.value=${formData.match.numberPattern || ''}
|
||||||
|
@input=${(e: Event) => { formData.match.numberPattern = (e.target as any).value || undefined; }}
|
||||||
|
></dees-input-text>
|
||||||
|
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'callerPattern'}
|
||||||
|
.label=${'Caller Pattern'}
|
||||||
|
.description=${'Match caller ID (same syntax)'}
|
||||||
|
.value=${formData.match.callerPattern || ''}
|
||||||
|
@input=${(e: Event) => { formData.match.callerPattern = (e.target as any).value || undefined; }}
|
||||||
|
></dees-input-text>
|
||||||
|
|
||||||
|
<dees-input-dropdown
|
||||||
|
.key=${'sourceProvider'} .label=${'Source Provider (inbound)'}
|
||||||
|
.selectedOption=${formData.match.sourceProvider
|
||||||
|
? { option: formData.match.sourceProvider, key: formData.match.sourceProvider }
|
||||||
|
: { option: '(any)', key: '' }}
|
||||||
|
.options=${[
|
||||||
|
{ option: '(any)', key: '' },
|
||||||
|
...providers.map((p: any) => ({ option: p.displayName || p.id, key: p.id })),
|
||||||
|
]}
|
||||||
|
@selectedOption=${(e: CustomEvent) => { formData.match.sourceProvider = e.detail.key || undefined; }}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
|
||||||
|
<div style="margin-top:8px;padding-top:12px;border-top:1px solid #334155;">
|
||||||
|
<div style="font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;font-weight:600;">Action</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dees-input-dropdown
|
||||||
|
.key=${'provider'} .label=${'Outbound Provider'}
|
||||||
|
.selectedOption=${formData.action.provider
|
||||||
|
? { option: formData.action.provider, key: formData.action.provider }
|
||||||
|
: { option: '(none)', key: '' }}
|
||||||
|
.options=${[
|
||||||
|
{ option: '(none)', key: '' },
|
||||||
|
...providers.map((p: any) => ({ option: p.displayName || p.id, key: p.id })),
|
||||||
|
]}
|
||||||
|
@selectedOption=${(e: CustomEvent) => { formData.action.provider = e.detail.key || undefined; }}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'targets'}
|
||||||
|
.label=${'Ring Devices (comma-separated IDs)'}
|
||||||
|
.description=${'Leave empty to ring all devices'}
|
||||||
|
.value=${(formData.action.targets || []).join(', ')}
|
||||||
|
@input=${(e: Event) => {
|
||||||
|
const v = (e.target as any).value.trim();
|
||||||
|
formData.action.targets = v ? v.split(',').map((s: string) => s.trim()) : undefined;
|
||||||
|
}}
|
||||||
|
></dees-input-text>
|
||||||
|
|
||||||
|
<dees-input-checkbox
|
||||||
|
.key=${'ringBrowsers'} .label=${'Ring browser clients'}
|
||||||
|
.value=${formData.action.ringBrowsers ?? false}
|
||||||
|
@newValue=${(e: CustomEvent) => { formData.action.ringBrowsers = e.detail; }}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'stripPrefix'} .label=${'Strip Prefix (outbound)'}
|
||||||
|
.value=${formData.action.stripPrefix || ''}
|
||||||
|
@input=${(e: Event) => { formData.action.stripPrefix = (e.target as any).value || undefined; }}
|
||||||
|
></dees-input-text>
|
||||||
|
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'prependPrefix'} .label=${'Prepend Prefix (outbound)'}
|
||||||
|
.value=${formData.action.prependPrefix || ''}
|
||||||
|
@input=${(e: Event) => { formData.action.prependPrefix = (e.target as any).value || undefined; }}
|
||||||
|
></dees-input-text>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* Maps URL paths to views in dees-simple-appdash.
|
* 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];
|
type TViewSlug = (typeof VIEWS)[number];
|
||||||
|
|
||||||
class AppRouter {
|
class AppRouter {
|
||||||
|
|||||||
Reference in New Issue
Block a user