Spec 11 §6.1: sp-sub-dial-user asks /api/internal/caller-name (func_curl, 2 s at most; this machine only, with an APP_SECRET-derived key, not logged) for the ringing user's name for the caller, and sets it on each ringing leg (pre-dial handler, CONNECTEDLINE), never on the call, so forwards and other members don't see someone else's contacts. Ring group/queue/number prefixes kept. Contrack lookups never wait (cache, contactsWarm every 5 min). Proven on real Asterisk with baresip: apps/api/test/asterisk/cnam-test.ts.
1129 lines
48 KiB
Markdown
1129 lines
48 KiB
Markdown
# 03 — Config generator (DB → Asterisk)
|
|
|
|
This is the heart of the product. Read all of it before changing any call-routing code.
|
|
|
|
Code lives in `apps/api/src/generator/`.
|
|
|
|
## 1. Interface
|
|
|
|
```ts
|
|
// load.ts
|
|
export type Snapshot = { /* every table from 02-data-model.md that the generator needs, fully loaded,
|
|
plus resolved settings and decrypted secrets */ };
|
|
export async function loadSnapshot(db: Db): Promise<Snapshot>;
|
|
|
|
// index.ts
|
|
export type RenderedFiles = Record<GeneratedFileName, string>;
|
|
export function render(snap: Snapshot): RenderedFiles; // PURE: no I/O, no Date.now(), no randomness
|
|
export async function apply(opts: { trigger: string; force?: boolean }): Promise<ApplyResult>;
|
|
export function requestApply(trigger: string): void; // debounced 1000 ms, calls apply()
|
|
```
|
|
|
|
Rules for `render()`:
|
|
- **Pure and deterministic.** Same snapshot → byte-identical output. Sort
|
|
everything: users by number, routes by position, members by position, and
|
|
objects without a natural order by `id`.
|
|
- The current date is not read inside `render()`. Holiday filtering needs
|
|
"today", so `loadSnapshot` puts `snap.today` (a `YYYY-MM-DD` string in `general.timezone`) into the snapshot.
|
|
- Every file starts with the header
|
|
`; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.`
|
|
- Never put a value into output without passing it through a sanitizer (§2).
|
|
|
|
`apply()` steps:
|
|
1. Take a process-wide mutex. Only one apply runs at a time.
|
|
2. `loadSnapshot()` → `render()`.
|
|
3. For each file, compare with what is on disk. Collect `changed`.
|
|
4. If nothing changed and not `force`: record a `generator_runs` row and return.
|
|
5. Copy the current files to `/etc/asterisk/selfphoneng/.prev/` (keep one previous version).
|
|
6. For each changed file, write `<name>.tmp` then `rename()` it over the old one (atomic).
|
|
7. Work out reload commands from the changed files (§3), or from every file when `force`. If the PJSIP
|
|
transport sections changed, don't reload PJSIP. Set
|
|
`system.restartRequired = true` instead (see `01-infrastructure.md` §4.3). Exception: the very first
|
|
apply (no stored `system.transportHash`) reloads PJSIP normally, since it only adds transports to an
|
|
Asterisk that booted with none.
|
|
8. Send each command as AMI `Action: Command`. If AMI is down, mark the run
|
|
`ok=false, error='ami-disconnected'`. The AMI reconnect handler calls `apply({force:true})` later.
|
|
9. After `queue reload`: re-send every paused queue member (`QueuePause`), because a reload resets pause state.
|
|
10. Insert a `generator_runs` row. Broadcast `system.configApplied` on the live WebSocket.
|
|
|
|
`POST /api/system/config/rollback` copies `.prev/*` back and reloads everything.
|
|
|
|
## 2. Sanitizers (`sanitize.ts`)
|
|
|
|
Dialplan and config files are code. A display name containing `)` or `;` or
|
|
a newline could break or inject dialplan. Every value goes through one of these:
|
|
|
|
| Function | Allowed | Used for | On bad input |
|
|
|---|---|---|---|
|
|
| `digits(v)` | `^[0-9]+$` | internal numbers, DIDs | throw |
|
|
| `dialable(v)` | `^[0-9*#+]+$` | forward targets, external numbers | throw |
|
|
| `pattern(v)` | `^[0-9XZN.!\[\]\-]+$`, max 32 | outbound route patterns | throw |
|
|
| `name(v)` | removes `" ' \ ; , ( ) [ ] { } $ < > \|` and control chars, trims, max 40 chars | caller ID names, labels | clean silently |
|
|
| `sound(v)` | `^[a-z0-9/_-]+$` | sound file names for Playback | throw |
|
|
| `ident(v)` | `^[a-z0-9-]+$` | context-name parts (uuids), MOH class names | throw |
|
|
| `secret(v)` | `^[A-Za-z0-9]+$` | generated SIP passwords | throw |
|
|
| `pin(v)` | `^[0-9]{4,10}$` | voicemail / conference PINs | throw |
|
|
| `host(v)` | hostname or IPv4 regex | trunk server host | throw |
|
|
| `cidr(v)` | IPv4 CIDR regex | local nets | throw |
|
|
|
|
The API validates with the same rules (shared zod schemas in `packages/shared`),
|
|
so a throw here means a bug, and the apply fails loudly. Also write a
|
|
unit test for each sanitizer with hostile input (`101); System(rm -rf /`, newlines, unicode).
|
|
|
|
## 3. Generated files and reload commands
|
|
|
|
All files go to `/etc/asterisk/selfphoneng/`.
|
|
|
|
| File | Module | AMI `Command:` after change |
|
|
|---|---|---|
|
|
| `pjsip_selfphoneng.conf` | res_pjsip | `module reload res_pjsip.so` (or restart-required if transports changed). Asterisk 24 has no `pjsip reload` CLI command. |
|
|
| `extensions_selfphoneng.conf` | pbx_config | `dialplan reload` |
|
|
| `extensions_globals_selfphoneng.conf` | pbx_config | `dialplan reload` |
|
|
| `voicemail_selfphoneng.conf` | app_voicemail | `voicemail reload` |
|
|
| `queues_selfphoneng.conf` | app_queue | `queue reload all` |
|
|
| `musiconhold_selfphoneng.conf` | res_musiconhold | `moh reload` |
|
|
| `rtp_selfphoneng.conf` | res_rtp_asterisk | `module reload res_rtp_asterisk.so` |
|
|
| `confbridge_selfphoneng.conf` | app_confbridge | `module reload app_confbridge.so` |
|
|
|
|
Each command is sent once per apply, even if several files map to it.
|
|
|
|
**Transport-change detection:** the "transport block" is every section with
|
|
`type=transport` plus `[global]` and `[system]`. Render it as its own string
|
|
(`renderTransports(snap)`) and compare its sha256 with the one stored in
|
|
`settings['system.transportHash']`.
|
|
|
|
## 4. `rtp_selfphoneng.conf`
|
|
|
|
```ini
|
|
; GENERATED ...
|
|
icesupport = yes
|
|
stunaddr = {network.stunServer} ; omit line when null
|
|
{if network.lanIp and network.publicIp}
|
|
[ice_host_candidates]
|
|
{network.lanIp} => {network.publicIp},include_local_address
|
|
{/if}
|
|
```
|
|
(`network.lanIp` is the server's own LAN address, see `02-data-model.md` §3.)
|
|
`include_local_address` keeps the LAN host candidate next to the public one. Without it a browser
|
|
on the LAN only gets the public address and needs hairpin NAT, which many routers lack (M5-T1).
|
|
|
|
## 5. `pjsip_selfphoneng.conf`
|
|
|
|
Render the sections in this order: global, system, transports, extensions
|
|
(sorted by number), trunks (sorted by name).
|
|
|
|
### 5.1 Global, system, transports
|
|
|
|
```ini
|
|
[global]
|
|
type = global
|
|
user_agent = SelfPhoneNG
|
|
endpoint_identifier_order = ip,username,anonymous
|
|
keep_alive_interval = 90
|
|
max_initial_qualify_time = 4
|
|
|
|
[system]
|
|
type = system
|
|
timer_t1 = 500
|
|
timer_b = 32000
|
|
|
|
[transport-udp]
|
|
type = transport
|
|
protocol = udp
|
|
bind = 0.0.0.0:{sip.udpPort}
|
|
{natLines}
|
|
|
|
{if sip.tcpEnabled}
|
|
[transport-tcp]
|
|
type = transport
|
|
protocol = tcp
|
|
bind = 0.0.0.0:{sip.udpPort}
|
|
{natLines}
|
|
{/if}
|
|
|
|
{if sip.tlsEnabled}
|
|
[transport-tls]
|
|
type = transport
|
|
protocol = tls
|
|
bind = 0.0.0.0:{sip.tlsPort}
|
|
cert_file = /var/lib/selfphoneng/tls/fullchain.pem
|
|
priv_key_file = /var/lib/selfphoneng/tls/privkey.pem
|
|
method = tlsv1_2
|
|
verify_server = yes ; as built (M11-T2): check the provider (08 §2.1)
|
|
ca_list_file = /etc/ssl/certs/ca-certificates.crt
|
|
{natLines}
|
|
{/if}
|
|
|
|
[transport-ws]
|
|
type = transport
|
|
protocol = ws
|
|
bind = 0.0.0.0
|
|
```
|
|
`{natLines}` is empty when `network.publicIp` is null. Otherwise:
|
|
```ini
|
|
external_media_address = {publicIp}
|
|
external_signaling_address = {publicIp}
|
|
local_net = {each localNets entry on its own line}
|
|
```
|
|
|
|
Notes:
|
|
- The WebSocket bind address is ignored. The browser connects through Asterisk's HTTP server (`http.conf`).
|
|
If browser registration fails with a "no transport" error, change
|
|
`protocol = ws` to `protocol = wss` and fix the spec.
|
|
- The asterisk container needs `./data/tls:/var/lib/selfphoneng/tls:ro`. Added in M11-T2 (the api mounts it read-only too, as `TLS_DIR`).
|
|
- No `anonymous` endpoint is ever generated, so unauthenticated calls from the internet are rejected.
|
|
|
|
### 5.2 One extension (example: 101, Dan Doe, webrtc on)
|
|
|
|
```ini
|
|
; ── extension 101 (Dan Doe) ──
|
|
[101]
|
|
type = endpoint
|
|
context = sp-from-ext
|
|
disallow = all
|
|
allow = g722,ulaw,alaw
|
|
auth = 101-auth
|
|
aors = 101
|
|
callerid = "Dan Doe" <101>
|
|
mailboxes = 101@default ; omit if voicemail disabled
|
|
mwi_subscribe_replaces_unsolicited = yes ; omit if voicemail disabled. The AOR also sends unsolicited MWI;
|
|
; without this a phone that SUBSCRIBEs gets 500 "already configured
|
|
; for unsolicited MWI" (checked on 24.0.0-rc2, M4-T1)
|
|
direct_media = no
|
|
rtp_symmetric = yes
|
|
force_rport = yes
|
|
rewrite_contact = yes
|
|
dtmf_mode = rfc4733
|
|
send_pai = yes
|
|
allow_subscribe = yes
|
|
named_call_group = sales ; omit if pickup_group null
|
|
named_pickup_group = sales ; omit if pickup_group null
|
|
language = en
|
|
moh_suggest = default
|
|
tone_zone = us
|
|
set_var = SP_EXT=101
|
|
set_var = SP_CLASS=2 ; internal=0 local=1 national=2 international=3
|
|
set_var = SP_OUTCID=13125551234 ; omit if outbound_caller_id_did_id null
|
|
set_var = SP_E911CID=13125551234 ; omit if emergency_caller_id_did_id null
|
|
set_var = SP_RECMODE=never
|
|
set_var = __SP_DIRECTION=internal ; loads as variable "__SP_DIRECTION" (checked on 24.0.0-rc2); inheritance to
|
|
; dialed channels still to be proven by a call test (M2-T6)
|
|
|
|
[101-auth]
|
|
type = auth
|
|
auth_type = userpass
|
|
username = 101
|
|
password = {decrypted sip password}
|
|
|
|
[101]
|
|
type = aor
|
|
max_contacts = 5
|
|
remove_existing = yes
|
|
qualify_frequency = 30
|
|
mailboxes = 101@default ; omit if voicemail disabled
|
|
|
|
{if webrtc_enabled}
|
|
[101-web]
|
|
type = endpoint
|
|
context = sp-from-ext
|
|
webrtc = yes ; turns on ICE, AVPF, DTLS-SRTP, rtcp-mux
|
|
disallow = all
|
|
allow = g722,ulaw ; NO opus: no open source transcoder in Asterisk (AGENTS rule 4)
|
|
auth = 101-web-auth
|
|
aors = 101-web
|
|
from_domain = selfphoneng.invalid ; else the From host is the machine's hostname; SIP.js drops
|
|
; INVITEs whose host label starts with a digit (M5-T1)
|
|
callerid = "Dan Doe" <101>
|
|
mailboxes = 101@default
|
|
mwi_subscribe_replaces_unsolicited = yes
|
|
dtmf_mode = rfc4733
|
|
allow_subscribe = yes
|
|
named_call_group = sales
|
|
named_pickup_group = sales
|
|
language = en
|
|
set_var = (same set_var lines as [101])
|
|
|
|
[101-web-auth]
|
|
type = auth
|
|
auth_type = userpass
|
|
username = 101-web
|
|
password = {same decrypted sip password}
|
|
|
|
[101-web]
|
|
type = aor
|
|
max_contacts = 3
|
|
remove_existing = yes
|
|
qualify_frequency = 0 ; browsers don't answer OPTIONS reliably
|
|
{/if}
|
|
```
|
|
|
|
Desk phones and softphones register as user `101`. The browser softphone
|
|
registers as `101-web`. Dialing user 101 rings both (§6.2).
|
|
|
|
### 5.3 One voip.ms trunk (registration mode)
|
|
|
|
Trunk section names use the trunk id: `trunk-{id}`. Example values: server
|
|
`chicago1.voip.ms`, sub-account `123456_pbx`.
|
|
|
|
```ini
|
|
; ── trunk "voip.ms" ──
|
|
[trunk-{id}-auth]
|
|
type = auth
|
|
auth_type = userpass
|
|
username = 123456_pbx
|
|
password = {decrypted}
|
|
|
|
[trunk-{id}-reg]
|
|
type = registration
|
|
transport = transport-udp ; transport-tls when transport=tls
|
|
outbound_auth = trunk-{id}-auth
|
|
server_uri = sip:chicago1.voip.ms:5060
|
|
client_uri = sip:123456_pbx@chicago1.voip.ms:5060
|
|
contact_user = 123456_pbx
|
|
retry_interval = 60
|
|
forbidden_retry_interval = 600
|
|
fatal_retry_interval = 600
|
|
max_retries = 10000
|
|
expiration = 300
|
|
line = yes
|
|
endpoint = trunk-{id}
|
|
|
|
[trunk-{id}]
|
|
type = aor
|
|
contact = sip:chicago1.voip.ms:5060
|
|
qualify_frequency = 60
|
|
|
|
[trunk-{id}]
|
|
type = endpoint
|
|
transport = transport-udp
|
|
context = sp-from-trunk-{id}
|
|
identify_by = ip ; M11-T8: no inbound auth, so never match by From username
|
|
disallow = all
|
|
allow = ulaw ; from trunks.codecs
|
|
outbound_auth = trunk-{id}-auth
|
|
aors = trunk-{id}
|
|
from_user = 123456_pbx
|
|
from_domain = chicago1.voip.ms
|
|
direct_media = no
|
|
rtp_symmetric = yes
|
|
force_rport = yes
|
|
rewrite_contact = yes
|
|
send_rpid = yes ; carries our caller ID to voip.ms
|
|
trust_id_inbound = yes
|
|
dtmf_mode = rfc4733
|
|
media_encryption = sdes ; only when srtp = true (requires transport = tls)
|
|
|
|
[trunk-{id}-identify]
|
|
type = identify
|
|
endpoint = trunk-{id}
|
|
match = chicago1.voip.ms
|
|
```
|
|
For `auth_mode = 'ip'`, leave out the `-auth` and `-reg` sections and the `outbound_auth` line
|
|
(and `from_user` when there is no username).
|
|
Skip disabled trunks entirely.
|
|
For TCP/TLS trunks, `server_uri`, `client_uri` and the AOR `contact` get `;transport=tcp|tls`.
|
|
As built (M11-T2): TLS trunks get `qualify_frequency = 0` (Asterisk 24 checks a qualify's certificate
|
|
against the IP, see 08 §2.1); their health comes from the registration.
|
|
`identify` resolves `match` when the config loads, and an unresolvable name is an ERROR that drops the
|
|
trunk's identify (verified), so the API refuses a server name that doesn't resolve, and a transport
|
|
that isn't turned on in Settings → SIP.
|
|
|
|
## 6. Dialplan: `extensions_selfphoneng.conf`
|
|
|
|
### 6.0 Destination → dialplan (`destinations.ts`)
|
|
|
|
`destApp(dest, snap): string` returns ONE dialplan application call that sends the call there:
|
|
|
|
| Destination | Output |
|
|
|---|---|
|
|
| `user` | `Goto(sp-local-numbers,{ext.number},1)` |
|
|
| `voicemail` | `Goto(sp-vm-direct,{ext.number},1)` |
|
|
| `ring_group` | `Goto(sp-rg-{id},s,1)` |
|
|
| `queue` | `Goto(sp-queue-{id},s,1)` |
|
|
| `ivr` | `Goto(sp-ivr-{id},s,1)` |
|
|
| `time_condition` | `Goto(sp-tc-{id},s,1)` |
|
|
| `conference` | `Goto(sp-conf-{id},s,1)` |
|
|
| `announcement` | `Goto(sp-ann-{id},s,1)` |
|
|
| `external` | `Goto(sp-ext-forward,{dialable(number)},1)` |
|
|
| `hangup` | `Hangup()` |
|
|
|
|
If the target row is missing (should be impossible, see 02 §1), output `Hangup()` and log a warning.
|
|
|
|
### 6.1 Contexts: complete list
|
|
|
|
| Context | Kind | Purpose |
|
|
|---|---|---|
|
|
| `sp-from-ext` | static | Entry point for calls from our phones |
|
|
| `sp-from-trunk-{id}` | per trunk | Entry point for calls from a trunk |
|
|
| `sp-did-exists` | generated | One empty exten per DID, used to check "is this one of ours?" |
|
|
| `sp-inbound` | generated | DID → destination |
|
|
| `sp-local-numbers` | generated | Every internal number + hints |
|
|
| `sp-featurecodes` | static | `*` codes |
|
|
| `sp-emergency-dial` / `sp-emergency` | generated | 911 |
|
|
| `sp-outbound-routes` | generated | Pattern → route |
|
|
| `sp-route-{id}` | per route | Permission check, caller ID, trunk failover |
|
|
| `sp-rg-{id}` | per ring group | |
|
|
| `sp-queue-{id}` | per queue | |
|
|
| `sp-ivr-{id}` | per IVR | |
|
|
| `sp-tc-{id}` | per time condition | |
|
|
| `sp-ann-{id}` | per announcement | |
|
|
| `sp-conf-{id}` | per conference | |
|
|
| `sp-page-{id}` | per paging group | |
|
|
| `sp-vm-direct`, `sp-ext-forward`, `sp-rg-member`, `sp-queue-member` | static | helpers |
|
|
| `sp-sub-*` | static | Gosub subroutines |
|
|
| `sp-parkedcalls` | made by res_parking | included by `sp-local-numbers` |
|
|
|
|
"static" means the text is a constant string in the generator (`dialplan/static.ts`)
|
|
with no DB data, so all dialplan stays in one place in code.
|
|
|
|
### 6.2 Static subroutines and helpers
|
|
|
|
```ini
|
|
; ─── Normalize a number. ARG1 = raw. Returns normalized in GOSUB_RETVAL. (matches 00 §8)
|
|
[sp-sub-normalize]
|
|
exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
|
same => n,ExecIf($["${N:0:2}" = "+1"]?Set(N=${N:1}))
|
|
same => n,ExecIf($["${N:0:1}" = "+"]?Set(N=011${N:1}))
|
|
same => n,ExecIf($[${LEN(${N})} = 10]?Set(N=1${N}))
|
|
same => n,Return(${N})
|
|
|
|
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
|
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
|
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
|
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
|
; never sees someone else's contact name.
|
|
[sp-sub-dial-user]
|
|
exten => s,1,Set(DS=)
|
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
|
same => n,GotoIf($["${DS}" = ""]?unavail)
|
|
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
|
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
|
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
|
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
|
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
|
same => n,Set(_SP_CNAM=)
|
|
same => n,Return()
|
|
same => n(plain),Dial(${DS},${ARG2},tx)
|
|
same => n,Return()
|
|
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
|
same => n,Return()
|
|
|
|
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
|
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
|
[sp-sub-cnam]
|
|
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
|
same => n,Set(CURLOPT(httptimeout)=2)
|
|
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
|
|
|
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
|
[sp-predial-cnam]
|
|
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
|
same => n,Return()
|
|
|
|
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
|
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
|
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
|
; in its Contact from reaching Dial's options (',') or another device ('&').
|
|
[sp-sub-contacts]
|
|
exten => s,1,Set(LOCAL(CL)=${PJSIP_AOR(${ARG1},contact)})
|
|
same => n(next),Set(LOCAL(C)=${POP(CL)})
|
|
same => n,GotoIf($["${C}" = ""]?done)
|
|
same => n,Set(LOCAL(ST)=${PJSIP_CONTACT(${C},status)})
|
|
same => n,GotoIf($["${ST}" = "Unavailable" | "${ST}" = "Unreachable"]?next)
|
|
same => n,Set(LOCAL(U)=${PJSIP_CONTACT(${C},uri)})
|
|
same => n,Set(LOCAL(D)=${CUT(U,\;,1)})
|
|
same => n,Set(LOCAL(I)=1)
|
|
same => n(param),Set(I=$[${I} + 1])
|
|
same => n,GotoIf($[${I} > ${FIELDQTY(U,\;)}]?add)
|
|
same => n,Set(LOCAL(P)=${CUT(U,\;,${I})})
|
|
same => n,GotoIf($["${P:0:3}" = "pn-"]?param)
|
|
same => n,Set(D=${D}\;${P})
|
|
same => n,Goto(param)
|
|
same => n(add),Set(DS=${DS}${IF($["${DS}" = ""]?:&)}PJSIP/${ARG1}/${FILTER(0-9a-zA-Z.:@\;=_+%[]\x2d,${D})})
|
|
same => n,Goto(next)
|
|
same => n(done),Return()
|
|
|
|
; ─── Call a user with all their personal rules.
|
|
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
|
[sp-sub-user]
|
|
exten => s,1,Set(__SP_TARGET=${ARG1})
|
|
same => n,ExecIf($["${SP_DIRECTION}" = "inbound" & ("${ARG4}" = "inbound" | "${ARG4}" = "all")]?Gosub(sp-sub-record,s,1))
|
|
same => n,GotoIf($["${DB(SP/DND/${ARG1})}" = "1"]?busy)
|
|
same => n,Set(FWD=${DB(SP/CFU/${ARG1})})
|
|
same => n,GotoIf($["${FWD}" != ""]?forward)
|
|
same => n,Gosub(sp-sub-dial-user,s,1(${ARG1},${ARG2}))
|
|
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
|
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
|
|
same => n(noanswer),Set(FWD=${DB(SP/CFNA/${ARG1})})
|
|
same => n,GotoIf($["${FWD}" != ""]?forward)
|
|
same => n,GotoIf($["${ARG3}" = "1"]?vmu)
|
|
same => n,Return()
|
|
same => n(vmu),VoiceMail(${ARG1}@default,u)
|
|
same => n,Return()
|
|
same => n(busy),Set(FWD=${DB(SP/CFB/${ARG1})})
|
|
same => n,GotoIf($["${FWD}" != ""]?forward)
|
|
same => n,GotoIf($["${ARG3}" = "1"]?vmb)
|
|
same => n,Busy(5)
|
|
same => n,Return()
|
|
same => n(vmb),VoiceMail(${ARG1}@default,b)
|
|
same => n,Return()
|
|
same => n(forward),Gosub(sp-sub-forward,s,1(${FWD},${ARG1}))
|
|
same => n(done),Return()
|
|
|
|
; ─── Forward a call. ARG1 = target number, ARG2 = ext whose forward rule this is.
|
|
; Stops after 3 hops so 101→102→101 can't loop forever.
|
|
[sp-sub-forward]
|
|
exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} + 1])
|
|
same => n,GotoIf($[${SP_FWDHOPS} > 3]?stop)
|
|
same => n,GotoIf($[${DIALPLAN_EXISTS(sp-local-numbers,${ARG1},1)}]?internal)
|
|
same => n,Set(SP_CLASS=${GLOBAL(SP_CLASS_${ARG2})})
|
|
same => n,Set(SP_LIMITEXT=${ARG2}) ; counts toward that extension's call limit
|
|
same => n,Set(__SP_FORWARDED=1)
|
|
same => n,Goto(sp-outbound-routes,${ARG1},1)
|
|
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
|
|
same => n(stop),Return()
|
|
|
|
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
|
[sp-sub-record]
|
|
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
|
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
|
same => n,MixMonitor(${SP_REC},b)
|
|
same => n(done),Return()
|
|
|
|
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
|
[sp-sub-autoanswer]
|
|
exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=<sip:${SP_EXT}>\;answer-after=0)
|
|
same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer)
|
|
same => n,Return()
|
|
|
|
[sp-vm-direct]
|
|
exten => _X.,1,Answer()
|
|
same => n,VoiceMail(${EXTEN}@default,u)
|
|
same => n,Hangup()
|
|
|
|
[sp-ext-forward]
|
|
exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external destination
|
|
same => n,Set(__SP_FORWARDED=1)
|
|
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
|
|
|
[sp-rg-member] ; one member of a ring group (Local channel)
|
|
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
|
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
|
same => n(skip),Hangup()
|
|
|
|
[sp-queue-member] ; one queue agent (Local channel)
|
|
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
|
same => n,Hangup()
|
|
|
|
[sp-from-ext]
|
|
include => sp-emergency-dial
|
|
include => sp-featurecodes
|
|
include => sp-local-numbers
|
|
include => sp-outbound-routes
|
|
```
|
|
|
|
### 6.3 Globals file `extensions_globals_selfphoneng.conf`
|
|
|
|
```ini
|
|
SP_DEFAULT_CID={routing.defaultCallerIdDidId → number, or empty}
|
|
SP_DEFAULT_E911_CID={emergency.defaultCallerIdDidId → number, or SP_DEFAULT_CID}
|
|
SP_REC_ANNOUNCE={recordings.announcementSoundId → asterisk_name, or empty}
|
|
SP_CLASS_101=2 ; one per extension, used by sp-sub-forward
|
|
SP_TCADMIN_101=1 ; one per extension whose user is an admin (time-condition override codes)
|
|
```
|
|
(`routing.defaultCallerIdDidId` is in 02 §3.)
|
|
|
|
### 6.4 Trunk entry `sp-from-trunk-{id}`
|
|
|
|
```ini
|
|
[sp-from-trunk-{id}]
|
|
exten => s,1,Goto(sp-from-trunk-{id},unknown,1)
|
|
exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
|
|
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN}))
|
|
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
|
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
|
|
; Request-URI didn't hold one of our DIDs: try the To header (see 06 §3)
|
|
same => n,Set(TOU=${CUT(CUT(PJSIP_HEADER(read,To),@,1),:,2)})
|
|
same => n,Gosub(sp-sub-normalize,s,1(${TOU}))
|
|
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
|
same => n(caller),Gosub(sp-sub-normalize,s,1(${CALLERID(num)}))
|
|
same => n,Set(CALLERID(num)=${GOSUB_RETVAL})
|
|
same => n,GotoIf($["${DB(SP/BLOCK/${CALLERID(num)})}" = "1"]?blocked)
|
|
same => n,Goto(sp-inbound,${IF($["${SP_IN}" = ""]?unknown:${SP_IN})},1)
|
|
same => n(blocked),Hangup(21)
|
|
```
|
|
|
|
### 6.5 `sp-did-exists` and `sp-inbound`
|
|
|
|
```ini
|
|
[sp-did-exists]
|
|
exten => 13125551234,1,NoOp() ; one line per DID
|
|
|
|
[sp-inbound]
|
|
; DID 13125551234 "Main line"
|
|
exten => 13125551234,1,Set(__SP_DID=13125551234)
|
|
same => n,Set(CALLERID(name)=SALES:${CALLERID(name)}) ; only if caller_id_name_prefix
|
|
same => n,Answer() ; only if record_inbound and announcement set
|
|
same => n,Playback({SP_REC_ANNOUNCE sound}) ; only if record_inbound and announcement set
|
|
same => n,Gosub(sp-sub-record,s,1) ; only if record_inbound
|
|
same => n,{destApp(did.destination)}
|
|
|
|
; anything else
|
|
exten => _[0-9a-z].,1,{destApp(routing.unmatchedDidDestination)}
|
|
```
|
|
Leave `[sp-did-exists]` out when there are no DIDs: Asterisk doesn't create an empty context, and
|
|
`DIALPLAN_EXISTS` on a missing context is simply false. `record_inbound` arrives with recording (M7).
|
|
|
|
### 6.6 `sp-local-numbers`
|
|
|
|
```ini
|
|
[sp-local-numbers]
|
|
include => sp-parkedcalls
|
|
|
|
; users (sorted by number)
|
|
exten => 101,1,Gosub(sp-sub-user,s,1(101,25,1,never)) ; (ext, ring_seconds, voicemail 1/0, record_mode)
|
|
same => n,Hangup()
|
|
exten => 101,hint,PJSIP/101&PJSIP/101-web ; just PJSIP/101 when webrtc disabled
|
|
|
|
; ring groups, queues, ivrs, conferences, paging groups (each one line)
|
|
exten => 500,1,Goto(sp-rg-{id},s,1)
|
|
exten => 550,1,Goto(sp-queue-{id},s,1)
|
|
exten => 600,1,Goto(sp-ivr-{id},s,1)
|
|
exten => 800,1,Goto(sp-conf-{id},s,1)
|
|
exten => 850,1,Goto(sp-page-{id},s,1)
|
|
```
|
|
|
|
### 6.7 Ring groups
|
|
|
|
`ringall`:
|
|
```ini
|
|
[sp-rg-{id}]
|
|
exten => s,1,Set(__SP_TARGET=500)
|
|
same => n,Set(CALLERID(name)={prefix}${CALLERID(name)}) ; only if prefix
|
|
same => n,Dial(Local/101@sp-rg-member&Local/102@sp-rg-member,{ring_seconds},t{m if music_instead_of_ring})
|
|
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
|
same => n,{destApp(no_answer_destination)}
|
|
same => n(done),Hangup()
|
|
```
|
|
`hunt` (one member at a time, in `position` order):
|
|
```ini
|
|
[sp-rg-{id}]
|
|
exten => s,1,Set(__SP_TARGET=500)
|
|
same => n,Dial(Local/101@sp-rg-member,{ring_seconds},t)
|
|
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
|
same => n,Dial(Local/102@sp-rg-member,{ring_seconds},t)
|
|
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
|
same => n,{destApp(no_answer_destination)}
|
|
same => n(done),Hangup()
|
|
```
|
|
With zero members, leave out the Dial lines.
|
|
Ring groups deliberately **ignore** members' call forwarding and send nobody to voicemail.
|
|
The caller-name prefix (here and on DIDs) goes through `name()`, which trims, so one space is
|
|
always put between it and the caller's name: `Set(CALLERID(name)={prefix} ${CALLERID(name)})`.
|
|
Golden case `ringgroups`; `scripts/e2e/m6-t2.mjs` checks ring-all and hunt with real calls (M6-T2).
|
|
|
|
### 6.8 Queues
|
|
|
|
`queues_selfphoneng.conf` (the queue name is its number):
|
|
```ini
|
|
[550]
|
|
strategy = rrmemory
|
|
timeout = 15 ; member_ring_seconds
|
|
retry = 5
|
|
wrapuptime = 10
|
|
maxlen = 0
|
|
musicclass = default
|
|
announce-frequency = 60 ; 0 when announce_position = false
|
|
announce-position = yes
|
|
ringinuse = no
|
|
setinterfacevar = yes
|
|
; when join_when_no_agents = false add these two lines:
|
|
joinempty = paused,unavailable,invalid,unknown
|
|
leavewhenempty = paused,unavailable,invalid,unknown
|
|
member => Local/101@sp-queue-member/n,0,Dan Doe,hint:101@sp-local-numbers
|
|
member => Local/102@sp-queue-member/n,0,Ann Lee,hint:102@sp-local-numbers
|
|
```
|
|
Dialplan:
|
|
```ini
|
|
[sp-queue-{id}]
|
|
exten => s,1,Set(__SP_TARGET=550)
|
|
same => n,Answer()
|
|
same => n,Set(CALLERID(name)={prefix}${CALLERID(name)}) ; only if prefix
|
|
same => n,Gosub(sp-sub-record,s,1) ; only if record
|
|
same => n,Queue(550,t,,,{max_wait_seconds})
|
|
same => n,GotoIf($["${QUEUESTATUS}" = "TIMEOUT" | "${QUEUESTATUS}" = "FULL" | "${QUEUESTATUS}" = "JOINEMPTY" | "${QUEUESTATUS}" = "LEAVEEMPTY" | "${QUEUESTATUS}" = "JOINUNAVAIL" | "${QUEUESTATUS}" = "LEAVEUNAVAIL"]?fail)
|
|
same => n,Hangup()
|
|
same => n(fail),UserEvent(SpQueueExit,Queue: 550,Status: ${QUEUESTATUS})
|
|
same => n,{destApp(timeout_destination)}
|
|
```
|
|
The api handles agent pause/unpause with AMI `QueuePause`, using `Interface: Local/<ext>@sp-queue-member/n`.
|
|
`SpQueueExit` is for the stats (04 §7.3): Asterisk 24 raises `QueueCallerAbandon` for timeouts too, and
|
|
callers turned away (`FULL`, `JOINEMPTY`) never join. Member lines are sorted by extension; a member
|
|
name that sanitizes to nothing is replaced by the extension number.
|
|
As built (M8-T1): golden case `queue`; `pnpm test:asterisk` also checks `queue show <n>` lists each
|
|
queue's members; `scripts/e2e/m8-t1.mjs` runs 3 callers, 2 agents, `*45550`, a UI pause and a timeout
|
|
with real phones.
|
|
|
|
### 6.9 IVR
|
|
|
|
```ini
|
|
[sp-ivr-{id}]
|
|
include => sp-ivr-direct ; only if allow_direct_dial
|
|
exten => s,1,Answer()
|
|
same => n,Set(SP_IVR_TRIES=0)
|
|
same => n(menu),Set(TIMEOUT(digit)=3)
|
|
same => n,Background({greeting.asterisk_name})
|
|
same => n,WaitExten({timeout_seconds})
|
|
exten => 1,1,{destApp(option 1)} ; one per ivr_options row
|
|
exten => i,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
|
|
same => n,GotoIf($[${SP_IVR_TRIES} >= {max_retries}]?giveup)
|
|
same => n,Playback({invalid sound or pbx-invalid})
|
|
same => n,Goto(s,menu)
|
|
same => n(giveup),{destApp(invalid_destination)}
|
|
exten => t,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
|
|
same => n,GotoIf($[${SP_IVR_TRIES} >= {max_retries}]?giveup)
|
|
same => n,Goto(s,menu)
|
|
same => n(giveup),{destApp(timeout_destination)}
|
|
```
|
|
Changed in M11-T8: direct dial used to include `sp-local-numbers`, which let an outside caller dial a
|
|
paging group (the office speakers, and with duplex, listen in) or a parking slot (someone else's
|
|
call). `sp-ivr-direct` (rendered when any IVR has direct dial) holds one
|
|
`exten => {n},1,Goto(sp-local-numbers,{n},1)` per user, ring group, queue, conference room and IVR;
|
|
`ivr-direct.test.ts`.
|
|
Known behavior to mention in the UI: with direct dial on, option `1` waits up to
|
|
3 s (digit timeout) when internal numbers also start with 1.
|
|
Options are sorted by key; `#` and `*` work as extension names (checked by the `ivr` golden case
|
|
loading into Asterisk 24). An option may point at its own IVR ("press 9 to hear this again"); the
|
|
api drops an IVR's own options before checking references on delete (M6-T3).
|
|
|
|
### 6.10 Time conditions (business hours)
|
|
|
|
```ini
|
|
[sp-tc-{id}]
|
|
exten => s,1,Set(OVR=${DB(SP/TCOVR/{id})})
|
|
same => n,GotoIf($["${OVR}" = "open"]?open)
|
|
same => n,GotoIf($["${OVR}" = "closed"]?closed)
|
|
; holidays (only if use_holidays): one line per DAY
|
|
same => n,GotoIfTime(*,*,25,dec,America/Chicago?holiday)
|
|
; open ranges: one line per range
|
|
same => n,GotoIfTime(08:00-17:00,mon&tue&wed&thu&fri,*,*,America/Chicago?open)
|
|
same => n,Goto(closed)
|
|
same => n(open),{destApp(open_destination)}
|
|
same => n(closed),{destApp(closed_destination)}
|
|
same => n(holiday),{destApp(holiday_destination ?? closed_destination)}
|
|
```
|
|
Holiday rules:
|
|
- Expand `start_date..end_date` to single days (max 31; validation rejects longer).
|
|
- `GotoIfTime` can't match a year. So a non-recurring holiday is rendered
|
|
only if its date is between `snap.today` and `snap.today + 364 days`.
|
|
Job `nightlyRegenerate` (04 §8) runs at 00:05 local time so past ones drop out.
|
|
- Months are written `jan feb mar apr may jun jul aug sep oct nov dec`. Days
|
|
are written `mon tue wed thu fri sat sun`, joined with `&`.
|
|
- Validation: `start < end` within a range (no ranges across midnight; the user adds two).
|
|
|
|
Override codes use `time_conditions.override_digit` (02). Feature codes `*31N` force open,
|
|
`*32N` force closed, `*33N` automatic (§6.13).
|
|
|
|
As built (M6-T4):
|
|
- A range's `end` is exclusive ("closed at 17:00") but `GotoIfTime`'s end minute is inclusive, so
|
|
ranges render one minute short: `08:00-16:59`; `24:00` (end of day) renders `23:59`.
|
|
- Holiday days are deduplicated and sorted by month/day; a range over New Year works.
|
|
- `[sp-tc-override]` is always rendered, ending with `exten => _*3[123]X` → `ss-noservice` for
|
|
digits no office hours use. `*33N` plays `de-activated`.
|
|
- The api's "open now" (`timeConditionStatus` in `packages/shared`) makes the same decision.
|
|
- Holidays roll over by re-rendering when the company date changes (a 10-minute check in
|
|
`index.ts`) instead of a croner job at 00:05; M11-T4 can move it into the jobs.
|
|
- Golden cases `holidays` and `ivr-business-hours`; `scripts/e2e/m6-t4.mjs` checks force
|
|
open/closed routing, `*321`/`*331` from an admin phone, and refusal for a non-admin.
|
|
|
|
### 6.11 Announcements, conferences, paging
|
|
|
|
```ini
|
|
[sp-ann-{id}]
|
|
exten => s,1,Answer()
|
|
same => n,Playback({sound})
|
|
same => n,{destApp(destination)}
|
|
|
|
; conference without PINs
|
|
[sp-conf-{id}]
|
|
exten => s,1,Set(__SP_TARGET={number})
|
|
same => n,Answer()
|
|
same => n,Gosub(sp-sub-record,s,1) ; only if record
|
|
same => n,ConfBridge({number},sp_room_{number},default_user,default_menu)
|
|
same => n,Hangup()
|
|
|
|
; conference with PIN (and optional admin PIN)
|
|
[sp-conf-{id}]
|
|
exten => s,1,Set(__SP_TARGET={number})
|
|
same => n,Answer()
|
|
same => n,Set(TRIES=0)
|
|
same => n(ask),Read(PIN,conf-getpin,10,,1,8)
|
|
same => n,GotoIf($["${PIN}" = "{admin_pin}"]?admin) ; only if admin_pin
|
|
same => n,GotoIf($["${PIN}" = "{pin}"]?user)
|
|
same => n,Set(TRIES=$[${TRIES} + 1])
|
|
same => n,GotoIf($[${TRIES} < 3]?bad)
|
|
same => n,Hangup()
|
|
same => n(bad),Playback(conf-invalidpin)
|
|
same => n,Goto(ask)
|
|
same => n(admin),ConfBridge({number},sp_room_{number},sp_admin_user,default_menu) ; (admin) on the Gosub when recorded
|
|
same => n,Hangup()
|
|
same => n(user),ConfBridge({number},sp_room_{number},default_user,default_menu)
|
|
same => n,Hangup()
|
|
|
|
[sp-page-{id}]
|
|
exten => s,1,Page(PJSIP/101&PJSIP/102,{d if duplex}iqsb(sp-sub-autoanswer^s^1),30)
|
|
same => n,Hangup()
|
|
```
|
|
Paging only rings desk endpoints (`PJSIP/<ext>`), not `-web`. As built (M8-T4): `s` added, so phones
|
|
already on a call aren't paged; that includes the pager when they're a member themselves. A group
|
|
with no members is `exten => s,1,Hangup()`. Paging groups aren't call destinations. Golden case
|
|
`paging`; `scripts/e2e/m8-t4.mjs` pages with real calls (baresip phones that honor the auto-answer
|
|
headers, like Yealink/Grandstream do; a real desk phone is still to be checked).
|
|
|
|
`confbridge_selfphoneng.conf` always contains the admin profile, plus one bridge profile per room:
|
|
```ini
|
|
[sp_admin_user]
|
|
type = user
|
|
admin = yes
|
|
marked = yes
|
|
music_on_hold_when_empty = yes
|
|
|
|
[sp_room_800](default_bridge) ; room 800
|
|
max_members = 20
|
|
```
|
|
As built (M8-T3), changed from the first draft: `Set(CONFBRIDGE(bridge,max_members)=…)` before
|
|
`ConfBridge(…,default_bridge,…)` is ignored (a 4th caller got into a room of 3 on Asterisk 24), so
|
|
each room has its own bridge profile. A recorded room runs `sp-sub-record` on each caller's leg,
|
|
just before `ConfBridge()`, instead of `record_conference`: the files are ordinary call recordings
|
|
(call history, Recordings page, retention). That is one file per participant, and a caller turned
|
|
away from a full room leaves a few seconds of recording too. Golden case `conferences`;
|
|
`scripts/e2e/m8-t3.mjs` checks PIN, admin PIN, the limit and recordings with real calls.
|
|
|
|
### 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 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${FILTER(0-9*#+,${EXTEN:0})},1) ; strip=0 prepend=1
|
|
exten => _1NXXNXXXXXX,1,Goto(sp-route-{id},${FILTER(0-9*#+,${EXTEN:0})},1)
|
|
|
|
[sp-or-{id2}]
|
|
exten => _011.,1,Goto(sp-route-{id2},${FILTER(0-9*#+,${EXTEN:0})},1)
|
|
; FILTER (M11-T8): SIP allows "," and "&" in a dialed number. "011…,4" made this
|
|
; Goto(sp-route-{id2},011…,4,1), which starts past the class check (trunk-test.ts proves it).
|
|
|
|
[sp-route-{id}]
|
|
exten => _X.,1,Set(__SP_DIRECTION=outbound)
|
|
same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < {required_class_number}]?denied)
|
|
; outside calls at once (calls.maxPerExtension, calls.maxTotal; a line pair is left out when its
|
|
; setting is 0, and with no total the per-extension skip goes to n(counted),NoOp()). A forward
|
|
; counts for the extension whose forward it is (SP_LIMITEXT from sp-sub-forward); a call with no
|
|
; extension (sp-ext-forward) only in the total. call-limits.test.ts, trunk-test.ts.
|
|
same => n,Set(SP_LIMITEXT=${IF($["${SP_LIMITEXT}" = ""]?${SP_EXT}:${SP_LIMITEXT})})
|
|
same => n,GotoIf($["${SP_LIMITEXT}" = ""]?total)
|
|
same => n,Set(GROUP(spext)=${SP_LIMITEXT})
|
|
same => n,GotoIf($[${GROUP_COUNT(${SP_LIMITEXT}@spext)} > {calls.maxPerExtension}]?busy)
|
|
same => n(total),Set(GROUP(spout)=all)
|
|
same => n,GotoIf($[${GROUP_COUNT(all@spout)} > {calls.maxTotal}]?busy)
|
|
same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))
|
|
; caller ID priority: route override > user's SP_OUTCID > inbound DID (forwards) > default
|
|
same => n,Set(CID={route override number, or empty})
|
|
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}))
|
|
; trunk 1 (with optional channel limit)
|
|
same => n,Set(GROUP(sptrunk)={trunk1id})
|
|
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(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 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 |
|
|
|---|---|---|
|
|
| Toll-free | `1800NXXXXXX`, `1888NXXXXXX`, `1877NXXXXXX`, `1866NXXXXXX`, `1855NXXXXXX`, `1844NXXXXXX`, `1833NXXXXXX` | local |
|
|
| Local/National (NANP) | `NXXNXXXXXX` (prepend `1`), `1NXXNXXXXXX` | national |
|
|
| International | `011.` | international — **created disabled** |
|
|
|
|
|
|
### 6.13 Feature codes `sp-featurecodes`
|
|
|
|
```ini
|
|
[sp-featurecodes]
|
|
exten => *97,1,Answer() ; my voicemail
|
|
same => n,Gosub(sp-sub-vmlimit,s,1) ; 1 at a time, 10/hour per phone (08 §6)
|
|
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
|
same => n,VoiceMailMain(${SP_EXT}@default)
|
|
same => n,Hangup()
|
|
same => n(full),Playback(an-error-has-occurred)
|
|
same => n,Hangup()
|
|
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
|
same => n,Gosub(sp-sub-vmlimit,s,1)
|
|
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
|
same => n,VoiceMailMain(@default)
|
|
same => n,Hangup()
|
|
same => n(full),Playback(an-error-has-occurred)
|
|
same => n,Hangup()
|
|
exten => *78,1,Answer() ; DND on
|
|
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
|
same => n,Playback(do-not-disturb&activated)
|
|
same => n,Hangup()
|
|
exten => *79,1,Answer() ; DND off
|
|
same => n,Set(X=${DB_DELETE(SP/DND/${SP_EXT})})
|
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
|
same => n,Playback(do-not-disturb&de-activated)
|
|
same => n,Hangup()
|
|
exten => _*72X.,1,Answer() ; *72<number> forward always
|
|
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
|
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
|
same => n,Playback(call-fwd-unconditional&activated)
|
|
same => n,Hangup()
|
|
exten => *73,1,Answer() ; cancel forward always
|
|
same => n,Set(X=${DB_DELETE(SP/CFU/${SP_EXT})})
|
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
|
same => n,Playback(call-fwd-unconditional&de-activated)
|
|
same => n,Hangup()
|
|
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
|
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
|
same => n,Hangup()
|
|
; *8 group pickup is handled by features.conf pickupexten + named pickup groups
|
|
exten => *43,1,Answer() ; echo test
|
|
same => n,Playback(demo-echotest)
|
|
same => n,Echo()
|
|
same => n,Playback(demo-echodone)
|
|
same => n,Hangup()
|
|
exten => *65,1,Answer() ; say my extension
|
|
same => n,SayDigits(${SP_EXT})
|
|
same => n,Hangup()
|
|
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
|
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
|
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
|
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
|
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
|
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
|
|
same => n,Set(DB(${K})=1)
|
|
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 1)
|
|
same => n,Playback(agent-loggedoff)
|
|
same => n,Hangup()
|
|
same => n(login),Set(X=${DB_DELETE(${K})})
|
|
same => n,UnpauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
|
same => n,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
|
|
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
|
|
same => n,Playback(agent-loginok)
|
|
same => n,Hangup()
|
|
same => n(deny),Playback(ss-noservice)
|
|
same => n,Hangup()
|
|
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
|
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
|
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
|
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
|
same => n,Hangup()
|
|
same => n(deny),Answer()
|
|
same => n,Playback(ss-noservice)
|
|
same => n,Hangup()
|
|
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
|
|
exten => _*3[123]X,1,Answer()
|
|
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
|
|
same => n,Goto(sp-tc-override,${EXTEN},1)
|
|
same => n(deny),Playback(ss-noservice)
|
|
same => n,Hangup()
|
|
```
|
|
The generator also writes `[sp-tc-override]`, with three lines per time condition that has an `override_digit`:
|
|
```ini
|
|
[sp-tc-override]
|
|
exten => *311,1,Set(DB(SP/TCOVR/{id})=open)
|
|
same => n,UserEvent(SpToggle,Key: TCOVR,Id: {id},Value: open)
|
|
same => n,Playback(activated)
|
|
same => n,Hangup()
|
|
exten => *321,1,Set(DB(SP/TCOVR/{id})=closed) ... (same shape, Value: closed)
|
|
exten => *331,1,Set(X=${DB_DELETE(SP/TCOVR/{id})}) ... (Value: auto)
|
|
```
|
|
**Forward validation:** when the api receives `SpToggle Key: CFU` it checks the
|
|
target against the user's outbound class (04 §5.3). If it isn't allowed, the api
|
|
deletes the AstDB key, stores nothing, and logs an audit entry. Dialplan also
|
|
limits forwards via `SP_CLASS_<ext>` in `sp-sub-forward`.
|
|
|
|
As built (M6-T7): everything above except `*45` (queues) and `*80` (intercom), which come with M8.
|
|
`*80` (M8-T4) only pages a desk phone: digits only (no `&` sneaking in another channel), and the
|
|
endpoint must exist with context `sp-from-ext`; anything else plays `ss-noservice`.
|
|
`*45` (M8-T1) checks `PQMSTATUS`/`UPQMSTATUS`, so a code for a queue the caller isn't an agent of plays
|
|
`ss-noservice` and stores nothing (the original snippet stored the key and said "logged off" anyway).
|
|
`*8` also has a dialplan line (`Pickup()`): `pickupexten` only catches it straight from a phone's
|
|
INVITE. All the sounds used exist in the image (checked).
|
|
|
|
Sound names like `do-not-disturb`, `call-fwd-unconditional`, `agent-loginok`
|
|
come from the Asterisk core/extra sound packs. If one is missing, swap in
|
|
`beep` and note it in the spec.
|
|
|
|
### 6.14 Emergency calls
|
|
|
|
**Never block a 911 call for any reason.** Not class, not missing caller ID,
|
|
not trunk limits, not DND.
|
|
|
|
```ini
|
|
[sp-emergency-dial]
|
|
exten => 911,1,Goto(sp-emergency,s,1)
|
|
exten => 9911,1,Goto(sp-emergency,s,1) ; people who dial 9 first out of habit
|
|
|
|
[sp-emergency]
|
|
exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E911_CID})})
|
|
same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
|
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
|
same => n,Set(__SP_DIRECTION=outbound)
|
|
same => n,Dial(PJSIP/911@trunk-{trunk1id}) ; every ENABLED trunk, in name order, no limits
|
|
same => n,Dial(PJSIP/911@trunk-{trunk2id})
|
|
same => n,Hangup()
|
|
```
|
|
The `SpEmergency` event triggers notifications (04 §5.3, 06 §6). For the
|
|
"ring a notification phone" part, the api originates a call into this static context:
|
|
|
|
```ini
|
|
[sp-emergency-alert]
|
|
; Originated by the api: Channel PJSIP/<notify ext>, Context sp-emergency-alert, Exten <caller ext>
|
|
exten => _X!,1,Answer() ; X! also matches a one-digit "0" (caller unknown, or a test)
|
|
same => n,Wait(1)
|
|
same => n,Playback(beep&beep&beep)
|
|
same => n,Playback(extension)
|
|
same => n,SayDigits(${EXTEN})
|
|
same => n,Playback(beep&beep&beep)
|
|
same => n,Playback(extension)
|
|
same => n,SayDigits(${EXTEN})
|
|
same => n,Hangup()
|
|
```
|
|
(If an admin uploads a sound named `emergency-alert`, play `custom/emergency-alert` first.)
|
|
|
|
## 7. `voicemail_selfphoneng.conf`
|
|
|
|
```ini
|
|
[general]
|
|
format = wav
|
|
attach = no ; the api sends emails itself
|
|
maxmsg = {voicemail.maxMessages}
|
|
maxsecs = {voicemail.maxMessageSeconds}
|
|
minsecs = 1
|
|
maxsilence = 10
|
|
silencethreshold = 128
|
|
maxlogins = 3
|
|
envelope = yes
|
|
saycid = yes
|
|
review = no
|
|
nextaftercmd = yes
|
|
sendvoicemail = no
|
|
pollmailboxes = yes
|
|
pollfreq = 30
|
|
|
|
[zonemessages]
|
|
local = {general.timezone}|'vm-received' Q 'digits/at' IMp
|
|
|
|
[default]
|
|
101 => {pin},{name},,,tz=local|attach=no
|
|
```
|
|
Only extensions with `voicemail_enabled` get a line. Names go through `name()` (no commas or `|`),
|
|
the time zone through `timezone()`. Until M6-T7, `sp-featurecodes` holds only `*97`/`*98`.
|
|
Checked live by `apps/api/test/asterisk/voicemail-test.ts` (M4-T1): leave a message, MWI NOTIFY
|
|
to a subscribed phone, `*97` login and playback, MWI off.
|
|
|
|
## 8. `musiconhold_selfphoneng.conf`
|
|
|
|
```ini
|
|
[{class.name}]
|
|
mode = files
|
|
directory = /var/lib/asterisk/moh/{class.name}
|
|
sort = random
|
|
```
|
|
One per `moh_classes` row. The api keeps the folder contents in sync with `sound_files` (04 §6).
|
|
|
|
As built (M8-T5): one per `moh_classes` row **that has at least one file**. An empty class plays
|
|
silence in Asterisk 24; left out, a channel asking for it gets `default` instead (checked with a
|
|
real queue caller). Because only the first and last file change this file, the api also sends
|
|
`moh reload` after every file it adds or removes. Golden case `queue`; `scripts/e2e/m8-t5.mjs`.
|
|
|
|
Since 2026-10-21 (owner: "whichever was uploaded first is always queued first"), a class is a
|
|
playlist in the order set on Sounds & music, with an optional shuffle:
|
|
|
|
```ini
|
|
[{class.name}]
|
|
mode = playlist
|
|
entry = /var/lib/asterisk/moh/{class.name}/{sound_files.id} ; by sound_files.position, no extension
|
|
sort = random ; only with moh_classes.shuffle
|
|
```
|
|
|
|
New uploads go last; `PATCH /api/moh-classes/:id {shuffle, order}` (order: every file id once).
|
|
Without shuffle each caller on hold starts at the first entry. `sort = random` picks each next
|
|
file at random in playlist mode too (res_musiconhold's randomize flag). `moh-test.ts` plays both
|
|
kinds in a real Asterisk and reads the order of the files it opens from its debug log.
|
|
|
|
## 9. Snapshot → endpoint variables summary
|
|
|
|
| Variable | Where set | Read by |
|
|
|---|---|---|
|
|
| `SP_EXT` | endpoint `set_var` | feature codes, emergency |
|
|
| `SP_CLASS` | endpoint `set_var`; `sp-sub-forward`; `sp-ext-forward` | `sp-route-*` |
|
|
| `SP_OUTCID` | endpoint `set_var` | `sp-route-*` |
|
|
| `SP_E911CID` | endpoint `set_var` | `sp-emergency` |
|
|
| `SP_RECMODE` | endpoint `set_var` | `sp-route-*` |
|
|
| `SP_DIRECTION` | `sp-from-trunk-*`, `sp-route-*`, endpoint | `sp-sub-user`, CDR |
|
|
| `SP_DID` | `sp-inbound` | `sp-route-*` (forwards), CDR |
|
|
| `SP_TARGET` | object contexts | live state |
|
|
| `SP_REC` | `sp-sub-record` | CDR → recordings |
|
|
| `SP_FWDHOPS`, `SP_FORWARDED` | forwards | loop guard |
|
|
| `SP_LIMITEXT` | `sp-sub-forward`; else `SP_EXT` in `sp-route-*` | call limits (group `spext`) |
|
|
|
|
## 10. Example: life of an inbound call
|
|
|
|
voip.ms sends `INVITE sip:3125551234@us` → PJSIP matches identify →
|
|
endpoint `trunk-X` → context `sp-from-trunk-X`, exten `3125551234` →
|
|
normalize to `13125551234` → it's in `sp-did-exists` → caller number
|
|
normalized, not blocked → `Goto(sp-inbound,13125551234,1)` → sets `SP_DID`
|
|
→ `Goto(sp-tc-T,s,1)` → it's Tuesday 10:00, open → `Goto(sp-ivr-I,s,1)` →
|
|
caller presses 2 → `Goto(sp-local-numbers,101,1)` → `sp-sub-user(101,25,1,never)`
|
|
→ not DND, no forward → rings `PJSIP/101` desk phone + `PJSIP/101-web` browser
|
|
→ no answer in 25 s → `VoiceMail(101@default,u)` → the api sees a new
|
|
`msg0000.txt` → emails the user.
|
|
|
|
## 11. Things the generator must NOT do
|
|
|
|
- Hard-code any number, DID, IP or password.
|
|
- Emit `chan_sip` config.
|
|
- Use `System()`, `Shell()`, `AGI()` or `TrySystem()`. Talk to the api with `UserEvent` only.
|
|
- Write anything outside `/etc/asterisk/selfphoneng/`.
|
|
|
|
## 12. Tests
|
|
|
|
1. **Golden tests** (`apps/api/src/generator/__golden__/<case>/`):
|
|
`snapshot.json` + `expected/<file>.conf`. Test: `render(snapshot)` equals
|
|
expected byte for byte. `UPDATE_GOLDEN=1 pnpm test` rewrites the expected
|
|
files. **A human (or reviewer agent) must read the diff** before committing.
|
|
Required cases: `empty`, `one-user`, `voipms-basic` (2 users, 1 trunk, 1 DID → user),
|
|
`ivr-business-hours`, `ringgroups`, `queue`, `outbound-routes`, `holidays`,
|
|
`hostile-names` (sanitizer coverage), `webrtc-disabled`.
|
|
2. **Determinism test:** render the same snapshot twice with shuffled array order → identical output.
|
|
3. **Asterisk load test** (integration, `pnpm test:asterisk`): start the asterisk
|
|
container with each golden case's files, then run over AMI
|
|
`dialplan show sp-local-numbers`, `pjsip show endpoints`, `queue show`.
|
|
Fail if Asterisk logs `ERROR` while loading.
|
|
4. **Call tests** (M3+, SIPp scenarios in `apps/api/test/sipp/`): register 101,
|
|
call 102, answer, BYE. Inbound from a fake trunk → IVR → digit → user.
|