LiveState loads extension states and registered devices from Asterisk on every AMI connect and follows ExtensionStatus/ContactStatus; the hub sends a snapshot then deltas batched every 250 ms (devices for admins only), pings every 30 s and closes unauthenticated sockets with 4401. The web UI keeps one reconnecting socket in a zustand store, shows a Reconnecting banner, a Dashboard (online, on a call, telephony engine) and status dots. Fix: Asterisk 24 has no 'pjsip reload'; reload with 'module reload res_pjsip.so'. AMI errors now carry the CLI output. Milestone check, automated: call-test.ts registers two baresip phones and holds a 30 s call with RTP both ways (pnpm test:asterisk); the recorded session drives the live-state tests. scripts/e2e/m2-live.mjs runs api + Asterisk + phones + browser on a private Docker network: a new extension is live in Asterisk in ~1.3 s, the dashboard shows 2/2 online and red dots during the call. Task: M2-T6
941 lines
36 KiB
Markdown
941 lines
36 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.
|
|
Skip disabled trunks entirely.
|
|
|
|
## 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)}
|
|
```
|
|
|
|
### 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
|
|
|
|
```ini
|
|
[sp-outbound-routes]
|
|
; one line per pattern, routes in position order; disabled routes skipped
|
|
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)
|
|
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}]?t2) ; only if max_channels
|
|
same => n,Dial(PJSIP/${EXTEN}@trunk-{trunk1id},120)
|
|
same => n,GotoIf($["${DIALSTATUS}" != "CHANUNAVAIL" & "${DIALSTATUS}" != "CONGESTION"]?done)
|
|
same => n(t2),Dial(PJSIP/${EXTEN}@trunk-{trunk2id},120) ; repeat per trunk
|
|
same => n(done),Hangup()
|
|
same => n(denied),Playback(ss-noservice)
|
|
same => n,Hangup()
|
|
```
|
|
Default routes created by the setup wizard (06 §4):
|
|
|
|
| Name | Patterns | Required class |
|
|
|---|---|---|
|
|
| Local/National (NANP) | `NXXNXXXXXX` (prepend `1`), `1NXXNXXXXXX` | national |
|
|
| Toll-free | `1800NXXXXXX`, `1888NXXXXXX`, `1877NXXXXXX`, `1866NXXXXXX`, `1855NXXXXXX`, `1844NXXXXXX`, `1833NXXXXXX` | local |
|
|
| International | `011.` | international — **created disabled** |
|
|
|
|
Pattern note: Asterisk sorts patterns by how specific they are, so toll-free can sit next to national safely.
|
|
|
|
### 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()
|
|
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.
|