# 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; // index.ts export type RenderedFiles = Record; 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; 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 `.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//. 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/.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)=\;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/@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 ` 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/`), 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 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 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 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_` 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/, Context sp-emergency-alert, Exten 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__//`): `snapshot.json` + `expected/.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.