diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 1cd0d1b..b98424a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -16,6 +16,7 @@ import { systemRoutes } from './routes/system.ts'; import { extensionRoutes } from './routes/extensions.ts'; import { trunkRoutes } from './routes/trunks.ts'; import { didRoutes } from './routes/dids.ts'; +import { outboundRouteRoutes } from './routes/outbound.ts'; import { liveRoutes } from './live/hub.ts'; declare module 'fastify' { @@ -83,6 +84,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public')) app.register(extensionRoutes); app.register(trunkRoutes); app.register(didRoutes); + app.register(outboundRouteRoutes); app.register(liveRoutes); if (existsSync(publicDir)) { diff --git a/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf index 94d8bce..80af058 100644 --- a/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf @@ -106,12 +106,12 @@ include => sp-parkedcalls ; anything else exten => _[0-9a-z].,1,Hangup() +[sp-outbound-routes] +exten => pending,1,Hangup() ; no routes yet + ; ─── Filled in by later milestones [sp-emergency-dial] ; M3-T4 (03 §6.14) exten => pending,1,Hangup() [sp-featurecodes] ; M6-T7 (03 §6.13) exten => pending,1,Hangup() - -[sp-outbound-routes] ; M3-T3 (03 §6.12) -exten => pending,1,Hangup() diff --git a/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf index ba6ca79..5d2029b 100644 --- a/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf @@ -115,12 +115,12 @@ exten => 105,hint,PJSIP/105&PJSIP/105-web ; anything else exten => _[0-9a-z].,1,Hangup() +[sp-outbound-routes] +exten => pending,1,Hangup() ; no routes yet + ; ─── Filled in by later milestones [sp-emergency-dial] ; M3-T4 (03 §6.14) exten => pending,1,Hangup() [sp-featurecodes] ; M6-T7 (03 §6.13) exten => pending,1,Hangup() - -[sp-outbound-routes] ; M3-T3 (03 §6.12) -exten => pending,1,Hangup() diff --git a/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf index 21322ff..5d78dec 100644 --- a/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf @@ -115,12 +115,12 @@ exten => 13125551234,1,Set(__SP_DID=13125551234) ; anything else exten => _[0-9a-z].,1,Hangup() +[sp-outbound-routes] +exten => pending,1,Hangup() ; no routes yet + ; ─── Filled in by later milestones [sp-emergency-dial] ; M3-T4 (03 §6.14) exten => pending,1,Hangup() [sp-featurecodes] ; M6-T7 (03 §6.13) exten => pending,1,Hangup() - -[sp-outbound-routes] ; M3-T3 (03 §6.12) -exten => pending,1,Hangup() diff --git a/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf index 1ced10a..7519b1b 100644 --- a/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf @@ -140,12 +140,56 @@ exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound) ; anything else 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 [sp-emergency-dial] ; M3-T4 (03 §6.14) exten => pending,1,Hangup() [sp-featurecodes] ; M6-T7 (03 §6.13) exten => pending,1,Hangup() - -[sp-outbound-routes] ; M3-T3 (03 §6.12) -exten => pending,1,Hangup() diff --git a/apps/api/src/generator/__golden__/trunk-variants/snapshot.json b/apps/api/src/generator/__golden__/trunk-variants/snapshot.json index 05bfec4..1fe3bd3 100644 --- a/apps/api/src/generator/__golden__/trunk-variants/snapshot.json +++ b/apps/api/src/generator/__golden__/trunk-variants/snapshot.json @@ -1,5 +1,7 @@ { - "settings": { "sip.tcpEnabled": true }, + "settings": { + "sip.tcpEnabled": true + }, "trunks": [ { "id": "a1b2c3d4-0000-4000-8000-000000000001", @@ -21,12 +23,53 @@ "password": "p@ss w0rd!#", "fromUser": "15551230000", "fromDomain": "example.net", - "codecs": ["g722", "ulaw"] + "codecs": ["g722", "ulaw"], + "maxChannels": 4 }, { "id": "a1b2c3d4-0000-4000-8000-000000000003", "name": "Old provider", "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"] + } ] } diff --git a/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf index d13a418..7b0b206 100644 --- a/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf @@ -137,12 +137,64 @@ exten => 13125559876,1,Set(__SP_DID=13125559876) ; anything else 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 [sp-emergency-dial] ; M3-T4 (03 §6.14) exten => pending,1,Hangup() [sp-featurecodes] ; M6-T7 (03 §6.13) exten => pending,1,Hangup() - -[sp-outbound-routes] ; M3-T3 (03 §6.12) -exten => pending,1,Hangup() diff --git a/apps/api/src/generator/__golden__/voipms-basic/snapshot.json b/apps/api/src/generator/__golden__/voipms-basic/snapshot.json index a8d6caa..467b557 100644 --- a/apps/api/src/generator/__golden__/voipms-basic/snapshot.json +++ b/apps/api/src/generator/__golden__/voipms-basic/snapshot.json @@ -3,21 +3,117 @@ "routing.defaultCallerIdDidId": "11111111-1111-4111-8111-111111111111", "emergency.defaultCallerIdDidId": "11111111-1111-4111-8111-111111111111" }, - "extensions": [{ "number": "101", "name": "Dan Doe" }], - "trunks": [{ "id": "7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37" }], + "extensions": [ + { + "number": "101", + "name": "Dan Doe" + } + ], + "trunks": [ + { + "id": "7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37", + "maxChannels": 2 + } + ], "dids": [ { "id": "11111111-1111-4111-8111-111111111111", "number": "13125551234", "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", "number": "13125559876", "label": "Sales; line", "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"] } ] } diff --git a/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf index 9309192..2fad828 100644 --- a/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf @@ -112,12 +112,12 @@ exten => 102,hint,PJSIP/102 ; anything else exten => _[0-9a-z].,1,Hangup() +[sp-outbound-routes] +exten => pending,1,Hangup() ; no routes yet + ; ─── Filled in by later milestones [sp-emergency-dial] ; M3-T4 (03 §6.14) exten => pending,1,Hangup() [sp-featurecodes] ; M6-T7 (03 §6.13) exten => pending,1,Hangup() - -[sp-outbound-routes] ; M3-T3 (03 §6.12) -exten => pending,1,Hangup() diff --git a/apps/api/src/generator/dialplan/local.ts b/apps/api/src/generator/dialplan/local.ts index c60c10f..2aa53ad 100644 --- a/apps/api/src/generator/dialplan/local.ts +++ b/apps/api/src/generator/dialplan/local.ts @@ -1,10 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // 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 * 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). const VOICEMAIL_READY = false; @@ -42,8 +41,7 @@ export function renderGlobals(snap: Snapshot): string { 'SP_REC_ANNOUNCE=', // M7: recordings.announcementSoundId → sound name ]; const exts = [...snap.extensions].sort(byNumber); - for (const e of exts) - lines.push(`SP_CLASS_${s.digits(e.number)}=${CLASS_NUMBER[e.outboundClass]}`); + for (const e of exts) lines.push(`SP_CLASS_${s.digits(e.number)}=${CLASS_RANK[e.outboundClass]}`); for (const e of exts) if (e.isAdmin) lines.push(`SP_TCADMIN_${s.digits(e.number)}=1`); return lines.join('\n') + '\n'; } diff --git a/apps/api/src/generator/dialplan/outbound.ts b/apps/api/src/generator/dialplan/outbound.ts new file mode 100644 index 0000000..ee20f12 --- /dev/null +++ b/apps/api/src/generator/dialplan/outbound.ts @@ -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(); + 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'; +} diff --git a/apps/api/src/generator/dialplan/pending.ts b/apps/api/src/generator/dialplan/pending.ts index 492a79d..1c6b016 100644 --- a/apps/api/src/generator/dialplan/pending.ts +++ b/apps/api/src/generator/dialplan/pending.ts @@ -8,7 +8,4 @@ exten => pending,1,Hangup() [sp-featurecodes] ; M6-T7 (03 §6.13) exten => pending,1,Hangup() - -[sp-outbound-routes] ; M3-T3 (03 §6.12) -exten => pending,1,Hangup() `; diff --git a/apps/api/src/generator/golden.test.ts b/apps/api/src/generator/golden.test.ts index 9f35fee..2b5c932 100644 --- a/apps/api/src/generator/golden.test.ts +++ b/apps/api/src/generator/golden.test.ts @@ -20,6 +20,7 @@ export function loadCase(name: string): Snapshot { extensions?: Partial[]; dids?: Partial[]; trunks?: Partial[]; + routes?: Partial[]; }; const settings = Object.fromEntries( (Object.keys(SettingsSchemas) as SettingKey[]) @@ -38,6 +39,17 @@ export function loadCase(name: string): Snapshot { id: d.id!, 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) => ({ name: 'voip.ms', enabled: true, diff --git a/apps/api/src/generator/index.ts b/apps/api/src/generator/index.ts index 73f11a6..3246b7d 100644 --- a/apps/api/src/generator/index.ts +++ b/apps/api/src/generator/index.ts @@ -14,6 +14,7 @@ import { STATIC_DIALPLAN } from './dialplan/static.ts'; import { renderGlobals, renderLocalNumbers } from './dialplan/local.ts'; import { PENDING_CONTEXTS } from './dialplan/pending.ts'; import { renderDids, renderTrunkEntries } from './dialplan/inbound.ts'; +import { renderOutbound } from './dialplan/outbound.ts'; export { renderTransports }; @@ -46,6 +47,7 @@ const BODIES: Partial string>> = { renderLocalNumbers(snap), renderTrunkEntries(snap), renderDids(snap), + renderOutbound(snap), PENDING_CONTEXTS, ] .filter(Boolean) diff --git a/apps/api/src/generator/load.ts b/apps/api/src/generator/load.ts index 1dd02ac..0dff83f 100644 --- a/apps/api/src/generator/load.ts +++ b/apps/api/src/generator/load.ts @@ -3,10 +3,10 @@ import { eq } from 'drizzle-orm'; import { alias } from 'drizzle-orm/pg-core'; 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 { getAllSettings } from '../services/settings.ts'; -import type { Destination } from '@selfphoneng/shared'; +import type { Destination, OutboundClass, RoutePattern } from '@selfphoneng/shared'; export type SnapExtension = { id: string; @@ -36,6 +36,17 @@ export type SnapDid = { 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 = { id: string; name: string; @@ -60,6 +71,7 @@ export type Snapshot = { extensions: SnapExtension[]; dids: SnapDid[]; trunks: SnapTrunk[]; + routes: SnapRoute[]; }; export function todayIn(timezone: string, now = new Date()): string { @@ -94,10 +106,22 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise }) .from(dids); 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 { today: todayIn(settings['general.timezone'], now), settings, 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) => ({ id: t.id, name: t.name, diff --git a/apps/api/src/generator/pjsip.ts b/apps/api/src/generator/pjsip.ts index db09796..f0dc3b3 100644 --- a/apps/api/src/generator/pjsip.ts +++ b/apps/api/src/generator/pjsip.ts @@ -1,10 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // 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 * 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)[]) => [`[${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 vars = [ `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.emergencyCallerId && `set_var = SP_E911CID=${s.digits(e.emergencyCallerId)}`, `set_var = SP_RECMODE=${e.recordMode}`, diff --git a/apps/api/src/routes/outbound.ts b/apps/api/src/routes/outbound.ts new file mode 100644 index 0000000..88d7ec4 --- /dev/null +++ b/apps/api/src/routes/outbound.ts @@ -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 { + 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> => { + 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); + }); +} diff --git a/apps/api/src/routes/trunks.ts b/apps/api/src/routes/trunks.ts index a936284..df32b76 100644 --- a/apps/api/src/routes/trunks.ts +++ b/apps/api/src/routes/trunks.ts @@ -7,6 +7,7 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { TrunkCreateSchema, + formatNumber, TrunkUpdateSchema, type ListResponse, type TrunkDto, @@ -19,6 +20,7 @@ import { requireAdmin } from '../auth/guards.ts'; import { encrypt } from '../crypto/secrets.ts'; import { audit } from '../services/audit.ts'; import { getSetting } from '../services/settings.ts'; +import { createDefaultRoutes } from '../services/outbound.ts'; type TrunkRow = typeof trunks.$inferSelect; @@ -106,7 +108,7 @@ export async function trunkRoutes(app: FastifyInstance) { summary: `Created trunk ${created.name}`, 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'); return reply.code(201).send(toDto(created)); }); @@ -167,7 +169,7 @@ export async function trunkRoutes(app: FastifyInstance) { const t = await getTrunk(db, id); const usedBy = [ ...(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 diff --git a/apps/api/src/services/outbound.ts b/apps/api/src/services/outbound.ts new file mode 100644 index 0000000..173f01d --- /dev/null +++ b/apps/api/src/services/outbound.ts @@ -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 { + 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 { + 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.' }; +} diff --git a/apps/api/test/asterisk/call-test.ts b/apps/api/test/asterisk/call-test.ts index d4f6d8b..6e7fca9 100644 --- a/apps/api/test/asterisk/call-test.ts +++ b/apps/api/test/asterisk/call-test.ts @@ -62,6 +62,7 @@ function snapshot(): Snapshot { settings, dids: [], trunks: [], + routes: [], extensions: [ext('101', 'Alice'), ext('102', 'Bob')], }; } diff --git a/apps/api/test/asterisk/trunk-test.ts b/apps/api/test/asterisk/trunk-test.ts index b2b02d7..4a98861 100644 --- a/apps/api/test/asterisk/trunk-test.ts +++ b/apps/api/test/asterisk/trunk-test.ts @@ -32,6 +32,7 @@ const PHONES = 'sp-trunk-phones'; const EXT_ID = '00000000-0000-4000-8000-000000000101'; const PHONE_PASS = 'TrunkTest101xxxxxxxxxxxx'; 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 sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -47,6 +48,7 @@ function snapshot(): Snapshot { .filter((k) => k !== 'sms.webhookToken') .map((k) => [k, SettingsSchemas[k].parse(undefined)]), ) as Snapshot['settings']; + settings['routing.defaultCallerIdDidId'] = DID_ID; const trunk = (id: string, name: string, password: string) => ({ id, name, @@ -68,7 +70,7 @@ function snapshot(): Snapshot { settings, dids: [ { - id: '11111111-1111-4111-8111-111111111111', + id: DID_ID, number: DID, trunkId: GOOD, label: 'Main line', @@ -97,6 +99,21 @@ function snapshot(): Snapshot { }, ], 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() same => n,Echo() +; Our outbound calls arrive here: remember what the provider saw, then echo the audio. [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, seconds = 60) { @@ -230,6 +250,26 @@ async function main() { `${conf}:/etc/asterisk/selfphoneng`, 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) => waitFor( `${name} to boot`, @@ -346,20 +386,36 @@ async function main() { if (!toPhone) problems.push('no DialBegin to PJSIP/101'); else if (toPhone.CallerIDNum !== `1${MOBILE}`) problems.push(`101 saw caller ID ${toPhone.CallerIDNum}, expected 1${MOBILE} (normalized)`); - 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(`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(`${cols[1]}: rx ${rx}, tx ${tx}`); - if (!(rx > 250) || !(tx > 250)) - problems.push(`one-way or no audio on ${cols[1]}: rx ${rx}, tx ${tx}`); - } + await checkAudio('inbound'); + + // M3-T3: 101 dials a 10-digit number; it leaves as 11 digits with the DID as caller ID. + const bothUp = async () => { + const up = (await ami.command('core show channels concise')) + .split('\n') + .filter((l) => l.includes('!Up!')); + return ( + up.some((l) => l.startsWith('PJSIP/101-')) && up.some((l) => l.startsWith('PJSIP/trunk-')) + ); + }; + await ami.command(`channel originate PJSIP/101 extension 3125550000@sp-from-ext`); + 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'); // 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}`); 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(); diff --git a/apps/api/test/outbound.test.ts b/apps/api/test/outbound.test.ts new file mode 100644 index 0000000..e789c69 --- /dev/null +++ b/apps/api/test/outbound.test.ts @@ -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>; + let req: (method: string, url: string, payload?: unknown) => Promise; + 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); + }); +}); diff --git a/apps/api/test/trunks.test.ts b/apps/api/test/trunks.test.ts index 398523a..1da6424 100644 --- a/apps/api/test/trunks.test.ts +++ b/apps/api/test/trunks.test.ts @@ -9,7 +9,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { TrunkCreateSchema, type LiveMessage } from '@selfphoneng/shared'; import { AmiParser, field } from '../src/ami/events.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 { hasTestDb } from './db.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 }); }); - 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 .insert(dids) .values({ number: '13125551234', trunkId: id, destination: { type: 'hangup' } }) .returning(); const r = await req('DELETE', `/api/trunks/${id}`); 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(outboundRoutes); expect((await req('DELETE', `/api/trunks/${id}`)).statusCode).toBe(200); await settle(); expect(pjsip()).not.toContain('trunk-'); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 9ec6b6f..a9b62a4 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -10,6 +10,10 @@ import type { ExtensionUpdate, ListResponse, NumberKind, + OutboundRouteCreate, + OutboundRouteDto, + OutboundRouteUpdate, + RouteTestResult, SipCredentials, TrunkCreate, TrunkDto, @@ -97,3 +101,15 @@ export const didsApi = { update: (id: string, d: DidUpdate) => api('PATCH', `/api/dids/${id}`, d), remove: (id: string) => api<{ ok: true }>('DELETE', `/api/dids/${id}`), }; + +export const routesApi = { + list: () => + api>('GET', '/api/outbound-routes').then((r) => r.items), + create: (r: OutboundRouteCreate) => api('POST', '/api/outbound-routes', r), + update: (id: string, r: OutboundRouteUpdate) => + api('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('POST', '/api/outbound-routes/test', { extensionId, number }), +}; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index c9df52e..ad682fd 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -12,6 +12,7 @@ import { UsersPage } from './pages/admin/UsersPage.tsx'; import { DashboardPage } from './pages/admin/DashboardPage.tsx'; import { TrunksPage } from './pages/admin/TrunksPage.tsx'; import { NumbersPage } from './pages/admin/NumbersPage.tsx'; +import { OutboundPage } from './pages/admin/OutboundPage.tsx'; import { MePage } from './pages/user/MePage.tsx'; import './index.css'; @@ -28,6 +29,8 @@ const router = createBrowserRouter([ ) : p.path === 'users' ? ( {(user) => } + ) : p.path === 'outbound' ? ( + ) : p.path === 'numbers' ? ( ) : p.path === 'trunks' ? ( diff --git a/apps/web/src/pages/admin/AdminLayout.tsx b/apps/web/src/pages/admin/AdminLayout.tsx index 971c45b..c62cbf9 100644 --- a/apps/web/src/pages/admin/AdminLayout.tsx +++ b/apps/web/src/pages/admin/AdminLayout.tsx @@ -14,7 +14,7 @@ export const ADMIN_PAGES = [ { path: 'users', label: 'Users & Extensions', milestone: null }, { path: 'trunks', label: 'Trunks', 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: 'queues', label: 'Queues', milestone: 'M8' }, { path: 'ivrs', label: 'Digital receptionist', milestone: 'M6' }, diff --git a/apps/web/src/pages/admin/OutboundPage.tsx b/apps/web/src/pages/admin/OutboundPage.tsx new file mode 100644 index 0000000..00b1039 --- /dev/null +++ b/apps/web/src/pages/admin/OutboundPage.tsx @@ -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(null); + const [deleting, setDeleting] = useState(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 ( +
+
+

Outbound rules

+ +
+

+ 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. +

+ {routes.isError && {routes.error.message}} + {move.isError && {move.error.message}} + {routes.data?.length === 0 && ( +

+ No rules yet. They are created with your first trunk. +

+ )} + {routes.data && routes.data.length > 0 && ( +
    + {routes.data.map((r, i) => ( +
  1. + + + + + {i + 1}. + + {r.name} + + {!r.enabled && off} + + {r.patterns.map((p) => p.match).join(' ')} + + + {CLASS_LABEL[r.requiredClass]} · {r.trunkIds.map(trunkName).join(' → ')} + + + +
  2. + ))} +
+ )} + + + {editing && trunks.data && ( + ({ id: t.id, name: t.name }))} + onClose={() => setEditing(null)} + onSaved={() => { + setEditing(null); + void refresh(); + }} + /> + )} + + Delete {deleting?.name}? Numbers only this rule matches can no longer + be dialed. +

+ } + confirmLabel="Delete" + busy={remove.isPending} + error={remove.error?.message} + onCancel={() => setDeleting(null)} + onConfirm={() => deleting && remove.mutate(deleting.id)} + /> +
+ ); +} + +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 ( +
{ + e.preventDefault(); + if (number.trim()) test.mutate(); + }} + > +

Test a number

+
+ + {(a) => ( + + )} + + + {(a) => ( + setNumber(e.target.value)} + inputMode="tel" + placeholder="3125550000" + className={inputClass} + /> + )} + + +
+ {test.isError && {test.error.message}} + {r && ( +

+ {r.ok ? ( + <> + {r.routeName} sends {r.dialed} 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 + )} +

+ )} +
+ ); +} + +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; + +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(); + const form = useForm({ + 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(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 ( + + + + + } + > +
+ {error && {error}} + + {(a) => } + + + +
+ Patterns +
+ Pattern help +

+ X any digit, Z 1–9, N 2–9, [1-5]{' '} + one of those, . one or more of anything. Example: NXXNXXXXXX{' '} + is a 10-digit US number. Strip removes digits from the front,{' '} + prepend adds digits, e.g. strip 0 prepend 1 turns 3125550000 into + 13125550000. +

+
+ {patterns.fields.map((f, i) => ( +
+ + + + +
+ ))} + {errors.patterns && ( +

+ {errors.patterns.message ?? + errors.patterns.root?.message ?? + 'Check the patterns: digits and X Z N [1-5] . ! only'} +

+ )} + +
+ + + {(a) => ( + + )} + + +
+ Trunks, in the order to try them +
    + {chosen.map((id, i) => ( +
  1. + {i + 1}. + {trunks.find((t) => t.id === id)?.name ?? id} + + +
  2. + ))} +
+ {trunks.some((t) => !chosen.includes(t.id)) && ( + + )} + {errors.trunkIds && ( +

{errors.trunkIds.message}

+ )} +
+ + + {(a) => ( + + )} + +
+
+ ); +} diff --git a/apps/web/src/pages/admin/UserDrawer.tsx b/apps/web/src/pages/admin/UserDrawer.tsx index 1e867a4..347b54f 100644 --- a/apps/web/src/pages/admin/UserDrawer.tsx +++ b/apps/web/src/pages/admin/UserDrawer.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -// User + extension editor (05 §2). Tabs so far: General, Phone, Credentials. -// Voicemail (M4), Calling and Emergency (M3) and Forwarding (M6) arrive with their features. +// User + extension editor (05 §2). Tabs so far: General, Phone, Calling, Credentials. +// Voicemail (M4), Emergency (M3-T4) and Forwarding (M6) arrive with their features. import { zodResolver } from '@hookform/resolvers/zod'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useState, type ReactNode } from 'react'; @@ -8,17 +8,20 @@ import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { ExtensionUpdateSchema, + OUTBOUND_CLASSES, + formatNumber, InternalNumber, RoleSchema, type ExtensionDto, type SipCredentials, type UserDto, } 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 { Alert, Button, Field, inputClass } from '../../components/ui.tsx'; -type Tab = 'general' | 'phone' | 'credentials'; +type Tab = 'general' | 'phone' | 'calling' | 'credentials'; const GeneralSchema = z.object({ displayName: z.string().trim().min(1, 'Required').max(100), @@ -51,6 +54,7 @@ export function UserDrawer({ const tabs: { id: Tab; label: string; enabled: boolean }[] = [ { id: 'general', label: 'General', enabled: true }, { id: 'phone', label: 'Phone', enabled: Boolean(ext.data) }, + { id: 'calling', label: 'Calling', enabled: Boolean(ext.data) }, { id: 'credentials', label: 'Credentials', enabled: Boolean(ext.data) }, ]; @@ -101,6 +105,7 @@ export function UserDrawer({ /> )} {tab === 'phone' && ext.data && onSaved()} />} + {tab === 'calling' && ext.data && onSaved()} />} {tab === 'credentials' && ext.data && } ); @@ -431,3 +436,63 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) { ); } + +function CallingTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void }) { + const qc = useQueryClient(); + const dids = useQuery({ queryKey: ['dids'], queryFn: didsApi.list }); + const [error, setError] = useState(); + 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 ( +
+ {error && {error}} + + {(a) => ( + + )} + + + {(a) => ( + + )} + +
+ ); +} diff --git a/docs/spec/03-config-generator.md b/docs/spec/03-config-generator.md index 76b5305..14cad0f 100644 --- a/docs/spec/03-config-generator.md +++ b/docs/spec/03-config-generator.md @@ -699,11 +699,23 @@ music_on_hold_when_empty = yes ### 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 [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 => _1NXXNXXXXXX,1,Goto(sp-route-{id},${EXTEN:0},1) + +[sp-or-{id2}] exten => _011.,1,Goto(sp-route-{id2},${EXTEN},1) [sp-route-{id}] @@ -718,23 +730,26 @@ exten => _X.,1,Set(__SP_DIRECTION=outbound) same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID})) ; trunk 1 (with optional channel limit) 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,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(denied),Playback(ss-noservice) 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 | |---|---|---| -| Local/National (NANP) | `NXXNXXXXXX` (prepend `1`), `1NXXNXXXXXX` | national | | Toll-free | `1800NXXXXXX`, `1888NXXXXXX`, `1877NXXXXXX`, `1866NXXXXXX`, `1855NXXXXXX`, `1844NXXXXXX`, `1833NXXXXXX` | local | +| Local/National (NANP) | `NXXNXXXXXX` (prepend `1`), `1NXXNXXXXXX` | national | | 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` diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 14e93d7..3c2c172 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,3 +9,4 @@ export * from './extensions.ts'; export * from './live.ts'; export * from './trunks.ts'; export * from './dids.ts'; +export * from './outbound.ts'; diff --git a/packages/shared/src/outbound.ts b/packages/shared/src/outbound.ts new file mode 100644 index 0000000..4fba810 --- /dev/null +++ b/packages/shared/src/outbound.ts @@ -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 = { + 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; + +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; +export type OutboundRouteUpdate = z.input; +export type OutboundRouteDto = z.output & { + 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, ''); diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index ed64633..b09d760 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -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 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 -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 baresip phones, and `trunk-test.ts` with registration against a fake provider) needs no api at all. diff --git a/scripts/e2e/m3-t3.mjs b/scripts/e2e/m3-t3.mjs new file mode 100644 index 0000000..b688ae8 --- /dev/null +++ b/scripts/e2e/m3-t3.mjs @@ -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 +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(); +}