Files
selfphoneng/docs/spec/03-config-generator.md
T
iamdoubz 175d5a2c73 music on hold: play order set in the UI, and shuffle
Owner: the first upload always played first. A class is now an Asterisk playlist in the order
set on Sounds & music (move up/down), new uploads last, with a Shuffle option (sort = random).
moh-test.ts plays both in a real Asterisk and reads the file order from its debug log.
2026-10-21 13:27:02 -05:00

47 KiB

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

// 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

; 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

[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:

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)

; ── 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.

; ── 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

; ─── 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).
[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,Dial(${DS},${ARG2},tx)
 same => n,Return()
 same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
 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

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}

[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

[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

[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:

[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):

[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):

[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:

[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

[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)

[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

[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:

[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().

[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

[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:

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

[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:

[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

[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

[{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:

[{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.