outbound: routes with failover and caller ID, test box, Outbound rules page

Outbound routes CRUD, reorder and POST /test (mirrors the dialplan:
911 and internal numbers first, then the first matching enabled route,
class check, usable trunks, caller ID priority route > user > default).
The first trunk creates the default rules: toll-free first (because
1NXXNXXXXXX also matches 1800...), US/Canada, international disabled.

Generator: Asterisk orders patterns in one context by specificity, not
by file order, so each route gets its own sp-or-{id} context and
sp-outbound-routes includes them in position order (spec 03 §6.12
updated). sp-route-{id}: class check -> ss-noservice, caller ID
priority, per-trunk GROUP channel limit, failover on CHANUNAVAIL or
CONGESTION, all-circuits-busy-now when every trunk is full or off.

UI: Outbound rules page (↑/↓ reorder, pattern rows with help, ordered
trunk list, caller ID override, test box) and a Calling tab on users
(calling permission, outbound caller ID).

trunk-test.ts: 101 dials 3125550000 -> the fake provider receives
13125550000 with the DID in Remote-Party-ID (From user is the
sub-account), two-way audio; a caller without a class hears
ss-noservice. scripts/e2e/m3-t3.mjs covers the UI. With real voip.ms
still to do: whether per-call caller ID is honored (06 §4).

Task: M3-T3
This commit is contained in:
iamdoubz committed 2026-10-17 20:04:35 -05:00
1 parent c117d8bb1d
commit 4496f6b89a
33 files changed
+1686 -70

No files matched your search

+2
View File
@@ -16,6 +16,7 @@ import { systemRoutes } from './routes/system.ts';
import { extensionRoutes } from './routes/extensions.ts'; import { extensionRoutes } from './routes/extensions.ts';
import { trunkRoutes } from './routes/trunks.ts'; import { trunkRoutes } from './routes/trunks.ts';
import { didRoutes } from './routes/dids.ts'; import { didRoutes } from './routes/dids.ts';
import { outboundRouteRoutes } from './routes/outbound.ts';
import { liveRoutes } from './live/hub.ts'; import { liveRoutes } from './live/hub.ts';
declare module 'fastify' { declare module 'fastify' {
@@ -83,6 +84,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(extensionRoutes); app.register(extensionRoutes);
app.register(trunkRoutes); app.register(trunkRoutes);
app.register(didRoutes); app.register(didRoutes);
app.register(outboundRouteRoutes);
app.register(liveRoutes); app.register(liveRoutes);
if (existsSync(publicDir)) { if (existsSync(publicDir)) {
@@ -106,12 +106,12 @@ include => sp-parkedcalls
; anything else ; anything else
exten => _[0-9a-z].,1,Hangup() exten => _[0-9a-z].,1,Hangup()
[sp-outbound-routes]
exten => pending,1,Hangup() ; no routes yet
; ─── Filled in by later milestones ; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14) [sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup() exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13) [sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup() exten => pending,1,Hangup()
[sp-outbound-routes] ; M3-T3 (03 §6.12)
exten => pending,1,Hangup()
@@ -115,12 +115,12 @@ exten => 105,hint,PJSIP/105&PJSIP/105-web
; anything else ; anything else
exten => _[0-9a-z].,1,Hangup() exten => _[0-9a-z].,1,Hangup()
[sp-outbound-routes]
exten => pending,1,Hangup() ; no routes yet
; ─── Filled in by later milestones ; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14) [sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup() exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13) [sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup() exten => pending,1,Hangup()
[sp-outbound-routes] ; M3-T3 (03 §6.12)
exten => pending,1,Hangup()
@@ -115,12 +115,12 @@ exten => 13125551234,1,Set(__SP_DID=13125551234)
; anything else ; anything else
exten => _[0-9a-z].,1,Hangup() exten => _[0-9a-z].,1,Hangup()
[sp-outbound-routes]
exten => pending,1,Hangup() ; no routes yet
; ─── Filled in by later milestones ; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14) [sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup() exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13) [sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup() exten => pending,1,Hangup()
[sp-outbound-routes] ; M3-T3 (03 §6.12)
exten => pending,1,Hangup()
@@ -140,12 +140,56 @@ exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
; anything else ; anything else
exten => _[0-9a-z].,1,Hangup() exten => _[0-9a-z].,1,Hangup()
[sp-outbound-routes]
include => sp-or-bbbbbbbb-0000-4000-8000-000000000001 ; First
include => sp-or-bbbbbbbb-0000-4000-8000-000000000002 ; Second evil
[sp-or-bbbbbbbb-0000-4000-8000-000000000001]
exten => _1[2-9]XXNXXXXXX,1,Goto(sp-route-bbbbbbbb-0000-4000-8000-000000000001,${EXTEN:0},1)
[sp-route-bbbbbbbb-0000-4000-8000-000000000001]
exten => _X.,1,Set(__SP_DIRECTION=outbound)
same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 2]?denied)
same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))
; caller ID: route override > user > the DID a forwarded call came in on > default
same => n,Set(CID=)
same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_OUTCID}))
same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DID}))
same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DEFAULT_CID}))
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
same => n(busy),Playback(all-circuits-busy-now)
same => n(done),Hangup()
same => n(denied),Playback(ss-noservice)
same => n,Hangup()
[sp-or-bbbbbbbb-0000-4000-8000-000000000002]
exten => _9NXXNXXXXXX,1,Goto(sp-route-bbbbbbbb-0000-4000-8000-000000000002,1${EXTEN:1},1)
[sp-route-bbbbbbbb-0000-4000-8000-000000000002]
exten => _X.,1,Set(__SP_DIRECTION=outbound)
same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 1]?denied)
same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))
; caller ID: route override > user > the DID a forwarded call came in on > default
same => n,Set(CID=15551230000)
same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_OUTCID}))
same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DID}))
same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DEFAULT_CID}))
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
same => n,Set(GROUP(sptrunk)=a1b2c3d4-0000-4000-8000-000000000002)
same => n,GotoIf($[${GROUP_COUNT(a1b2c3d4-0000-4000-8000-000000000002@sptrunk)} > 4]?t1)
same => n,Dial(PJSIP/${EXTEN}@trunk-a1b2c3d4-0000-4000-8000-000000000002,120)
same => n,GotoIf($["${DIALSTATUS}" != "CHANUNAVAIL" & "${DIALSTATUS}" != "CONGESTION"]?done)
same => n(t1),Set(GROUP(sptrunk)=a1b2c3d4-0000-4000-8000-000000000001)
same => n,Dial(PJSIP/${EXTEN}@trunk-a1b2c3d4-0000-4000-8000-000000000001,120)
same => n,GotoIf($["${DIALSTATUS}" != "CHANUNAVAIL" & "${DIALSTATUS}" != "CONGESTION"]?done)
same => n(busy),Playback(all-circuits-busy-now)
same => n(done),Hangup()
same => n(denied),Playback(ss-noservice)
same => n,Hangup()
; ─── Filled in by later milestones ; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14) [sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup() exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13) [sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup() exten => pending,1,Hangup()
[sp-outbound-routes] ; M3-T3 (03 §6.12)
exten => pending,1,Hangup()
@@ -1,5 +1,7 @@
{ {
"settings": { "sip.tcpEnabled": true }, "settings": {
"sip.tcpEnabled": true
},
"trunks": [ "trunks": [
{ {
"id": "a1b2c3d4-0000-4000-8000-000000000001", "id": "a1b2c3d4-0000-4000-8000-000000000001",
@@ -21,12 +23,53 @@
"password": "p@ss w0rd!#", "password": "p@ss w0rd!#",
"fromUser": "15551230000", "fromUser": "15551230000",
"fromDomain": "example.net", "fromDomain": "example.net",
"codecs": ["g722", "ulaw"] "codecs": ["g722", "ulaw"],
"maxChannels": 4
}, },
{ {
"id": "a1b2c3d4-0000-4000-8000-000000000003", "id": "a1b2c3d4-0000-4000-8000-000000000003",
"name": "Old provider", "name": "Old provider",
"enabled": false "enabled": false
} }
],
"routes": [
{
"id": "bbbbbbbb-0000-4000-8000-000000000002",
"name": "Second; [evil]",
"position": 5,
"patterns": [
{
"match": "9NXXNXXXXXX",
"strip": 1,
"prepend": "1"
},
{
"match": "9NXXNXXXXXX",
"strip": 1,
"prepend": "1"
}
],
"requiredClass": "local",
"trunkIds": [
"a1b2c3d4-0000-4000-8000-000000000002",
"a1b2c3d4-0000-4000-8000-000000000003",
"a1b2c3d4-0000-4000-8000-000000000001"
],
"callerId": "15551230000"
},
{
"id": "bbbbbbbb-0000-4000-8000-000000000001",
"name": "First",
"position": 1,
"patterns": [
{
"match": "1[2-9]XXNXXXXXX",
"strip": 0,
"prepend": ""
}
],
"requiredClass": "national",
"trunkIds": ["a1b2c3d4-0000-4000-8000-000000000003"]
}
] ]
} }
@@ -137,12 +137,64 @@ exten => 13125559876,1,Set(__SP_DID=13125559876)
; anything else ; anything else
exten => _[0-9a-z].,1,Hangup() exten => _[0-9a-z].,1,Hangup()
[sp-outbound-routes]
include => sp-or-aaaaaaaa-0000-4000-8000-000000000002 ; Toll-free
include => sp-or-aaaaaaaa-0000-4000-8000-000000000001 ; Local/National NANP
[sp-or-aaaaaaaa-0000-4000-8000-000000000002]
exten => _1800NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
exten => _1888NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
exten => _1877NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
exten => _1866NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
exten => _1855NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
exten => _1844NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
exten => _1833NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
[sp-route-aaaaaaaa-0000-4000-8000-000000000002]
exten => _X.,1,Set(__SP_DIRECTION=outbound)
same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 1]?denied)
same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))
; caller ID: route override > user > the DID a forwarded call came in on > default
same => n,Set(CID=)
same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_OUTCID}))
same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DID}))
same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DEFAULT_CID}))
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
same => n,Set(GROUP(sptrunk)=7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37)
same => n,GotoIf($[${GROUP_COUNT(7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37@sptrunk)} > 2]?busy)
same => n,Dial(PJSIP/${EXTEN}@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,120)
same => n,GotoIf($["${DIALSTATUS}" != "CHANUNAVAIL" & "${DIALSTATUS}" != "CONGESTION"]?done)
same => n(busy),Playback(all-circuits-busy-now)
same => n(done),Hangup()
same => n(denied),Playback(ss-noservice)
same => n,Hangup()
[sp-or-aaaaaaaa-0000-4000-8000-000000000001]
exten => _NXXNXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000001,1${EXTEN:0},1)
exten => _1NXXNXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000001,${EXTEN:0},1)
[sp-route-aaaaaaaa-0000-4000-8000-000000000001]
exten => _X.,1,Set(__SP_DIRECTION=outbound)
same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 2]?denied)
same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))
; caller ID: route override > user > the DID a forwarded call came in on > default
same => n,Set(CID=)
same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_OUTCID}))
same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DID}))
same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DEFAULT_CID}))
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
same => n,Set(GROUP(sptrunk)=7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37)
same => n,GotoIf($[${GROUP_COUNT(7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37@sptrunk)} > 2]?busy)
same => n,Dial(PJSIP/${EXTEN}@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,120)
same => n,GotoIf($["${DIALSTATUS}" != "CHANUNAVAIL" & "${DIALSTATUS}" != "CONGESTION"]?done)
same => n(busy),Playback(all-circuits-busy-now)
same => n(done),Hangup()
same => n(denied),Playback(ss-noservice)
same => n,Hangup()
; ─── Filled in by later milestones ; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14) [sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup() exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13) [sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup() exten => pending,1,Hangup()
[sp-outbound-routes] ; M3-T3 (03 §6.12)
exten => pending,1,Hangup()
@@ -3,21 +3,117 @@
"routing.defaultCallerIdDidId": "11111111-1111-4111-8111-111111111111", "routing.defaultCallerIdDidId": "11111111-1111-4111-8111-111111111111",
"emergency.defaultCallerIdDidId": "11111111-1111-4111-8111-111111111111" "emergency.defaultCallerIdDidId": "11111111-1111-4111-8111-111111111111"
}, },
"extensions": [{ "number": "101", "name": "Dan Doe" }], "extensions": [
"trunks": [{ "id": "7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37" }], {
"number": "101",
"name": "Dan Doe"
}
],
"trunks": [
{
"id": "7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37",
"maxChannels": 2
}
],
"dids": [ "dids": [
{ {
"id": "11111111-1111-4111-8111-111111111111", "id": "11111111-1111-4111-8111-111111111111",
"number": "13125551234", "number": "13125551234",
"label": "Main line", "label": "Main line",
"destination": { "type": "user", "id": "00000000-0000-4000-8000-000000000000" } "destination": {
"type": "user",
"id": "00000000-0000-4000-8000-000000000000"
}
}, },
{ {
"id": "22222222-2222-4222-8222-222222222222", "id": "22222222-2222-4222-8222-222222222222",
"number": "13125559876", "number": "13125559876",
"label": "Sales; line", "label": "Sales; line",
"callerIdNamePrefix": "SALES:", "callerIdNamePrefix": "SALES:",
"destination": { "type": "external", "number": "13125550000" } "destination": {
"type": "external",
"number": "13125550000"
}
}
],
"routes": [
{
"id": "aaaaaaaa-0000-4000-8000-000000000001",
"name": "Local/National (NANP)",
"position": 1,
"patterns": [
{
"match": "NXXNXXXXXX",
"strip": 0,
"prepend": "1"
},
{
"match": "1NXXNXXXXXX",
"strip": 0,
"prepend": ""
}
],
"requiredClass": "national",
"trunkIds": ["7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37"]
},
{
"id": "aaaaaaaa-0000-4000-8000-000000000002",
"name": "Toll-free",
"position": 0,
"patterns": [
{
"match": "1800NXXXXXX",
"strip": 0,
"prepend": ""
},
{
"match": "1888NXXXXXX",
"strip": 0,
"prepend": ""
},
{
"match": "1877NXXXXXX",
"strip": 0,
"prepend": ""
},
{
"match": "1866NXXXXXX",
"strip": 0,
"prepend": ""
},
{
"match": "1855NXXXXXX",
"strip": 0,
"prepend": ""
},
{
"match": "1844NXXXXXX",
"strip": 0,
"prepend": ""
},
{
"match": "1833NXXXXXX",
"strip": 0,
"prepend": ""
}
],
"requiredClass": "local",
"trunkIds": ["7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37"]
},
{
"id": "aaaaaaaa-0000-4000-8000-000000000003",
"name": "International",
"position": 2,
"enabled": false,
"patterns": [
{
"match": "011.",
"strip": 0,
"prepend": ""
}
],
"requiredClass": "international",
"trunkIds": ["7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37"]
} }
] ]
} }
@@ -112,12 +112,12 @@ exten => 102,hint,PJSIP/102
; anything else ; anything else
exten => _[0-9a-z].,1,Hangup() exten => _[0-9a-z].,1,Hangup()
[sp-outbound-routes]
exten => pending,1,Hangup() ; no routes yet
; ─── Filled in by later milestones ; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14) [sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup() exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13) [sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup() exten => pending,1,Hangup()
[sp-outbound-routes] ; M3-T3 (03 §6.12)
exten => pending,1,Hangup()
+2 -4
View File
@@ -1,10 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// sp-local-numbers (03 §6.6) and the globals file (03 §6.3). // sp-local-numbers (03 §6.6) and the globals file (03 §6.3).
import { CLASS_RANK } from '@selfphoneng/shared';
import { byNumber, type Snapshot } from '../load.ts'; import { byNumber, type Snapshot } from '../load.ts';
import * as s from '../sanitize.ts'; import * as s from '../sanitize.ts';
const CLASS_NUMBER = { internal: 0, local: 1, national: 2, international: 3 } as const;
// TODO(M4): voicemail on. Until then every user gets ARG3 = 0 (09 M2-T4). // TODO(M4): voicemail on. Until then every user gets ARG3 = 0 (09 M2-T4).
const VOICEMAIL_READY = false; const VOICEMAIL_READY = false;
@@ -42,8 +41,7 @@ export function renderGlobals(snap: Snapshot): string {
'SP_REC_ANNOUNCE=', // M7: recordings.announcementSoundId → sound name 'SP_REC_ANNOUNCE=', // M7: recordings.announcementSoundId → sound name
]; ];
const exts = [...snap.extensions].sort(byNumber); const exts = [...snap.extensions].sort(byNumber);
for (const e of exts) for (const e of exts) lines.push(`SP_CLASS_${s.digits(e.number)}=${CLASS_RANK[e.outboundClass]}`);
lines.push(`SP_CLASS_${s.digits(e.number)}=${CLASS_NUMBER[e.outboundClass]}`);
for (const e of exts) if (e.isAdmin) lines.push(`SP_TCADMIN_${s.digits(e.number)}=1`); for (const e of exts) if (e.isAdmin) lines.push(`SP_TCADMIN_${s.digits(e.number)}=1`);
return lines.join('\n') + '\n'; return lines.join('\n') + '\n';
} }
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Outbound routes (03 §6.12). Asterisk orders patterns inside one context by how specific they
// are, not by file order, so each route gets its own context and sp-outbound-routes includes
// them in position order: an include is only searched after the ones before it.
import { CLASS_RANK } from '@selfphoneng/shared';
import type { Snapshot } from '../load.ts';
import * as s from '../sanitize.ts';
export function renderOutbound(snap: Snapshot): string {
const trunks = new Map(snap.trunks.filter((t) => t.enabled).map((t) => [t.id, t]));
const routes = snap.routes
.filter((r) => r.enabled && r.patterns.length)
.sort((a, b) => a.position - b.position || a.id.localeCompare(b.id));
const top = ['[sp-outbound-routes]'];
if (!routes.length) top.push('exten => pending,1,Hangup() ; no routes yet');
const out: string[] = [];
for (const r of routes) {
const id = s.ident(r.id);
top.push(`include => sp-or-${id} ; ${s.name(r.name)}`);
const seen = new Set<string>();
const pat = [`[sp-or-${id}]`];
for (const p of r.patterns) {
const match = s.pattern(p.match);
if (seen.has(match)) continue; // a duplicate would be a load error
seen.add(match);
const strip = s.digits(String(p.strip));
const prepend = p.prepend ? s.dialable(p.prepend) : '';
pat.push(`exten => _${match},1,Goto(sp-route-${id},${prepend}\${EXTEN:${strip}},1)`);
}
const route = [
`[sp-route-${id}]`,
'exten => _X.,1,Set(__SP_DIRECTION=outbound)',
` same => n,GotoIf($[\${IF($["\${SP_CLASS}" = ""]?0:\${SP_CLASS})} < ${CLASS_RANK[r.requiredClass]}]?denied)`,
' same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))',
' ; caller ID: route override > user > the DID a forwarded call came in on > default',
` same => n,Set(CID=${r.callerId ? s.digits(r.callerId) : ''})`,
' same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_OUTCID}))',
' same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DID}))',
' same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DEFAULT_CID}))',
' same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))',
];
const tries = r.trunkIds.map((tid) => trunks.get(tid)).filter((t) => t !== undefined);
tries.forEach((t, i) => {
const tid = s.ident(t.id);
const next = i + 1 < tries.length ? `t${i + 1}` : 'busy';
const label = i === 0 ? ' same => n' : ` same => n(t${i})`;
route.push(`${label},Set(GROUP(sptrunk)=${tid})`);
if (t.maxChannels)
route.push(
` same => n,GotoIf($[\${GROUP_COUNT(${tid}@sptrunk)} > ${s.digits(String(t.maxChannels))}]?${next})`,
);
route.push(
` same => n,Dial(PJSIP/\${EXTEN}@trunk-${tid},120)`,
` same => n,GotoIf($["\${DIALSTATUS}" != "CHANUNAVAIL" & "\${DIALSTATUS}" != "CONGESTION"]?done)`,
);
});
route.push(
' same => n(busy),Playback(all-circuits-busy-now)', // every trunk full, down or off
' same => n(done),Hangup()',
' same => n(denied),Playback(ss-noservice)',
' same => n,Hangup()',
);
out.push(pat.join('\n'), route.join('\n'));
}
return [top.join('\n'), ...out].join('\n\n') + '\n';
}
@@ -8,7 +8,4 @@ exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13) [sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup() exten => pending,1,Hangup()
[sp-outbound-routes] ; M3-T3 (03 §6.12)
exten => pending,1,Hangup()
`; `;
+12
View File
@@ -20,6 +20,7 @@ export function loadCase(name: string): Snapshot {
extensions?: Partial<Snapshot['extensions'][number]>[]; extensions?: Partial<Snapshot['extensions'][number]>[];
dids?: Partial<Snapshot['dids'][number]>[]; dids?: Partial<Snapshot['dids'][number]>[];
trunks?: Partial<Snapshot['trunks'][number]>[]; trunks?: Partial<Snapshot['trunks'][number]>[];
routes?: Partial<Snapshot['routes'][number]>[];
}; };
const settings = Object.fromEntries( const settings = Object.fromEntries(
(Object.keys(SettingsSchemas) as SettingKey[]) (Object.keys(SettingsSchemas) as SettingKey[])
@@ -38,6 +39,17 @@ export function loadCase(name: string): Snapshot {
id: d.id!, id: d.id!,
number: d.number!, number: d.number!,
})), })),
routes: (raw.routes ?? []).map((r, i) => ({
name: `Route ${i + 1}`,
position: i,
enabled: true,
patterns: [],
requiredClass: 'national',
trunkIds: [],
callerId: null,
...r,
id: r.id!,
})),
trunks: (raw.trunks ?? []).map((t) => ({ trunks: (raw.trunks ?? []).map((t) => ({
name: 'voip.ms', name: 'voip.ms',
enabled: true, enabled: true,
+2
View File
@@ -14,6 +14,7 @@ import { STATIC_DIALPLAN } from './dialplan/static.ts';
import { renderGlobals, renderLocalNumbers } from './dialplan/local.ts'; import { renderGlobals, renderLocalNumbers } from './dialplan/local.ts';
import { PENDING_CONTEXTS } from './dialplan/pending.ts'; import { PENDING_CONTEXTS } from './dialplan/pending.ts';
import { renderDids, renderTrunkEntries } from './dialplan/inbound.ts'; import { renderDids, renderTrunkEntries } from './dialplan/inbound.ts';
import { renderOutbound } from './dialplan/outbound.ts';
export { renderTransports }; export { renderTransports };
@@ -46,6 +47,7 @@ const BODIES: Partial<Record<GeneratedFileName, (snap: Snapshot) => string>> = {
renderLocalNumbers(snap), renderLocalNumbers(snap),
renderTrunkEntries(snap), renderTrunkEntries(snap),
renderDids(snap), renderDids(snap),
renderOutbound(snap),
PENDING_CONTEXTS, PENDING_CONTEXTS,
] ]
.filter(Boolean) .filter(Boolean)
+26 -2
View File
@@ -3,10 +3,10 @@
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core'; import { alias } from 'drizzle-orm/pg-core';
import type { Db } from '../db/client.ts'; import type { Db } from '../db/client.ts';
import { dids, extensions, trunks, users } from '../db/schema.ts'; import { dids, extensions, outboundRoutes, trunks, users } from '../db/schema.ts';
import { decrypt } from '../crypto/secrets.ts'; import { decrypt } from '../crypto/secrets.ts';
import { getAllSettings } from '../services/settings.ts'; import { getAllSettings } from '../services/settings.ts';
import type { Destination } from '@selfphoneng/shared'; import type { Destination, OutboundClass, RoutePattern } from '@selfphoneng/shared';
export type SnapExtension = { export type SnapExtension = {
id: string; id: string;
@@ -36,6 +36,17 @@ export type SnapDid = {
callerIdNamePrefix: string | null; callerIdNamePrefix: string | null;
}; };
export type SnapRoute = {
id: string;
name: string;
position: number;
enabled: boolean;
patterns: RoutePattern[];
requiredClass: OutboundClass;
trunkIds: string[];
callerId: string | null; // the override DID's number
};
export type SnapTrunk = { export type SnapTrunk = {
id: string; id: string;
name: string; name: string;
@@ -60,6 +71,7 @@ export type Snapshot = {
extensions: SnapExtension[]; extensions: SnapExtension[];
dids: SnapDid[]; dids: SnapDid[];
trunks: SnapTrunk[]; trunks: SnapTrunk[];
routes: SnapRoute[];
}; };
export function todayIn(timezone: string, now = new Date()): string { export function todayIn(timezone: string, now = new Date()): string {
@@ -94,10 +106,22 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
}) })
.from(dids); .from(dids);
const trunkRows = await db.select().from(trunks); const trunkRows = await db.select().from(trunks);
const routeRows = await db.select().from(outboundRoutes);
const didNumber = new Map(didRows.map((d) => [d.id, d.number]));
return { return {
today: todayIn(settings['general.timezone'], now), today: todayIn(settings['general.timezone'], now),
settings, settings,
dids: didRows.map((d) => ({ ...d, destination: d.destination as Destination })), dids: didRows.map((d) => ({ ...d, destination: d.destination as Destination })),
routes: routeRows.map((r) => ({
id: r.id,
name: r.name,
position: r.position,
enabled: r.enabled,
patterns: r.patterns as RoutePattern[],
requiredClass: r.requiredClass,
trunkIds: r.trunkIds,
callerId: r.callerIdDidId ? (didNumber.get(r.callerIdDidId) ?? null) : null,
})),
trunks: trunkRows.map((t) => ({ trunks: trunkRows.map((t) => ({
id: t.id, id: t.id,
name: t.name, name: t.name,
+2 -3
View File
@@ -1,10 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// pjsip_selfphoneng.conf (03 §5) and rtp_selfphoneng.conf (03 §4). // pjsip_selfphoneng.conf (03 §5) and rtp_selfphoneng.conf (03 §4).
import { CLASS_RANK } from '@selfphoneng/shared';
import { byNumber, type SnapExtension, type SnapTrunk, type Snapshot } from './load.ts'; import { byNumber, type SnapExtension, type SnapTrunk, type Snapshot } from './load.ts';
import * as s from './sanitize.ts'; import * as s from './sanitize.ts';
const CLASS_NUMBER = { internal: 0, local: 1, national: 2, international: 3 } as const;
const section = (title: string, lines: (string | false | null | undefined)[]) => const section = (title: string, lines: (string | false | null | undefined)[]) =>
[`[${title}]`, ...lines.filter((l): l is string => typeof l === 'string')].join('\n'); [`[${title}]`, ...lines.filter((l): l is string => typeof l === 'string')].join('\n');
@@ -76,7 +75,7 @@ function extensionSections(e: SnapExtension): string {
const mailbox = e.voicemailEnabled ? `mailboxes = ${num}@default` : null; const mailbox = e.voicemailEnabled ? `mailboxes = ${num}@default` : null;
const vars = [ const vars = [
`set_var = SP_EXT=${num}`, `set_var = SP_EXT=${num}`,
`set_var = SP_CLASS=${CLASS_NUMBER[e.outboundClass]}`, `set_var = SP_CLASS=${CLASS_RANK[e.outboundClass]}`,
e.outboundCallerId && `set_var = SP_OUTCID=${s.digits(e.outboundCallerId)}`, e.outboundCallerId && `set_var = SP_OUTCID=${s.digits(e.outboundCallerId)}`,
e.emergencyCallerId && `set_var = SP_E911CID=${s.digits(e.emergencyCallerId)}`, e.emergencyCallerId && `set_var = SP_E911CID=${s.digits(e.emergencyCallerId)}`,
`set_var = SP_RECMODE=${e.recordMode}`, `set_var = SP_RECMODE=${e.recordMode}`,
+175
View File
@@ -0,0 +1,175 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/outbound-routes (04 §7.3, 03 §6.12). Admin only. Position = match order.
import { asc, eq, inArray, max } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
OutboundRouteCreateSchema,
OutboundRouteUpdateSchema,
RouteTestSchema,
type ListResponse,
type OutboundRouteDto,
type RoutePattern,
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { dids, outboundRoutes, trunks } from '../db/schema.ts';
import { badRequest, notFound } from '../errors.ts';
import { requireAdmin } from '../auth/guards.ts';
import { audit } from '../services/audit.ts';
import { testRoute } from '../services/outbound.ts';
type RouteRow = typeof outboundRoutes.$inferSelect;
const toDto = (r: RouteRow): OutboundRouteDto => ({
id: r.id,
name: r.name,
position: r.position,
enabled: r.enabled,
patterns: r.patterns as RoutePattern[],
requiredClass: r.requiredClass,
trunkIds: r.trunkIds,
callerIdDidId: r.callerIdDidId,
createdAt: r.createdAt.toISOString(),
});
const IdParams = z.object({ id: z.uuid() });
async function getRoute(db: Db, id: string): Promise<RouteRow> {
const [r] = await db.select().from(outboundRoutes).where(eq(outboundRoutes.id, id));
if (!r) throw notFound('Outbound rule not found');
return r;
}
/** trunk_ids is a plain uuid[] (no FK), so check it here. */
async function checkRefs(db: Db, b: { trunkIds?: string[]; callerIdDidId?: string | null }) {
if (b.trunkIds) {
if (new Set(b.trunkIds).size !== b.trunkIds.length) throw badRequest('A trunk is listed twice');
const found = await db
.select({ id: trunks.id })
.from(trunks)
.where(inArray(trunks.id, b.trunkIds));
if (found.length !== b.trunkIds.length) throw badRequest('One of those trunks does not exist');
}
if (b.callerIdDidId) {
const [d] = await db.select({ id: dids.id }).from(dids).where(eq(dids.id, b.callerIdDidId));
if (!d) throw badRequest('That caller ID number does not exist');
}
}
export async function outboundRouteRoutes(app: FastifyInstance) {
const { db } = app;
app.addHook('preHandler', requireAdmin);
app.get('/api/outbound-routes', async (): Promise<ListResponse<OutboundRouteDto>> => {
const rows = await db
.select()
.from(outboundRoutes)
.orderBy(asc(outboundRoutes.position), asc(outboundRoutes.id));
return { items: rows.map(toDto), nextCursor: null };
});
app.get('/api/outbound-routes/:id', async (req) => {
const { id } = IdParams.parse(req.params);
return toDto(await getRoute(db, id));
});
app.post('/api/outbound-routes', async (req, reply) => {
const body = OutboundRouteCreateSchema.parse(req.body);
await checkRefs(db, body);
const [{ last }] = (await db
.select({ last: max(outboundRoutes.position) })
.from(outboundRoutes)) as [{ last: number | null }];
const [created] = (await db
.insert(outboundRoutes)
.values({ ...body, position: (last ?? -1) + 1 })
.returning()) as [RouteRow];
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'create',
entityType: 'outbound_route',
entityId: created.id,
summary: `Created outbound rule ${created.name}`,
diff: { after: body },
});
app.generator.requestApply('api-change');
return reply.code(201).send(toDto(created));
});
app.patch('/api/outbound-routes/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const body = OutboundRouteUpdateSchema.parse(req.body);
const before = await getRoute(db, id);
await checkRefs(db, body);
const [after] = (await db
.update(outboundRoutes)
.set(body)
.where(eq(outboundRoutes.id, id))
.returning()) as [RouteRow];
const changed = (Object.keys(body) as (keyof typeof body)[]).filter(
(k) => JSON.stringify(before[k]) !== JSON.stringify(after[k]),
);
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'update',
entityType: 'outbound_route',
entityId: id,
summary: `Updated outbound rule ${after.name}${changed.length ? ` (${changed.join(', ')})` : ''}`,
diff: {
before: Object.fromEntries(changed.map((k) => [k, before[k]])),
after: Object.fromEntries(changed.map((k) => [k, after[k]])),
},
});
app.generator.requestApply('api-change');
return toDto(after);
});
app.delete('/api/outbound-routes/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const r = await getRoute(db, id);
await db.delete(outboundRoutes).where(eq(outboundRoutes.id, id));
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'delete',
entityType: 'outbound_route',
entityId: id,
summary: `Deleted outbound rule ${r.name}`,
diff: { before: toDto(r) },
});
app.generator.requestApply('api-change');
return { ok: true };
});
/** The full new order: every route id exactly once. */
app.post('/api/outbound-routes/reorder', async (req) => {
const { ids } = z.object({ ids: z.array(z.uuid()) }).parse(req.body);
const all = await db.select({ id: outboundRoutes.id }).from(outboundRoutes);
if (
ids.length !== all.length ||
new Set(ids).size !== ids.length ||
all.some((r) => !ids.includes(r.id))
)
throw badRequest('Send every outbound rule exactly once');
await db.transaction(async (tx) => {
for (const [position, id] of ids.entries())
await tx.update(outboundRoutes).set({ position }).where(eq(outboundRoutes.id, id));
});
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'reorder',
entityType: 'outbound_route',
summary: 'Reordered outbound rules',
diff: { after: ids },
});
app.generator.requestApply('api-change');
return { ok: true };
});
app.post('/api/outbound-routes/test', async (req) => {
const { extensionId, number } = RouteTestSchema.parse(req.body);
return testRoute(db, extensionId, number);
});
}
+4 -2
View File
@@ -7,6 +7,7 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { import {
TrunkCreateSchema, TrunkCreateSchema,
formatNumber,
TrunkUpdateSchema, TrunkUpdateSchema,
type ListResponse, type ListResponse,
type TrunkDto, type TrunkDto,
@@ -19,6 +20,7 @@ import { requireAdmin } from '../auth/guards.ts';
import { encrypt } from '../crypto/secrets.ts'; import { encrypt } from '../crypto/secrets.ts';
import { audit } from '../services/audit.ts'; import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts'; import { getSetting } from '../services/settings.ts';
import { createDefaultRoutes } from '../services/outbound.ts';
type TrunkRow = typeof trunks.$inferSelect; type TrunkRow = typeof trunks.$inferSelect;
@@ -106,7 +108,7 @@ export async function trunkRoutes(app: FastifyInstance) {
summary: `Created trunk ${created.name}`, summary: `Created trunk ${created.name}`,
diff: { after: toDto(created) }, diff: { after: toDto(created) },
}); });
// TODO(M3-T3): create the default outbound routes with the first trunk (06 §4). await createDefaultRoutes(db, created.id); // only when there are no routes yet (06 §4)
app.generator.requestApply('api-change'); app.generator.requestApply('api-change');
return reply.code(201).send(toDto(created)); return reply.code(201).send(toDto(created));
}); });
@@ -167,7 +169,7 @@ export async function trunkRoutes(app: FastifyInstance) {
const t = await getTrunk(db, id); const t = await getTrunk(db, id);
const usedBy = [ const usedBy = [
...(await db.select({ n: dids.number }).from(dids).where(eq(dids.trunkId, id))).map( ...(await db.select({ n: dids.number }).from(dids).where(eq(dids.trunkId, id))).map(
(r) => `number ${r.n}`, (r) => `number ${formatNumber(r.n)}`,
), ),
...( ...(
await db await db
+120
View File
@@ -0,0 +1,120 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Outbound routes: the defaults (06 §4) and "which route would this call use?" (04 §7.3).
import { asc, count, eq, inArray } from 'drizzle-orm';
import {
CLASS_RANK,
dialedDigits,
formatNumber,
patternRegex,
type RoutePattern,
type RouteTestResult,
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { dids, extensions, outboundRoutes, trunks } from '../db/schema.ts';
import { notFound } from '../errors.ts';
import { getSetting } from './settings.ts';
const TOLL_FREE = ['800', '888', '877', '866', '855', '844', '833'];
/**
* Created with the first trunk so calls work straight away; international starts disabled.
* Toll-free comes first: 1NXXNXXXXXX also matches 1800…, and the first matching rule wins.
*/
export async function createDefaultRoutes(db: Db, trunkId: string): Promise<void> {
const [{ n }] = (await db.select({ n: count() }).from(outboundRoutes)) as [{ n: number }];
if (n > 0) return;
await db.insert(outboundRoutes).values([
{
name: 'Toll-free',
position: 0,
patterns: TOLL_FREE.map((p) => ({ match: `1${p}NXXXXXX`, strip: 0, prepend: '' })),
requiredClass: 'local',
trunkIds: [trunkId],
},
{
name: 'Local and national (US/Canada)',
position: 1,
patterns: [
{ match: 'NXXNXXXXXX', strip: 0, prepend: '1' },
{ match: '1NXXNXXXXXX', strip: 0, prepend: '' },
],
requiredClass: 'national',
trunkIds: [trunkId],
},
{
name: 'International',
position: 2,
enabled: false, // toll-fraud protection: an admin turns it on deliberately (06 §4)
patterns: [{ match: '011.', strip: 0, prepend: '' }],
requiredClass: 'international',
trunkIds: [trunkId],
},
]);
}
/** Mirrors the generated dialplan: sp-from-ext tries emergency, internal numbers, then routes. */
export async function testRoute(
db: Db,
extensionId: string,
input: string,
): Promise<RouteTestResult> {
const [ext] = await db.select().from(extensions).where(eq(extensions.id, extensionId));
if (!ext) throw notFound('Extension not found');
const n = dialedDigits(input);
if (n === '911' || n === '9911')
return { ok: false, reason: 'Emergency call: always allowed, handled by the 911 rules' };
const [internal] = await db
.select({ id: extensions.id })
.from(extensions)
.where(eq(extensions.number, n));
if (internal) return { ok: false, reason: `${n} is an internal number, not an outside call` };
const routes = await db
.select()
.from(outboundRoutes)
.where(eq(outboundRoutes.enabled, true))
.orderBy(asc(outboundRoutes.position), asc(outboundRoutes.id));
for (const r of routes) {
const p = (r.patterns as RoutePattern[]).find((x) => patternRegex(x.match).test(n));
if (!p) continue;
if (CLASS_RANK[ext.outboundClass] < CLASS_RANK[r.requiredClass])
return {
ok: false,
routeId: r.id,
routeName: r.name,
reason: `Denied: "${r.name}" needs calling permission "${r.requiredClass}", ${ext.number} has "${ext.outboundClass}". The caller hears "no service".`,
};
const rows = r.trunkIds.length
? await db
.select({ id: trunks.id, name: trunks.name, enabled: trunks.enabled })
.from(trunks)
.where(inArray(trunks.id, r.trunkIds))
: [];
const usable = r.trunkIds
.map((id) => rows.find((t) => t.id === id))
.filter((t) => t?.enabled)
.map((t) => ({ id: t!.id, name: t!.name }));
if (!usable.length)
return {
ok: false,
routeId: r.id,
routeName: r.name,
reason: `"${r.name}" matches but none of its trunks is enabled`,
};
const cidDid = r.callerIdDidId ?? ext.outboundCallerIdDidId;
const cidId = cidDid ?? (await getSetting(db, 'routing.defaultCallerIdDidId'));
const [did] = cidId
? await db.select({ number: dids.number }).from(dids).where(eq(dids.id, cidId))
: [];
return {
ok: true,
routeId: r.id,
routeName: r.name,
pattern: p.match,
dialed: p.prepend + n.slice(p.strip),
trunks: usable,
callerId: did ? formatNumber(did.number) : null,
};
}
return { ok: false, reason: 'No enabled route matches this number. The call fails.' };
}
+1
View File
@@ -62,6 +62,7 @@ function snapshot(): Snapshot {
settings, settings,
dids: [], dids: [],
trunks: [], trunks: [],
routes: [],
extensions: [ext('101', 'Alice'), ext('102', 'Bob')], extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
}; };
} }
+75 -17
View File
@@ -32,6 +32,7 @@ const PHONES = 'sp-trunk-phones';
const EXT_ID = '00000000-0000-4000-8000-000000000101'; const EXT_ID = '00000000-0000-4000-8000-000000000101';
const PHONE_PASS = 'TrunkTest101xxxxxxxxxxxx'; const PHONE_PASS = 'TrunkTest101xxxxxxxxxxxx';
const DID = '13125551234'; const DID = '13125551234';
const DID_ID = '11111111-1111-4111-8111-111111111111';
const MOBILE = '3125559999'; // the outside caller, 10 digits as voip.ms sends them const MOBILE = '3125559999'; // the outside caller, 10 digits as voip.ms sends them
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
@@ -47,6 +48,7 @@ function snapshot(): Snapshot {
.filter((k) => k !== 'sms.webhookToken') .filter((k) => k !== 'sms.webhookToken')
.map((k) => [k, SettingsSchemas[k].parse(undefined)]), .map((k) => [k, SettingsSchemas[k].parse(undefined)]),
) as Snapshot['settings']; ) as Snapshot['settings'];
settings['routing.defaultCallerIdDidId'] = DID_ID;
const trunk = (id: string, name: string, password: string) => ({ const trunk = (id: string, name: string, password: string) => ({
id, id,
name, name,
@@ -68,7 +70,7 @@ function snapshot(): Snapshot {
settings, settings,
dids: [ dids: [
{ {
id: '11111111-1111-4111-8111-111111111111', id: DID_ID,
number: DID, number: DID,
trunkId: GOOD, trunkId: GOOD,
label: 'Main line', label: 'Main line',
@@ -97,6 +99,21 @@ function snapshot(): Snapshot {
}, },
], ],
trunks: [trunk(GOOD, 'voip.ms', SUB.pass), trunk(BAD, 'wrong password', 'NotThePassword1')], trunks: [trunk(GOOD, 'voip.ms', SUB.pass), trunk(BAD, 'wrong password', 'NotThePassword1')],
routes: [
{
id: 'aaaaaaaa-0000-4000-8000-000000000001',
name: 'Local and national',
position: 0,
enabled: true,
patterns: [
{ match: 'NXXNXXXXXX', strip: 0, prepend: '1' },
{ match: '1NXXNXXXXXX', strip: 0, prepend: '' },
],
requiredClass: 'national',
trunkIds: [GOOD],
callerId: null,
},
],
}; };
} }
@@ -167,8 +184,11 @@ exten => _X.,1,Set(CALLERID(all)="Mobile" <${MOBILE}>)
exten => s,1,Answer() exten => s,1,Answer()
same => n,Echo() same => n,Echo()
; Our outbound calls arrive here: remember what the provider saw, then echo the audio.
[provider-in] [provider-in]
exten => _X.,1,Hangup() exten => _X.,1,Set(GLOBAL(LAST_IN)=to=\${EXTEN} from=\${CALLERID(num)} rpid=\${PJSIP_HEADER(read,Remote-Party-ID)})
same => n,Answer()
same => n,Echo()
`; `;
async function waitFor(what: string, fn: () => Promise<boolean>, seconds = 60) { async function waitFor(what: string, fn: () => Promise<boolean>, seconds = 60) {
@@ -230,6 +250,26 @@ async function main() {
`${conf}:/etc/asterisk/selfphoneng`, `${conf}:/etc/asterisk/selfphoneng`,
IMAGE, IMAGE,
); );
/** Holds the call 10 s, then both legs (101 and the trunk) must have RTP both ways. */
async function checkAudio(what: string) {
await sleep(10_000);
const stats = await ami.command('pjsip show channelstats');
// The ChannelId column is cut at 18 characters: "trunk-7d3e9a52-1c4".
const rows = stats.split('\n').filter((l) => /^\s*\S+ (101-[0-9a-f]{8}|trunk-\S+) /.test(l));
if (rows.length !== 2) problems.push(`${what}: expected the 101 and trunk legs in:\n${stats}`);
for (const r of rows) {
const cols = r.trim().split(/\s+/);
const ulaw = cols.indexOf('ulaw');
const rx = Number(cols[ulaw + 1]);
const tx = Number(cols[ulaw + 5]);
log(`${what} ${cols[1]}: rx ${rx}, tx ${tx}`);
if (!(rx > 250) || !(tx > 250))
problems.push(`${what}: one-way or no audio on ${cols[1]}: rx ${rx}, tx ${tx}`);
}
await ami.command('channel request hangup all');
for (let i = 0; i < 10 && /PJSIP\//.test(await ami.command('core show channels concise')); i++)
await sleep(500);
}
const booted = async (name: string) => const booted = async (name: string) =>
waitFor( waitFor(
`${name} to boot`, `${name} to boot`,
@@ -346,20 +386,36 @@ async function main() {
if (!toPhone) problems.push('no DialBegin to PJSIP/101'); if (!toPhone) problems.push('no DialBegin to PJSIP/101');
else if (toPhone.CallerIDNum !== `1${MOBILE}`) else if (toPhone.CallerIDNum !== `1${MOBILE}`)
problems.push(`101 saw caller ID ${toPhone.CallerIDNum}, expected 1${MOBILE} (normalized)`); problems.push(`101 saw caller ID ${toPhone.CallerIDNum}, expected 1${MOBILE} (normalized)`);
await sleep(10_000); await checkAudio('inbound');
const stats = await ami.command('pjsip show channelstats');
// The ChannelId column is cut at 18 characters: "trunk-7d3e9a52-1c4". // M3-T3: 101 dials a 10-digit number; it leaves as 11 digits with the DID as caller ID.
const rows = stats.split('\n').filter((l) => /^\s*\S+ (101-[0-9a-f]{8}|trunk-\S+) /.test(l)); const bothUp = async () => {
if (rows.length !== 2) problems.push(`expected the 101 and trunk legs in:\n${stats}`); const up = (await ami.command('core show channels concise'))
for (const r of rows) { .split('\n')
const cols = r.trim().split(/\s+/); .filter((l) => l.includes('!Up!'));
const ulaw = cols.indexOf('ulaw'); return (
const rx = Number(cols[ulaw + 1]); up.some((l) => l.startsWith('PJSIP/101-')) && up.some((l) => l.startsWith('PJSIP/trunk-'))
const tx = Number(cols[ulaw + 5]); );
log(`${cols[1]}: rx ${rx}, tx ${tx}`); };
if (!(rx > 250) || !(tx > 250)) await ami.command(`channel originate PJSIP/101 extension 3125550000@sp-from-ext`);
problems.push(`one-way or no audio on ${cols[1]}: rx ${rx}, tx ${tx}`); await waitFor('the outbound call to connect', bothUp, 30);
} const seen = docker('exec', PROVIDER, 'asterisk', '-rx', 'dialplan show globals').out;
const lastIn = /LAST_IN=(.*)/.exec(seen)?.[1] ?? '';
log(`provider received: ${lastIn}`);
if (!lastIn.startsWith('to=13125550000 '))
problems.push(`provider saw ${lastIn}, expected to=13125550000`);
if (!new RegExp(`rpid=.*sip:${DID}@`).test(lastIn))
problems.push(`caller ID ${DID} not in Remote-Party-ID: ${lastIn}`);
await checkAudio('outbound');
// A caller whose class is too low (a Local channel has no SP_CLASS) hears "no service".
await ami.command('channel originate Local/13125550000@sp-from-ext application Wait 10');
await waitFor(
'ss-noservice',
async () => /Playback!ss-noservice/.test(await ami.command('core show channels concise')),
10,
);
log('class check: ss-noservice');
await ami.command('channel request hangup all'); await ami.command('channel request hangup all');
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors // eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
@@ -384,7 +440,9 @@ async function main() {
for (const p of problems) console.log(` ${p}`); for (const p of problems) console.log(` ${p}`);
process.exit(1); process.exit(1);
} }
console.log('ok trunks (registration, rejection, qualify, inbound DID call to 101 with audio)'); console.log(
'ok trunks (registration, rejection, qualify, inbound DID → 101, outbound with DID caller ID, class check)',
);
} }
await main(); await main();
+170
View File
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M3-T3: outbound routes, their order, the test box, and caller ID priority.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { patternRegex } from '@selfphoneng/shared';
import { settings } from '../src/db/schema.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe('patternRegex (Asterisk pattern rules)', () => {
it.each([
['NXXNXXXXXX', '3125550000', true],
['NXXNXXXXXX', '1125550000', false], // N is 2-9
['1NXXNXXXXXX', '13125550000', true],
['1800NXXXXXX', '18005551234', true],
['011.', '011442079460958', true],
['011.', '011', false], // "." needs at least one more character
['011!', '011', true],
['9[2-9]XX', '9234', true],
['9[2-9]XX', '9134', false],
['ZXX', '011', false],
])('%s vs %s → %s', (p, n, ok) => expect(patternRegex(p).test(n)).toBe(ok));
});
describe.skipIf(!hasTestDb)('outbound routes API', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
const dialplan = () => readFileSync(join(t.genDir, 'extensions_selfphoneng.conf'), 'utf8');
const settle = async () => {
await new Promise((r) => setTimeout(r, 60));
await t.app.generator.apply({ trigger: 'test' });
};
let trunkId: string;
let ext: { id: string };
let local: { id: string };
let mainDid: string;
let salesDid: string;
let routes: { id: string; name: string }[];
const test = async (extensionId: string, number: string) =>
(await req('POST', '/api/outbound-routes/test', { extensionId, number })).json();
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
req = (method, url, payload) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
});
trunkId = (
await req('POST', '/api/trunks', {
name: 'voip.ms',
serverHost: '127.0.0.1',
username: '123456_pbx',
password: 'pw',
})
).json().id;
ext = (await req('POST', '/api/extensions', { number: '101', name: 'Dan' })).json();
local = (
await req('POST', '/api/extensions', { number: '102', name: 'Lo', outboundClass: 'local' })
).json();
mainDid = (
await req('POST', '/api/dids', {
number: '3125551234',
trunkId,
destination: { type: 'hangup' },
})
).json().id;
salesDid = (
await req('POST', '/api/dids', {
number: '3125559876',
trunkId,
destination: { type: 'hangup' },
})
).json().id;
await t.db.insert(settings).values({ key: 'routing.defaultCallerIdDidId', value: mainDid });
routes = (await req('GET', '/api/outbound-routes')).json().items;
});
afterAll(() => t?.close());
it('a 10-digit number goes out as 11 digits with the default caller ID', async () => {
expect(await test(ext.id, '(312) 555-0000')).toEqual({
ok: true,
routeId: routes[1]!.id,
routeName: 'Local and national (US/Canada)',
pattern: 'NXXNXXXXXX',
dialed: '13125550000',
trunks: [{ id: trunkId, name: 'voip.ms' }],
callerId: '(312) 555-1234',
});
});
it('class local may call toll-free but not national; international is off', async () => {
expect((await test(local.id, '18005551234')).ok).toBe(true);
const denied = await test(local.id, '13125550000');
expect(denied).toMatchObject({ ok: false, routeName: 'Local and national (US/Canada)' });
expect(denied.reason).toMatch(/needs calling permission "national"/);
expect(await test(ext.id, '011442079460958')).toEqual({
ok: false,
reason: 'No enabled route matches this number. The call fails.',
});
expect((await test(ext.id, '911')).reason).toMatch(/Emergency/);
expect((await test(ext.id, '102')).reason).toMatch(/internal number/);
});
it('caller ID: the user setting beats the default, a route override beats both', async () => {
await req('PATCH', `/api/extensions/${ext.id}`, { outboundCallerIdDidId: salesDid });
expect((await test(ext.id, '3125550000')).callerId).toBe('(312) 555-9876');
await req('PATCH', `/api/outbound-routes/${routes[1]!.id}`, { callerIdDidId: mainDid });
expect((await test(ext.id, '3125550000')).callerId).toBe('(312) 555-1234');
await req('PATCH', `/api/outbound-routes/${routes[1]!.id}`, { callerIdDidId: null });
});
it('turning international on, then reordering, changes what matches first', async () => {
const intl = routes[2]!;
await req('PATCH', `/api/outbound-routes/${intl.id}`, { enabled: true });
expect((await test(ext.id, '011442079460958')).ok).toBe(false); // class national < international
const catchAll = (
await req('POST', '/api/outbound-routes', {
name: 'Everything',
patterns: [{ match: 'X.', strip: 0, prepend: '' }],
requiredClass: 'local',
trunkIds: [trunkId],
})
).json();
expect(catchAll.position).toBe(3);
expect((await test(local.id, '13125550000')).routeName).toBe('Local and national (US/Canada)');
const ids = [catchAll.id, ...routes.map((r) => r.id)];
expect((await req('POST', '/api/outbound-routes/reorder', { ids })).statusCode).toBe(200);
expect((await test(local.id, '13125550000')).routeName).toBe('Everything');
await settle();
const conf = dialplan();
const order = [...conf.matchAll(/^include => sp-or-(\S+)/gm)].map((m) => m[1]);
expect(order).toEqual(ids); // the include order is the position order
expect(conf).toContain(`[sp-route-${catchAll.id}]`);
expect(
(await req('POST', '/api/outbound-routes/reorder', { ids: ids.slice(1) })).statusCode,
).toBe(400);
});
it('refuses unknown or repeated trunks and bad patterns', async () => {
const base = {
name: 'x',
requiredClass: 'local',
patterns: [{ match: 'X.', strip: 0, prepend: '' }],
};
const unknown = await req('POST', '/api/outbound-routes', {
...base,
trunkIds: ['00000000-0000-4000-8000-000000000000'],
});
expect(unknown.statusCode).toBe(400);
const twice = await req('POST', '/api/outbound-routes', {
...base,
trunkIds: [trunkId, trunkId],
});
expect(twice.statusCode).toBe(400);
const bad = await req('POST', '/api/outbound-routes', {
...base,
trunkIds: [trunkId],
patterns: [{ match: '9,Dial(x)', strip: 0, prepend: '' }],
});
expect(bad.statusCode).toBe(400);
});
});
+19 -3
View File
@@ -9,7 +9,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { TrunkCreateSchema, type LiveMessage } from '@selfphoneng/shared'; import { TrunkCreateSchema, type LiveMessage } from '@selfphoneng/shared';
import { AmiParser, field } from '../src/ami/events.ts'; import { AmiParser, field } from '../src/ami/events.ts';
import { decrypt } from '../src/crypto/secrets.ts'; import { decrypt } from '../src/crypto/secrets.ts';
import { auditLog, dids, trunks } from '../src/db/schema.ts'; import { auditLog, dids, outboundRoutes, trunks } from '../src/db/schema.ts';
import { trunkOfObject } from '../src/live/state.ts'; import { trunkOfObject } from '../src/live/state.ts';
import { hasTestDb } from './db.ts'; import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts'; import { ADMIN, H, createTestApp } from './app.ts';
@@ -156,15 +156,31 @@ describe.skipIf(!hasTestDb)('trunks API', () => {
await req('PATCH', `/api/trunks/${id}`, { enabled: true }); await req('PATCH', `/api/trunks/${id}`, { enabled: true });
}); });
it('refuses to delete a trunk that a phone number uses', async () => { it('the first trunk gets the default outbound rules (international off)', async () => {
const routes = (await req('GET', '/api/outbound-routes')).json().items;
expect(routes.map((r: { name: string; enabled: boolean }) => [r.name, r.enabled])).toEqual([
['Toll-free', true],
['Local and national (US/Canada)', true],
['International', false],
]);
expect(routes.every((r: { trunkIds: string[] }) => r.trunkIds[0] === id)).toBe(true);
});
it('refuses to delete a trunk that a phone number or an outbound rule uses', async () => {
const [d] = await t.db const [d] = await t.db
.insert(dids) .insert(dids)
.values({ number: '13125551234', trunkId: id, destination: { type: 'hangup' } }) .values({ number: '13125551234', trunkId: id, destination: { type: 'hangup' } })
.returning(); .returning();
const r = await req('DELETE', `/api/trunks/${id}`); const r = await req('DELETE', `/api/trunks/${id}`);
expect(r.statusCode).toBe(409); expect(r.statusCode).toBe(409);
expect(r.json().error.message).toContain('number 13125551234'); expect(r.json().error.details.usedBy).toEqual([
'number (312) 555-1234',
'outbound rule "Toll-free"',
'outbound rule "Local and national (US/Canada)"',
'outbound rule "International"',
]);
await t.db.delete(dids).where(eq(dids.id, d!.id)); await t.db.delete(dids).where(eq(dids.id, d!.id));
await t.db.delete(outboundRoutes);
expect((await req('DELETE', `/api/trunks/${id}`)).statusCode).toBe(200); expect((await req('DELETE', `/api/trunks/${id}`)).statusCode).toBe(200);
await settle(); await settle();
expect(pjsip()).not.toContain('trunk-'); expect(pjsip()).not.toContain('trunk-');
+16
View File
@@ -10,6 +10,10 @@ import type {
ExtensionUpdate, ExtensionUpdate,
ListResponse, ListResponse,
NumberKind, NumberKind,
OutboundRouteCreate,
OutboundRouteDto,
OutboundRouteUpdate,
RouteTestResult,
SipCredentials, SipCredentials,
TrunkCreate, TrunkCreate,
TrunkDto, TrunkDto,
@@ -97,3 +101,15 @@ export const didsApi = {
update: (id: string, d: DidUpdate) => api<DidDto>('PATCH', `/api/dids/${id}`, d), update: (id: string, d: DidUpdate) => api<DidDto>('PATCH', `/api/dids/${id}`, d),
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/dids/${id}`), remove: (id: string) => api<{ ok: true }>('DELETE', `/api/dids/${id}`),
}; };
export const routesApi = {
list: () =>
api<ListResponse<OutboundRouteDto>>('GET', '/api/outbound-routes').then((r) => r.items),
create: (r: OutboundRouteCreate) => api<OutboundRouteDto>('POST', '/api/outbound-routes', r),
update: (id: string, r: OutboundRouteUpdate) =>
api<OutboundRouteDto>('PATCH', `/api/outbound-routes/${id}`, r),
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/outbound-routes/${id}`),
reorder: (ids: string[]) => api<{ ok: true }>('POST', '/api/outbound-routes/reorder', { ids }),
test: (extensionId: string, number: string) =>
api<RouteTestResult>('POST', '/api/outbound-routes/test', { extensionId, number }),
};
+3
View File
@@ -12,6 +12,7 @@ import { UsersPage } from './pages/admin/UsersPage.tsx';
import { DashboardPage } from './pages/admin/DashboardPage.tsx'; import { DashboardPage } from './pages/admin/DashboardPage.tsx';
import { TrunksPage } from './pages/admin/TrunksPage.tsx'; import { TrunksPage } from './pages/admin/TrunksPage.tsx';
import { NumbersPage } from './pages/admin/NumbersPage.tsx'; import { NumbersPage } from './pages/admin/NumbersPage.tsx';
import { OutboundPage } from './pages/admin/OutboundPage.tsx';
import { MePage } from './pages/user/MePage.tsx'; import { MePage } from './pages/user/MePage.tsx';
import './index.css'; import './index.css';
@@ -28,6 +29,8 @@ const router = createBrowserRouter([
<DashboardPage /> <DashboardPage />
) : p.path === 'users' ? ( ) : p.path === 'users' ? (
<RequireAuth>{(user) => <UsersPage currentUserId={user.id} />}</RequireAuth> <RequireAuth>{(user) => <UsersPage currentUserId={user.id} />}</RequireAuth>
) : p.path === 'outbound' ? (
<OutboundPage />
) : p.path === 'numbers' ? ( ) : p.path === 'numbers' ? (
<NumbersPage /> <NumbersPage />
) : p.path === 'trunks' ? ( ) : p.path === 'trunks' ? (
+1 -1
View File
@@ -14,7 +14,7 @@ export const ADMIN_PAGES = [
{ path: 'users', label: 'Users & Extensions', milestone: null }, { path: 'users', label: 'Users & Extensions', milestone: null },
{ path: 'trunks', label: 'Trunks', milestone: null }, { path: 'trunks', label: 'Trunks', milestone: null },
{ path: 'numbers', label: 'Phone numbers', milestone: null }, { path: 'numbers', label: 'Phone numbers', milestone: null },
{ path: 'outbound', label: 'Outbound rules', milestone: 'M3' }, { path: 'outbound', label: 'Outbound rules', milestone: null },
{ path: 'ring-groups', label: 'Ring groups', milestone: 'M6' }, { path: 'ring-groups', label: 'Ring groups', milestone: 'M6' },
{ path: 'queues', label: 'Queues', milestone: 'M8' }, { path: 'queues', label: 'Queues', milestone: 'M8' },
{ path: 'ivrs', label: 'Digital receptionist', milestone: 'M6' }, { path: 'ivrs', label: 'Digital receptionist', milestone: 'M6' },
+443
View File
@@ -0,0 +1,443 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Outbound rules (05 §2, 03 §6.12): ordered list (first match wins), form, and a test box.
// ponytail: ↑/↓ buttons instead of drag-to-reorder; keyboard-friendly, no drag library.
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import {
OUTBOUND_CLASSES,
RoutePatternSchema,
formatNumber,
type OutboundRouteDto,
type RouteTestResult,
} from '@selfphoneng/shared';
import { didsApi, extensionsApi, routesApi, trunksApi } from '../../api/client.ts';
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
export const CLASS_LABEL: Record<(typeof OUTBOUND_CLASSES)[number], string> = {
internal: 'Internal only',
local: 'Local and toll-free',
national: 'US/Canada',
international: 'International',
};
export function OutboundPage() {
const qc = useQueryClient();
const routes = useQuery({ queryKey: ['routes'], queryFn: routesApi.list });
const trunks = useQuery({ queryKey: ['trunks'], queryFn: trunksApi.list });
const [editing, setEditing] = useState<OutboundRouteDto | 'new' | null>(null);
const [deleting, setDeleting] = useState<OutboundRouteDto | null>(null);
const refresh = () => qc.invalidateQueries({ queryKey: ['routes'] });
const remove = useMutation({
mutationFn: (id: string) => routesApi.remove(id),
onSuccess: () => {
setDeleting(null);
void refresh();
},
});
const move = useMutation({
mutationFn: ({ from, to }: { from: number; to: number }) => {
const ids = routes.data!.map((r) => r.id);
[ids[from], ids[to]] = [ids[to]!, ids[from]!];
return routesApi.reorder(ids);
},
onSettled: () => void refresh(),
});
const trunkName = (id: string) => trunks.data?.find((t) => t.id === id)?.name ?? '?';
return (
<section className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Outbound rules</h1>
<Button variant="primary" onClick={() => setEditing('new')} disabled={!trunks.data?.length}>
New rule
</Button>
</div>
<p className="max-w-prose text-sm text-zinc-500">
When someone dials an outside number, the first rule whose pattern matches decides how the
call goes out. Put more specific rules (like toll-free) above general ones.
</p>
{routes.isError && <Alert>{routes.error.message}</Alert>}
{move.isError && <Alert>{move.error.message}</Alert>}
{routes.data?.length === 0 && (
<p className="text-sm text-zinc-500">
No rules yet. They are created with your first trunk.
</p>
)}
{routes.data && routes.data.length > 0 && (
<ol className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{routes.data.map((r, i) => (
<li
key={r.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="flex flex-col">
<Button
variant="ghost"
className="px-1 py-0"
aria-label={`Move ${r.name} up`}
disabled={i === 0 || move.isPending}
onClick={() => move.mutate({ from: i, to: i - 1 })}
>
↑
</Button>
<Button
variant="ghost"
className="px-1 py-0"
aria-label={`Move ${r.name} down`}
disabled={i === routes.data.length - 1 || move.isPending}
onClick={() => move.mutate({ from: i, to: i + 1 })}
>
↓
</Button>
</span>
<span className="w-5 text-zinc-500">{i + 1}.</span>
<span className={`font-medium ${r.enabled ? '' : 'text-zinc-400 line-through'}`}>
{r.name}
</span>
{!r.enabled && <span className="text-xs text-zinc-500">off</span>}
<span className="flex-1 truncate font-mono text-xs text-zinc-500">
{r.patterns.map((p) => p.match).join(' ')}
</span>
<span className="hidden text-zinc-500 sm:inline">
{CLASS_LABEL[r.requiredClass]} · {r.trunkIds.map(trunkName).join(' → ')}
</span>
<Button onClick={() => setEditing(r)}>Edit</Button>
<Button
variant="ghost"
onClick={() => {
remove.reset();
setDeleting(r);
}}
>
Delete
</Button>
</li>
))}
</ol>
)}
<TestBox />
{editing && trunks.data && (
<RouteDrawer
route={editing === 'new' ? null : editing}
trunks={trunks.data.map((t) => ({ id: t.id, name: t.name }))}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null);
void refresh();
}}
/>
)}
<ConfirmDialog
open={deleting !== null}
title="Delete rule"
message={
<p>
Delete <strong>{deleting?.name}</strong>? Numbers only this rule matches can no longer
be dialed.
</p>
}
confirmLabel="Delete"
busy={remove.isPending}
error={remove.error?.message}
onCancel={() => setDeleting(null)}
onConfirm={() => deleting && remove.mutate(deleting.id)}
/>
</section>
);
}
function TestBox() {
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
const [extId, setExtId] = useState('');
const [number, setNumber] = useState('');
const test = useMutation({
mutationFn: () => routesApi.test(extId || exts.data![0]!.id, number),
});
const r: RouteTestResult | undefined = test.data;
if (!exts.data?.length) return null;
return (
<form
className="flex flex-col gap-3 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"
onSubmit={(e) => {
e.preventDefault();
if (number.trim()) test.mutate();
}}
>
<h2 className="font-medium">Test a number</h2>
<div className="flex flex-wrap items-end gap-2">
<Field label="Caller">
{(a) => (
<select
{...a}
value={extId}
onChange={(e) => setExtId(e.target.value)}
className={inputClass}
>
{exts.data.map((x) => (
<option key={x.id} value={x.id}>
{x.number} {x.name}
</option>
))}
</select>
)}
</Field>
<Field label="Dials">
{(a) => (
<input
{...a}
value={number}
onChange={(e) => setNumber(e.target.value)}
inputMode="tel"
placeholder="3125550000"
className={inputClass}
/>
)}
</Field>
<Button type="submit" disabled={test.isPending}>
Test
</Button>
</div>
{test.isError && <Alert>{test.error.message}</Alert>}
{r && (
<p role="status" className="text-sm">
{r.ok ? (
<>
<strong>{r.routeName}</strong> sends <span className="font-mono">{r.dialed}</span> via{' '}
{r.trunks.map((t) => t.name).join(', then ')}
{r.callerId
? `, caller ID ${r.callerId}.`
: '. No caller ID is set, so the provider uses its own (voip.ms: the sub-account CallerID).'}
</>
) : (
r.reason
)}
</p>
)}
</form>
);
}
const FormSchema = z.object({
name: z.string().trim().min(1, 'Required').max(60),
enabled: z.boolean(),
patterns: z.array(RoutePatternSchema).min(1, 'Add at least one pattern'),
requiredClass: z.enum(OUTBOUND_CLASSES),
trunkIds: z.array(z.string()).min(1, 'Pick at least one trunk'),
callerIdDidId: z.string(),
});
type FormValues = z.input<typeof FormSchema>;
function RouteDrawer({
route,
trunks,
onClose,
onSaved,
}: {
route: OutboundRouteDto | null;
trunks: { id: string; name: string }[];
onClose: () => void;
onSaved: () => void;
}) {
const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list });
const [error, setError] = useState<string>();
const form = useForm<FormValues>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: route?.name ?? '',
enabled: route?.enabled ?? true,
patterns: route?.patterns ?? [{ match: '', strip: 0, prepend: '' }],
requiredClass: route?.requiredClass ?? 'national',
trunkIds: route?.trunkIds ?? [trunks[0]!.id],
callerIdDidId: route?.callerIdDidId ?? '',
},
});
const { errors } = form.formState;
const patterns = useFieldArray({ control: form.control, name: 'patterns' });
const [chosen, setChosen] = useState<string[]>(form.getValues('trunkIds'));
const setTrunks = (ids: string[]) => {
setChosen(ids);
form.setValue('trunkIds', ids, { shouldValidate: true });
};
const onSubmit = form.handleSubmit(async (v) => {
setError(undefined);
const body = { ...v, callerIdDidId: v.callerIdDidId || null };
try {
if (route) await routesApi.update(route.id, body);
else await routesApi.create(body);
onSaved();
} catch (e) {
setError((e as Error).message);
}
});
return (
<Dialog
open
drawer
onClose={onClose}
title={route ? `Edit ${route.name}` : 'New outbound rule'}
footer={
<>
<Button onClick={onClose}>Cancel</Button>
<Button variant="primary" type="submit" form="route-form">
{route ? 'Save' : 'Create rule'}
</Button>
</>
}
>
<form id="route-form" onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
{error && <Alert>{error}</Alert>}
<Field label="Name" error={errors.name?.message}>
{(a) => <input {...a} {...form.register('name')} className={inputClass} />}
</Field>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" {...form.register('enabled')} className="size-4" />
Enabled
</label>
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Patterns</legend>
<details className="text-xs text-zinc-500">
<summary className="cursor-pointer">Pattern help</summary>
<p className="mt-1">
<code>X</code> any digit, <code>Z</code> 1–9, <code>N</code> 2–9, <code>[1-5]</code>{' '}
one of those, <code>.</code> one or more of anything. Example: <code>NXXNXXXXXX</code>{' '}
is a 10-digit US number. <em>Strip</em> removes digits from the front,{' '}
<em>prepend</em> adds digits, e.g. strip 0 prepend 1 turns 3125550000 into
13125550000.
</p>
</details>
{patterns.fields.map((f, i) => (
<div key={f.id} className="flex items-start gap-2">
<input
{...form.register(`patterns.${i}.match`)}
aria-label={`Pattern ${i + 1}`}
placeholder="NXXNXXXXXX"
aria-invalid={Boolean(errors.patterns?.[i]?.match)}
className={`${inputClass} flex-1 font-mono`}
/>
<input
{...form.register(`patterns.${i}.strip`, { valueAsNumber: true })}
aria-label={`Pattern ${i + 1} strip`}
type="number"
min={0}
title="Strip"
className={`${inputClass} w-16`}
/>
<input
{...form.register(`patterns.${i}.prepend`)}
aria-label={`Pattern ${i + 1} prepend`}
placeholder="prepend"
className={`${inputClass} w-24 font-mono`}
/>
<Button
variant="ghost"
aria-label={`Remove pattern ${i + 1}`}
onClick={() => patterns.remove(i)}
disabled={patterns.fields.length === 1}
>
✕
</Button>
</div>
))}
{errors.patterns && (
<p className="text-xs text-red-600 dark:text-red-400">
{errors.patterns.message ??
errors.patterns.root?.message ??
'Check the patterns: digits and X Z N [1-5] . ! only'}
</p>
)}
<Button onClick={() => patterns.append({ match: '', strip: 0, prepend: '' })}>
Add pattern
</Button>
</fieldset>
<Field label="Who may use it" hint="Users whose calling permission is at least this.">
{(a) => (
<select {...a} {...form.register('requiredClass')} className={inputClass}>
{OUTBOUND_CLASSES.map((c) => (
<option key={c} value={c}>
{CLASS_LABEL[c]}
</option>
))}
</select>
)}
</Field>
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Trunks, in the order to try them</legend>
<ol className="flex flex-col gap-1">
{chosen.map((id, i) => (
<li key={id} className="flex items-center gap-2 text-sm">
<span className="w-5 text-zinc-500">{i + 1}.</span>
<span className="flex-1">{trunks.find((t) => t.id === id)?.name ?? id}</span>
<Button
variant="ghost"
aria-label="Try earlier"
disabled={i === 0}
onClick={() => {
const ids = [...chosen];
[ids[i - 1], ids[i]] = [ids[i]!, ids[i - 1]!];
setTrunks(ids);
}}
>
↑
</Button>
<Button
variant="ghost"
aria-label="Remove trunk"
onClick={() => setTrunks(chosen.filter((x) => x !== id))}
>
✕
</Button>
</li>
))}
</ol>
{trunks.some((t) => !chosen.includes(t.id)) && (
<select
aria-label="Add a trunk"
value=""
onChange={(e) => e.target.value && setTrunks([...chosen, e.target.value])}
className={inputClass}
>
<option value="">Add a trunk…</option>
{trunks
.filter((t) => !chosen.includes(t.id))
.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
)}
{errors.trunkIds && (
<p className="text-xs text-red-600 dark:text-red-400">{errors.trunkIds.message}</p>
)}
</fieldset>
<Field
label="Caller ID"
hint="Overrides the caller's own outbound caller ID for calls through this rule."
>
{(a) => (
<select {...a} {...form.register('callerIdDidId')} className={inputClass}>
<option value="">The caller's own (default)</option>
{dids.data?.map((d) => (
<option key={d.id} value={d.id}>
{formatNumber(d.number)}
{d.label ? ` (${d.label})` : ''}
</option>
))}
</select>
)}
</Field>
</form>
</Dialog>
);
}
+69 -4
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// User + extension editor (05 §2). Tabs so far: General, Phone, Credentials. // User + extension editor (05 §2). Tabs so far: General, Phone, Calling, Credentials.
// Voicemail (M4), Calling and Emergency (M3) and Forwarding (M6) arrive with their features. // Voicemail (M4), Emergency (M3-T4) and Forwarding (M6) arrive with their features.
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react'; import { useState, type ReactNode } from 'react';
@@ -8,17 +8,20 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { import {
ExtensionUpdateSchema, ExtensionUpdateSchema,
OUTBOUND_CLASSES,
formatNumber,
InternalNumber, InternalNumber,
RoleSchema, RoleSchema,
type ExtensionDto, type ExtensionDto,
type SipCredentials, type SipCredentials,
type UserDto, type UserDto,
} from '@selfphoneng/shared'; } from '@selfphoneng/shared';
import { extensionsApi, usersApi } from '../../api/client.ts'; import { didsApi, extensionsApi, usersApi } from '../../api/client.ts';
import { CLASS_LABEL } from './OutboundPage.tsx';
import { Dialog } from '../../components/Dialog.tsx'; import { Dialog } from '../../components/Dialog.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx'; import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
type Tab = 'general' | 'phone' | 'credentials'; type Tab = 'general' | 'phone' | 'calling' | 'credentials';
const GeneralSchema = z.object({ const GeneralSchema = z.object({
displayName: z.string().trim().min(1, 'Required').max(100), displayName: z.string().trim().min(1, 'Required').max(100),
@@ -51,6 +54,7 @@ export function UserDrawer({
const tabs: { id: Tab; label: string; enabled: boolean }[] = [ const tabs: { id: Tab; label: string; enabled: boolean }[] = [
{ id: 'general', label: 'General', enabled: true }, { id: 'general', label: 'General', enabled: true },
{ id: 'phone', label: 'Phone', enabled: Boolean(ext.data) }, { id: 'phone', label: 'Phone', enabled: Boolean(ext.data) },
{ id: 'calling', label: 'Calling', enabled: Boolean(ext.data) },
{ id: 'credentials', label: 'Credentials', enabled: Boolean(ext.data) }, { id: 'credentials', label: 'Credentials', enabled: Boolean(ext.data) },
]; ];
@@ -101,6 +105,7 @@ export function UserDrawer({
/> />
)} )}
{tab === 'phone' && ext.data && <PhoneTab ext={ext.data} onSaved={() => onSaved()} />} {tab === 'phone' && ext.data && <PhoneTab ext={ext.data} onSaved={() => onSaved()} />}
{tab === 'calling' && ext.data && <CallingTab ext={ext.data} onSaved={() => onSaved()} />}
{tab === 'credentials' && ext.data && <CredentialsTab ext={ext.data} />} {tab === 'credentials' && ext.data && <CredentialsTab ext={ext.data} />}
</Dialog> </Dialog>
); );
@@ -431,3 +436,63 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
</div> </div>
); );
} }
function CallingTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void }) {
const qc = useQueryClient();
const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list });
const [error, setError] = useState<string>();
const form = useForm({
defaultValues: {
outboundClass: ext.outboundClass,
outboundCallerIdDidId: ext.outboundCallerIdDidId ?? '',
},
});
const onSubmit = form.handleSubmit(async (v) => {
setError(undefined);
try {
await extensionsApi.update(ext.id, {
outboundClass: v.outboundClass,
outboundCallerIdDidId: v.outboundCallerIdDidId || null,
});
await qc.invalidateQueries({ queryKey: ['extension', ext.id] });
onSaved();
} catch (e) {
setError((e as Error).message);
}
});
return (
<form id="user-calling-form" onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
{error && <Alert>{error}</Alert>}
<Field
label="Calling permission"
hint="Which outside numbers they may dial. 911 always works, whatever this says."
>
{(a) => (
<select {...a} {...form.register('outboundClass')} className={inputClass}>
{OUTBOUND_CLASSES.map((c) => (
<option key={c} value={c}>
{CLASS_LABEL[c]}
</option>
))}
</select>
)}
</Field>
<Field
label="Outbound caller ID"
hint="The number people see when they call out. Default: the company's main number."
>
{(a) => (
<select {...a} {...form.register('outboundCallerIdDidId')} className={inputClass}>
<option value="">Company default</option>
{dids.data?.map((d) => (
<option key={d.id} value={d.id}>
{formatNumber(d.number)}
{d.label ? ` (${d.label})` : ''}
</option>
))}
</select>
)}
</Field>
</form>
);
}
+21 -6
View File
@@ -699,11 +699,23 @@ music_on_hold_when_empty = yes
### 6.12 Outbound routes ### 6.12 Outbound routes
Asterisk sorts the patterns inside one context by how specific they are, not by file order, so
"routes in position order" needs one context per route, included in position order (an include
is searched only after the ones before it; verified by the load test and `outbound.test.ts`).
Duplicate patterns inside a route are dropped (a duplicate extension is a load error). When
there are no enabled routes, `sp-outbound-routes` holds `exten => pending,1,Hangup()`.
```ini ```ini
[sp-outbound-routes] [sp-outbound-routes]
; one line per pattern, routes in position order; disabled routes skipped ; one include per enabled route, in position order
include => sp-or-{id} ; route name
include => sp-or-{id2}
[sp-or-{id}]
exten => _NXXNXXXXXX,1,Goto(sp-route-{id},1${EXTEN:0},1) ; strip=0 prepend=1 exten => _NXXNXXXXXX,1,Goto(sp-route-{id},1${EXTEN:0},1) ; strip=0 prepend=1
exten => _1NXXNXXXXXX,1,Goto(sp-route-{id},${EXTEN:0},1) exten => _1NXXNXXXXXX,1,Goto(sp-route-{id},${EXTEN:0},1)
[sp-or-{id2}]
exten => _011.,1,Goto(sp-route-{id2},${EXTEN},1) exten => _011.,1,Goto(sp-route-{id2},${EXTEN},1)
[sp-route-{id}] [sp-route-{id}]
@@ -718,23 +730,26 @@ exten => _X.,1,Set(__SP_DIRECTION=outbound)
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID})) same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
; trunk 1 (with optional channel limit) ; trunk 1 (with optional channel limit)
same => n,Set(GROUP(sptrunk)={trunk1id}) same => n,Set(GROUP(sptrunk)={trunk1id})
same => n,GotoIf($[${GROUP_COUNT({trunk1id}@sptrunk)} > {max_channels}]?t2) ; only if max_channels same => n,GotoIf($[${GROUP_COUNT({trunk1id}@sptrunk)} > {max_channels}]?t1) ; only if max_channels (last trunk: ?busy)
same => n,Dial(PJSIP/${EXTEN}@trunk-{trunk1id},120) same => n,Dial(PJSIP/${EXTEN}@trunk-{trunk1id},120)
same => n,GotoIf($["${DIALSTATUS}" != "CHANUNAVAIL" & "${DIALSTATUS}" != "CONGESTION"]?done) same => n,GotoIf($["${DIALSTATUS}" != "CHANUNAVAIL" & "${DIALSTATUS}" != "CONGESTION"]?done)
same => n(t2),Dial(PJSIP/${EXTEN}@trunk-{trunk2id},120) ; repeat per trunk same => n(t1),Set(GROUP(sptrunk)={trunk2id}) ; repeat per trunk
same => n,Dial(PJSIP/${EXTEN}@trunk-{trunk2id},120)
same => n,GotoIf($["${DIALSTATUS}" != "CHANUNAVAIL" & "${DIALSTATUS}" != "CONGESTION"]?done)
same => n(busy),Playback(all-circuits-busy-now) ; every trunk full, down or off
same => n(done),Hangup() same => n(done),Hangup()
same => n(denied),Playback(ss-noservice) same => n(denied),Playback(ss-noservice)
same => n,Hangup() same => n,Hangup()
``` ```
Default routes created by the setup wizard (06 §4): Default routes, created with the first trunk (06 §4), in this order. Toll-free comes first because
`1NXXNXXXXXX` also matches `1800…` and the first matching route wins:
| Name | Patterns | Required class | | Name | Patterns | Required class |
|---|---|---| |---|---|---|
| Local/National (NANP) | `NXXNXXXXXX` (prepend `1`), `1NXXNXXXXXX` | national |
| Toll-free | `1800NXXXXXX`, `1888NXXXXXX`, `1877NXXXXXX`, `1866NXXXXXX`, `1855NXXXXXX`, `1844NXXXXXX`, `1833NXXXXXX` | local | | Toll-free | `1800NXXXXXX`, `1888NXXXXXX`, `1877NXXXXXX`, `1866NXXXXXX`, `1855NXXXXXX`, `1844NXXXXXX`, `1833NXXXXXX` | local |
| Local/National (NANP) | `NXXNXXXXXX` (prepend `1`), `1NXXNXXXXXX` | national |
| International | `011.` | international — **created disabled** | | International | `011.` | international — **created disabled** |
Pattern note: Asterisk sorts patterns by how specific they are, so toll-free can sit next to national safely.
### 6.13 Feature codes `sp-featurecodes` ### 6.13 Feature codes `sp-featurecodes`
+1
View File
@@ -9,3 +9,4 @@ export * from './extensions.ts';
export * from './live.ts'; export * from './live.ts';
export * from './trunks.ts'; export * from './trunks.ts';
export * from './dids.ts'; export * from './dids.ts';
export * from './outbound.ts';
+90
View File
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Outbound routes (02, 03 §6.12, 06 §4) and Asterisk dial-pattern matching for the test box.
import { z } from 'zod';
export const OUTBOUND_CLASSES = ['internal', 'local', 'national', 'international'] as const;
export type OutboundClass = (typeof OUTBOUND_CLASSES)[number];
/** SP_CLASS numbers in the dialplan: a caller may use a route when theirs is >= the route's. */
export const CLASS_RANK: Record<OutboundClass, number> = {
internal: 0,
local: 1,
national: 2,
international: 3,
};
export const RoutePatternSchema = z.object({
/** Asterisk pattern without the leading "_": X Z N [1-5] . ! and digits. */
match: z
.string()
.trim()
.regex(/^(?:[0-9XZN.!]|\[[0-9-]+\])+$/, 'Digits and X Z N [1-5] . ! only')
.max(32),
strip: z.number().int().min(0).max(20),
prepend: z
.string()
.regex(/^[0-9*#+]{0,10}$/, 'Digits only')
.max(10),
});
export type RoutePattern = z.infer<typeof RoutePatternSchema>;
const RouteFields = z.object({
name: z.string().trim().min(1, 'Required').max(60),
enabled: z.boolean(),
patterns: z.array(RoutePatternSchema).min(1, 'Add at least one pattern').max(50),
requiredClass: z.enum(OUTBOUND_CLASSES),
trunkIds: z.array(z.uuid()).min(1, 'Pick at least one trunk').max(10),
callerIdDidId: z.uuid().nullable(),
});
export const OutboundRouteCreateSchema = RouteFields.partial().required({
name: true,
patterns: true,
requiredClass: true,
trunkIds: true,
});
export const OutboundRouteUpdateSchema = RouteFields.partial();
export type OutboundRouteCreate = z.input<typeof OutboundRouteCreateSchema>;
export type OutboundRouteUpdate = z.input<typeof OutboundRouteUpdateSchema>;
export type OutboundRouteDto = z.output<typeof RouteFields> & {
id: string;
position: number;
createdAt: string;
};
export const RouteTestSchema = z.object({
extensionId: z.uuid(),
number: z.string().trim().min(1).max(32),
});
export type RouteTestResult =
| {
ok: true;
routeId: string;
routeName: string;
pattern: string;
dialed: string;
trunks: { id: string; name: string }[];
callerId: string | null;
}
| { ok: false; reason: string; routeId?: string; routeName?: string };
/** Asterisk extension pattern (without "_") → RegExp. Same rules Asterisk uses. */
export function patternRegex(match: string): RegExp {
let re = '';
for (let i = 0; i < match.length; i++) {
const c = match[i]!;
if (c === 'X') re += '[0-9]';
else if (c === 'Z') re += '[1-9]';
else if (c === 'N') re += '[2-9]';
else if (c === '.') re += '.+';
else if (c === '!') re += '.*';
else if (c === '[') {
const end = match.indexOf(']', i);
re += `[${match.slice(i + 1, end)}]`;
i = end;
} else re += c;
}
return new RegExp(`^${re}$`);
}
/** The dialed string as the dialplan sees it: what was typed, minus formatting. */
export const dialedDigits = (n: string) => n.replace(/[\s\-.()]/g, '');
+3 -1
View File
@@ -32,7 +32,9 @@ node scripts/e2e/m2-live.mjs down
`m3-t1.mjs` runs on the same setup (after `m1-t4.mjs`): it starts a fake voip.ms (a second Asterisk `m3-t1.mjs` runs on the same setup (after `m1-t4.mjs`): it starts a fake voip.ms (a second Asterisk
that accepts one sub-account) on the m2-live network, creates the trunk in the UI and waits for that accepts one sub-account) on the m2-live network, creates the trunk in the UI and waits for
**Registered**, then presses Test. `m3-t2.mjs` (after `m3-t1.mjs`) adds a phone number routed to a user in the UI and **Registered**, then presses Test. `m3-t2.mjs` (after `m3-t1.mjs`) adds a phone number routed to a user in the UI and
checks `sp-inbound` in Asterisk, and that the extension can no longer be deleted. checks `sp-inbound` in Asterisk, and that the extension can no longer be deleted. `m3-t3.mjs` (after
both) checks the default outbound rules, the test box, a user's calling permission, and that
reordering changes the include order in Asterisk.
`pnpm test:asterisk` (golden-file load test, `call-test.ts` with 30 s two-way audio between two `pnpm test:asterisk` (golden-file load test, `call-test.ts` with 30 s two-way audio between two
baresip phones, and `trunk-test.ts` with registration against a fake provider) needs no api at all. baresip phones, and `trunk-test.ts` with registration against a fake provider) needs no api at all.
+101
View File
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M3-T3 in a browser: the default outbound rules (made with the first trunk), the test box, a
// user's calling permission, reordering, and the result in the live Asterisk.
// Run after m3-t1.mjs and m3-t2.mjs on the m2-live setup.
// Usage: node scripts/e2e/m3-t3.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { launch } from './cdp.mjs';
const cli = (cmd) =>
spawnSync('docker', ['exec', 'sp-live-ast', 'asterisk', '-rx', cmd], { encoding: 'utf8' })
.stdout ?? '';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const [base, email, pw, shotsDir] = process.argv.slice(2);
const p = await launch({ shotsDir });
const body = () => p.evaluate('document.body.innerText');
async function testNumber(n) {
await p.fill('Dials', n);
await p.evaluate(
`[...document.querySelectorAll('form button[type=submit]')].find(b => b.textContent === 'Test').click(), true`,
);
await p.waitFor(`!!document.querySelector('form [role=status]')`, 'test result');
await sleep(300);
return p.evaluate(`document.querySelector('form [role=status]').innerText`);
}
try {
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.fill('Email', email);
await p.fill('Password', pw);
await p.click('Sign in');
await p.waitFor(`location.pathname === '/admin'`);
await p.click('Outbound rules');
await p.waitFor(`document.body.innerText.includes('Test a number')`, 'outbound page');
const text = await body();
const order = ['Toll-free', 'Local and national (US/Canada)', 'International'].map((n) =>
text.indexOf(n),
);
assert.ok(order[0] < order[1] && order[1] < order[2], `default rules in order:\n${text}`);
await p.shot('m3-t3-01-rules');
let r = await testNumber('(312) 555-0000');
assert.match(r, /Local and national \(US\/Canada\) sends 13125550000 via voip\.ms/);
console.log('test box:', r);
r = await testNumber('011 44 20 7946 0958');
assert.match(r, /No enabled route matches/);
console.log('test box:', r);
// Dan (101) gets "Local and toll-free" only: national is now denied, toll-free still works.
await p.click('Users & Extensions');
await p.waitFor(`document.body.innerText.includes('dan@example.com')`, 'users list');
await p.click('Edit Dan Doe');
await p.waitFor(
`[...document.querySelectorAll('[role=tab]')].some(t => t.textContent === 'Calling' && !t.disabled)`,
'Calling tab enabled',
);
await p.click('Calling');
await p.fill('Calling permission', 'local');
await p.shot('m3-t3-02-calling-tab');
await p.evaluate(`document.querySelector('dialog button[form=user-calling-form]').click(), true`);
await p.waitFor(`!document.querySelector('dialog[open]')`, 'drawer closed');
await p.click('Outbound rules');
await p.waitFor(`document.body.innerText.includes('Test a number')`);
r = await testNumber('3125550000');
assert.match(
r,
/Denied: "Local and national \(US\/Canada\)" needs calling permission "national"/,
);
console.log('test box:', r);
r = await testNumber('1-800-555-1234');
assert.match(r, /Toll-free sends 18005551234/);
await p.shot('m3-t3-03-denied');
// Reorder: national above toll-free. The test box and Asterisk's include order both follow.
await p.click('Move Local and national (US/Canada) up');
await p.waitFor(
`document.body.innerText.indexOf('Local and national') < document.body.innerText.indexOf('Toll-free')`,
'new order on screen',
);
r = await testNumber('1-800-555-1234');
assert.match(r, /Denied: "Local and national/); // 1NXXNXXXXXX now matches first
let first = '';
for (let i = 0; i < 30 && !first.includes('_NXXNXXXXXX'); i++) {
await sleep(100);
const inc = cli('dialplan show sp-outbound-routes');
const ids = [...inc.matchAll(/Include =>\s+'sp-or-([0-9a-f-]+)'/g)].map((m) => m[1]);
assert.equal(ids.length, 2, `two enabled rules included (international is off):\n${inc}`);
first = cli(`dialplan show sp-or-${ids[0]}`);
}
assert.match(first, /_NXXNXXXXXX/, 'national is included first now');
assert.deepEqual(p.consoleErrors, [], 'no console errors');
console.log('Asterisk: include order follows the new rule order');
console.log('M3-T3 (UI → Asterisk): PASS');
} catch (e) {
await p.shot('m3-t3-FAILED').catch(() => {});
console.error('FAIL:', e.message, '\nconsole:', p.consoleErrors);
process.exitCode = 1;
} finally {
await p.close();
}