docs: initial SelfPhoneNG spec
Fix leftover __OP_* dialplan variables from the openphone rename (all readers use SP_*). Record the reverse-proxy decision in 00 §10.
This commit is contained in:
commit
547bb68a6f
13 files changed
+3804
No files matched your search
@@ -0,0 +1,937 @@
|
||||
# 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). If the PJSIP
|
||||
transport sections changed, don't reload PJSIP. Set
|
||||
`system.restartRequired = true` instead (see `01-infrastructure.md` §4.3).
|
||||
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 | `pjsip reload` (or restart-required if transports changed) |
|
||||
| `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 ; verify inheritance prefix works in set_var (AGENTS rule 10)
|
||||
|
||||
[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.
|
||||
Reference in new issue
Block a user