Outbound routes CRUD, reorder and POST /test (mirrors the dialplan:
911 and internal numbers first, then the first matching enabled route,
class check, usable trunks, caller ID priority route > user > default).
The first trunk creates the default rules: toll-free first (because
1NXXNXXXXXX also matches 1800...), US/Canada, international disabled.
Generator: Asterisk orders patterns in one context by specificity, not
by file order, so each route gets its own sp-or-{id} context and
sp-outbound-routes includes them in position order (spec 03 §6.12
updated). sp-route-{id}: class check -> ss-noservice, caller ID
priority, per-trunk GROUP channel limit, failover on CHANUNAVAIL or
CONGESTION, all-circuits-busy-now when every trunk is full or off.
UI: Outbound rules page (↑/↓ reorder, pattern rows with help, ordered
trunk list, caller ID override, test box) and a Calling tab on users
(calling permission, outbound caller ID).
trunk-test.ts: 101 dials 3125550000 -> the fake provider receives
13125550000 with the DID in Remote-Party-ID (From user is the
sub-account), two-way audio; a caller without a class hears
ss-noservice. scripts/e2e/m3-t3.mjs covers the UI. With real voip.ms
still to do: whether per-call caller ID is honored (06 §4).
Task: M3-T3
37 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", soloadSnapshotputssnap.today(aYYYY-MM-DDstring ingeneral.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:
- Take a process-wide mutex. Only one apply runs at a time.
loadSnapshot()→render().- For each file, compare with what is on disk. Collect
changed. - If nothing changed and not
force: record agenerator_runsrow and return. - Copy the current files to
/etc/asterisk/selfphoneng/.prev/(keep one previous version). - For each changed file, write
<name>.tmpthenrename()it over the old one (atomic). - 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. Setsystem.restartRequired = trueinstead (see01-infrastructure.md§4.3). Exception: the very first apply (no storedsystem.transportHash) reloads PJSIP normally, since it only adds transports to an Asterisk that booted with none. - Send each command as AMI
Action: Command. If AMI is down, mark the runok=false, error='ami-disconnected'. The AMI reconnect handler callsapply({force:true})later. - After
queue reload: re-send every paused queue member (QueuePause), because a reload resets pause state. - Insert a
generator_runsrow. Broadcastsystem.configAppliedon 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}
{/if}
(network.lanIp is the server's own LAN address, see 02-data-model.md §3.)
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
{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, changeprotocol = wstoprotocol = wssand fix the spec. - The asterisk container needs
./data/tls:/var/lib/selfphoneng/tls:ro. Add it to the compose file when TLS is first used (M11). - No
anonymousendpoint 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
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
callerid = "Dan Doe" <101>
mailboxes = 101@default
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}
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.
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=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,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_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.
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),{destApp(timeout_destination)}
The api handles agent pause/unpause with AMI QueuePause, using Interface: Local/<ext>@sp-queue-member/n.
6.9 IVR
[sp-ivr-{id}]
include => sp-local-numbers ; 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)}
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.
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_dateto single days (max 31; validation rejects longer). GotoIfTimecan't match a year. So a non-recurring holiday is rendered only if its date is betweensnap.todayandsnap.today + 364 days. JobnightlyRegenerate(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 writtenmon tue wed thu fri sat sun, joined with&. - Validation:
start < endwithin 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).
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,Answer()
same => n,Set(CONFBRIDGE(bridge,max_members)={max_members})
same => n,Set(CONFBRIDGE(bridge,record_conference)=yes) ; only if record
same => n,ConfBridge({number},default_bridge,default_user,default_menu)
same => n,Hangup()
; conference with PIN (and optional admin PIN)
[sp-conf-{id}]
exten => s,1,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},default_bridge,sp_admin_user,default_menu)
same => n,Hangup()
same => n(user),ConfBridge({number},default_bridge,default_user,default_menu)
same => n,Hangup()
[sp-page-{id}]
exten => s,1,Page(PJSIP/101&PJSIP/102,{d if duplex}iqb(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
Paging only rings desk endpoints (PJSIP/<ext>), not -web.
confbridge_selfphoneng.conf always contains:
[sp_admin_user]
type = user
admin = yes
marked = yes
music_on_hold_when_empty = yes
6.12 Outbound routes
Asterisk sorts the patterns inside one context by how specific they are, not by file order, so
"routes in position order" needs one context per route, included in position order (an include
is searched only after the ones before it; verified by the load test and outbound.test.ts).
Duplicate patterns inside a route are dropped (a duplicate extension is a load error). When
there are no enabled routes, sp-outbound-routes holds exten => pending,1,Hangup().
[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${EXTEN:0},1) ; strip=0 prepend=1
exten => _1NXXNXXXXXX,1,Goto(sp-route-{id},${EXTEN:0},1)
[sp-or-{id2}]
exten => _011.,1,Goto(sp-route-{id2},${EXTEN},1)
[sp-route-{id}]
exten => _X.,1,Set(__SP_DIRECTION=outbound)
same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < {required_class_number}]?denied)
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,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default)
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,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-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=${EXTEN:3})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
same => n,Set(DB(${K})=1)
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
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,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
same => n,Playback(agent-loginok)
same => n,Hangup()
exten => _*80X.,1,Page(PJSIP/${EXTEN:3},db(sp-sub-autoanswer^s^1),30) ; intercom
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.
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()
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.
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).
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 |
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_sipconfig. - Use
System(),Shell(),AGI()orTrySystem(). Talk to the api withUserEventonly. - Write anything outside
/etc/asterisk/selfphoneng/.
12. Tests
- 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 testrewrites 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. - Determinism test: render the same snapshot twice with shuffled array order → identical output.
- Asterisk load test (integration,
pnpm test:asterisk): start the asterisk container with each golden case's files, then run over AMIdialplan show sp-local-numbers,pjsip show endpoints,queue show. Fail if Asterisk logsERRORwhile loading. - Call tests (M3+, SIPp scenarios in
apps/api/test/sipp/): register 101, call 102, answer, BYE. Inbound from a fake trunk → IVR → digit → user.