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:
1 parent
c117d8bb1d
commit
4496f6b89a
33 files changed
+1686
-70
No files matched your search
@@ -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)) {
|
||||
|
||||
@@ -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()
|
||||
+3
-3
@@ -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()
|
||||
@@ -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()
|
||||
+47
-3
@@ -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()
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
+55
-3
@@ -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()
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
+3
-3
@@ -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()
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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)
|
||||
exten => pending,1,Hangup()
|
||||
|
||||
[sp-outbound-routes] ; M3-T3 (03 §6.12)
|
||||
exten => pending,1,Hangup()
|
||||
`;
|
||||
@@ -20,6 +20,7 @@ export function loadCase(name: string): Snapshot {
|
||||
extensions?: Partial<Snapshot['extensions'][number]>[];
|
||||
dids?: Partial<Snapshot['dids'][number]>[];
|
||||
trunks?: Partial<Snapshot['trunks'][number]>[];
|
||||
routes?: Partial<Snapshot['routes'][number]>[];
|
||||
};
|
||||
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,
|
||||
|
||||
@@ -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<Record<GeneratedFileName, (snap: Snapshot) => string>> = {
|
||||
renderLocalNumbers(snap),
|
||||
renderTrunkEntries(snap),
|
||||
renderDids(snap),
|
||||
renderOutbound(snap),
|
||||
PENDING_CONTEXTS,
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -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<Snapshot>
|
||||
})
|
||||
.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,
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.' };
|
||||
}
|
||||
@@ -62,6 +62,7 @@ function snapshot(): Snapshot {
|
||||
settings,
|
||||
dids: [],
|
||||
trunks: [],
|
||||
routes: [],
|
||||
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<boolean>, 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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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-');
|
||||
|
||||
@@ -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<DidDto>('PATCH', `/api/dids/${id}`, d),
|
||||
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 }),
|
||||
};
|
||||
@@ -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([
|
||||
<DashboardPage />
|
||||
) : p.path === 'users' ? (
|
||||
<RequireAuth>{(user) => <UsersPage currentUserId={user.id} />}</RequireAuth>
|
||||
) : p.path === 'outbound' ? (
|
||||
<OutboundPage />
|
||||
) : p.path === 'numbers' ? (
|
||||
<NumbersPage />
|
||||
) : p.path === 'trunks' ? (
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 && <PhoneTab ext={ext.data} onSaved={() => onSaved()} />}
|
||||
{tab === 'calling' && ext.data && <CallingTab ext={ext.data} onSaved={() => onSaved()} />}
|
||||
{tab === 'credentials' && ext.data && <CredentialsTab ext={ext.data} />}
|
||||
</Dialog>
|
||||
);
|
||||
@@ -431,3 +436,63 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -9,3 +9,4 @@ export * from './extensions.ts';
|
||||
export * from './live.ts';
|
||||
export * from './trunks.ts';
|
||||
export * from './dids.ts';
|
||||
export * from './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<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, '');
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user