Dialplan: sp-emergency-dial (911, 9911) first in sp-from-ext; sp-emergency has no class, channel-limit or DND check, dials every enabled trunk in name order with the E911 caller ID, and raises UserEvent SpEmergency. Static sp-emergency-alert (_X! so "0" matches) for the ring-out. api: SpEmergency -> emergency_events row, live emergency.call (admins and notify users only), email via nodemailer (SMTP from settings) and an Originate to every notify extension, each outcome recorded. Test button runs the same with TEST in front, records and dials nothing. GET/PATCH /api/settings (smtp password write-only, references checked, read-only system keys), SMTP test, /api/system/warnings. Fix: clearing a nullable setting stored SQL NULL (now JSON null). UI: Settings page (Email, Emergency with the 06 §6 notice), user Emergency tab, dashboard warnings banner, red 911 banner. Verified: generator/emergency.test.ts; trunk-test.ts dials 911 from a caller with no class to the fake provider (E911 caller ID in RPID, SpEmergency on AMI, alert plays on baresip 101); scripts/e2e/m3-t4.mjs (SMTP sink receives the test email, 101 rings, banner shows). nodemailer pinned to 10.0.11 (newest release over two weeks old). Task: M3-T4
963 lines
37 KiB
Markdown
963 lines
37 KiB
Markdown
# 03 — Config generator (DB → Asterisk)
|
|
|
|
This is the heart of the product. Read all of it before changing any call-routing code.
|
|
|
|
Code lives in `apps/api/src/generator/`.
|
|
|
|
## 1. Interface
|
|
|
|
```ts
|
|
// load.ts
|
|
export type Snapshot = { /* every table from 02-data-model.md that the generator needs, fully loaded,
|
|
plus resolved settings and decrypted secrets */ };
|
|
export async function loadSnapshot(db: Db): Promise<Snapshot>;
|
|
|
|
// index.ts
|
|
export type RenderedFiles = Record<GeneratedFileName, string>;
|
|
export function render(snap: Snapshot): RenderedFiles; // PURE: no I/O, no Date.now(), no randomness
|
|
export async function apply(opts: { trigger: string; force?: boolean }): Promise<ApplyResult>;
|
|
export function requestApply(trigger: string): void; // debounced 1000 ms, calls apply()
|
|
```
|
|
|
|
Rules for `render()`:
|
|
- **Pure and deterministic.** Same snapshot → byte-identical output. Sort
|
|
everything: users by number, routes by position, members by position, and
|
|
objects without a natural order by `id`.
|
|
- The current date is not read inside `render()`. Holiday filtering needs
|
|
"today", so `loadSnapshot` puts `snap.today` (a `YYYY-MM-DD` string in `general.timezone`) into the snapshot.
|
|
- Every file starts with the header
|
|
`; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.`
|
|
- Never put a value into output without passing it through a sanitizer (§2).
|
|
|
|
`apply()` steps:
|
|
1. Take a process-wide mutex. Only one apply runs at a time.
|
|
2. `loadSnapshot()` → `render()`.
|
|
3. For each file, compare with what is on disk. Collect `changed`.
|
|
4. If nothing changed and not `force`: record a `generator_runs` row and return.
|
|
5. Copy the current files to `/etc/asterisk/selfphoneng/.prev/` (keep one previous version).
|
|
6. For each changed file, write `<name>.tmp` then `rename()` it over the old one (atomic).
|
|
7. Work out reload commands from the changed files (§3), or from every file when `force`. If the PJSIP
|
|
transport sections changed, don't reload PJSIP. Set
|
|
`system.restartRequired = true` instead (see `01-infrastructure.md` §4.3). Exception: the very first
|
|
apply (no stored `system.transportHash`) reloads PJSIP normally, since it only adds transports to an
|
|
Asterisk that booted with none.
|
|
8. Send each command as AMI `Action: Command`. If AMI is down, mark the run
|
|
`ok=false, error='ami-disconnected'`. The AMI reconnect handler calls `apply({force:true})` later.
|
|
9. After `queue reload`: re-send every paused queue member (`QueuePause`), because a reload resets pause state.
|
|
10. Insert a `generator_runs` row. Broadcast `system.configApplied` on the live WebSocket.
|
|
|
|
`POST /api/system/config/rollback` copies `.prev/*` back and reloads everything.
|
|
|
|
## 2. Sanitizers (`sanitize.ts`)
|
|
|
|
Dialplan and config files are code. A display name containing `)` or `;` or
|
|
a newline could break or inject dialplan. Every value goes through one of these:
|
|
|
|
| Function | Allowed | Used for | On bad input |
|
|
|---|---|---|---|
|
|
| `digits(v)` | `^[0-9]+$` | internal numbers, DIDs | throw |
|
|
| `dialable(v)` | `^[0-9*#+]+$` | forward targets, external numbers | throw |
|
|
| `pattern(v)` | `^[0-9XZN.!\[\]\-]+$`, max 32 | outbound route patterns | throw |
|
|
| `name(v)` | removes `" ' \ ; , ( ) [ ] { } $ < > \|` and control chars, trims, max 40 chars | caller ID names, labels | clean silently |
|
|
| `sound(v)` | `^[a-z0-9/_-]+$` | sound file names for Playback | throw |
|
|
| `ident(v)` | `^[a-z0-9-]+$` | context-name parts (uuids), MOH class names | throw |
|
|
| `secret(v)` | `^[A-Za-z0-9]+$` | generated SIP passwords | throw |
|
|
| `pin(v)` | `^[0-9]{4,10}$` | voicemail / conference PINs | throw |
|
|
| `host(v)` | hostname or IPv4 regex | trunk server host | throw |
|
|
| `cidr(v)` | IPv4 CIDR regex | local nets | throw |
|
|
|
|
The API validates with the same rules (shared zod schemas in `packages/shared`),
|
|
so a throw here means a bug, and the apply fails loudly. Also write a
|
|
unit test for each sanitizer with hostile input (`101); System(rm -rf /`, newlines, unicode).
|
|
|
|
## 3. Generated files and reload commands
|
|
|
|
All files go to `/etc/asterisk/selfphoneng/`.
|
|
|
|
| File | Module | AMI `Command:` after change |
|
|
|---|---|---|
|
|
| `pjsip_selfphoneng.conf` | res_pjsip | `module reload res_pjsip.so` (or restart-required if transports changed). Asterisk 24 has no `pjsip reload` CLI command. |
|
|
| `extensions_selfphoneng.conf` | pbx_config | `dialplan reload` |
|
|
| `extensions_globals_selfphoneng.conf` | pbx_config | `dialplan reload` |
|
|
| `voicemail_selfphoneng.conf` | app_voicemail | `voicemail reload` |
|
|
| `queues_selfphoneng.conf` | app_queue | `queue reload all` |
|
|
| `musiconhold_selfphoneng.conf` | res_musiconhold | `moh reload` |
|
|
| `rtp_selfphoneng.conf` | res_rtp_asterisk | `module reload res_rtp_asterisk.so` |
|
|
| `confbridge_selfphoneng.conf` | app_confbridge | `module reload app_confbridge.so` |
|
|
|
|
Each command is sent once per apply, even if several files map to it.
|
|
|
|
**Transport-change detection:** the "transport block" is every section with
|
|
`type=transport` plus `[global]` and `[system]`. Render it as its own string
|
|
(`renderTransports(snap)`) and compare its sha256 with the one stored in
|
|
`settings['system.transportHash']`.
|
|
|
|
## 4. `rtp_selfphoneng.conf`
|
|
|
|
```ini
|
|
; GENERATED ...
|
|
icesupport = yes
|
|
stunaddr = {network.stunServer} ; omit line when null
|
|
{if network.lanIp and network.publicIp}
|
|
[ice_host_candidates]
|
|
{network.lanIp} => {network.publicIp}
|
|
{/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
|
|
|
|
```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
|
|
{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`. Add it to the compose file when TLS is first used (M11).
|
|
- 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
|
|
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`.
|
|
|
|
```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}
|
|
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
|
|
|
|
```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).
|
|
[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`
|
|
|
|
```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.
|
|
|
|
### 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),{destApp(timeout_destination)}
|
|
```
|
|
The api handles agent pause/unpause with AMI `QueuePause`, using `Interface: Local/<ext>@sp-queue-member/n`.
|
|
|
|
### 6.9 IVR
|
|
|
|
```ini
|
|
[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)
|
|
|
|
```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).
|
|
|
|
### 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,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:
|
|
```ini
|
|
[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()`.
|
|
|
|
```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${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`
|
|
|
|
```ini
|
|
[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`:
|
|
```ini
|
|
[sp-tc-override]
|
|
exten => *311,1,Set(DB(SP/TCOVR/{id})=open)
|
|
same => n,UserEvent(SpToggle,Key: TCOVR,Id: {id},Value: open)
|
|
same => n,Playback(activated)
|
|
same => n,Hangup()
|
|
exten => *321,1,Set(DB(SP/TCOVR/{id})=closed) ... (same shape, Value: closed)
|
|
exten => *331,1,Set(X=${DB_DELETE(SP/TCOVR/{id})}) ... (Value: auto)
|
|
```
|
|
**Forward validation:** when the api receives `SpToggle Key: CFU` it checks the
|
|
target against the user's outbound class (04 §5.3). If it isn't allowed, the api
|
|
deletes the AstDB key, stores nothing, and logs an audit entry. Dialplan also
|
|
limits forwards via `SP_CLASS_<ext>` in `sp-sub-forward`.
|
|
|
|
Sound names like `do-not-disturb`, `call-fwd-unconditional`, `agent-loginok`
|
|
come from the Asterisk core/extra sound packs. If one is missing, swap in
|
|
`beep` and note it in the spec.
|
|
|
|
### 6.14 Emergency calls
|
|
|
|
**Never block a 911 call for any reason.** Not class, not missing caller ID,
|
|
not trunk limits, not DND.
|
|
|
|
```ini
|
|
[sp-emergency-dial]
|
|
exten => 911,1,Goto(sp-emergency,s,1)
|
|
exten => 9911,1,Goto(sp-emergency,s,1) ; people who dial 9 first out of habit
|
|
|
|
[sp-emergency]
|
|
exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E911_CID})})
|
|
same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
|
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
|
same => n,Set(__SP_DIRECTION=outbound)
|
|
same => n,Dial(PJSIP/911@trunk-{trunk1id}) ; every ENABLED trunk, in name order, no limits
|
|
same => n,Dial(PJSIP/911@trunk-{trunk2id})
|
|
same => n,Hangup()
|
|
```
|
|
The `SpEmergency` event triggers notifications (04 §5.3, 06 §6). For the
|
|
"ring a notification phone" part, the api originates a call into this static context:
|
|
|
|
```ini
|
|
[sp-emergency-alert]
|
|
; Originated by the api: Channel PJSIP/<notify ext>, Context sp-emergency-alert, Exten <caller ext>
|
|
exten => _X!,1,Answer() ; X! also matches a one-digit "0" (caller unknown, or a test)
|
|
same => n,Wait(1)
|
|
same => n,Playback(beep&beep&beep)
|
|
same => n,Playback(extension)
|
|
same => n,SayDigits(${EXTEN})
|
|
same => n,Playback(beep&beep&beep)
|
|
same => n,Playback(extension)
|
|
same => n,SayDigits(${EXTEN})
|
|
same => n,Hangup()
|
|
```
|
|
(If an admin uploads a sound named `emergency-alert`, play `custom/emergency-alert` first.)
|
|
|
|
## 7. `voicemail_selfphoneng.conf`
|
|
|
|
```ini
|
|
[general]
|
|
format = wav
|
|
attach = no ; the api sends emails itself
|
|
maxmsg = {voicemail.maxMessages}
|
|
maxsecs = {voicemail.maxMessageSeconds}
|
|
minsecs = 1
|
|
maxsilence = 10
|
|
silencethreshold = 128
|
|
maxlogins = 3
|
|
envelope = yes
|
|
saycid = yes
|
|
review = no
|
|
nextaftercmd = yes
|
|
sendvoicemail = no
|
|
pollmailboxes = yes
|
|
pollfreq = 30
|
|
|
|
[zonemessages]
|
|
local = {general.timezone}|'vm-received' Q 'digits/at' IMp
|
|
|
|
[default]
|
|
101 => {pin},{name},,,tz=local|attach=no
|
|
```
|
|
Only extensions with `voicemail_enabled` get a line.
|
|
|
|
## 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).
|
|
|
|
## 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_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.
|