generator: base dialplan, globals and sp-local-numbers with hints

Static sp-sub-* helpers and sp-from-ext from 03 §6.2 (with __SP_ variable
names), globals (default and E911 caller ID, per-extension class and admin
flags), and every user in sp-local-numbers with a PJSIP/<n>[&PJSIP/<n>-web]
hint. Voicemail stays off (ARG3=0) until M4. Contexts that later milestones
fill (emergency, feature codes, outbound routes) get a placeholder so
Asterisk doesn't warn about missing includes. Empty ccss.conf for the next
image build.

Task: M2-T4
This commit is contained in:
iamdoubz committed 2026-10-17 16:28:13 -05:00
1 parent 06ac99b9f4
commit a4da81cf8f
16 files changed
+673 -1

No files matched your search

@@ -1 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
@@ -1 +1,113 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
; ─── 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
[sp-local-numbers]
include => sp-parkedcalls
; users (sorted by number)
; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup()
[sp-outbound-routes] ; M3-T3 (03 §6.12)
exten => pending,1,Hangup()
@@ -1 +1,7 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_CLASS_103=2
SP_CLASS_104=2
SP_CLASS_105=2
@@ -1 +1,122 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
; ─── 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
[sp-local-numbers]
include => sp-parkedcalls
; users (sorted by number)
exten => 103,1,Gosub(sp-sub-user,s,1(103,25,0,never))
same => n,Hangup()
exten => 103,hint,PJSIP/103&PJSIP/103-web
exten => 104,1,Gosub(sp-sub-user,s,1(104,25,0,never))
same => n,Hangup()
exten => 104,hint,PJSIP/104&PJSIP/104-web
exten => 105,1,Gosub(sp-sub-user,s,1(105,25,0,never))
same => n,Hangup()
exten => 105,hint,PJSIP/105&PJSIP/105-web
; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup()
[sp-outbound-routes] ; M3-T3 (03 §6.12)
exten => pending,1,Hangup()
@@ -1 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
SP_DEFAULT_CID=13125551234
SP_DEFAULT_E911_CID=13125551234
SP_REC_ANNOUNCE=
SP_CLASS_101=2
SP_TCADMIN_101=1
@@ -1 +1,116 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
; ─── 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
[sp-local-numbers]
include => sp-parkedcalls
; users (sorted by number)
exten => 101,1,Gosub(sp-sub-user,s,1(101,25,0,never))
same => n,Hangup()
exten => 101,hint,PJSIP/101&PJSIP/101-web
; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup()
[sp-outbound-routes] ; M3-T3 (03 §6.12)
exten => pending,1,Hangup()
@@ -2,7 +2,8 @@
"settings": {
"network.publicIp": "203.0.113.10",
"network.lanIp": "192.168.1.20",
"network.stunServer": "stun.example.org:3478"
"network.stunServer": "stun.example.org:3478",
"routing.defaultCallerIdDidId": "11111111-1111-4111-8111-111111111111"
},
"extensions": [
{
@@ -14,5 +15,11 @@
"emergencyCallerId": "13125551234",
"isAdmin": true
}
],
"dids": [
{
"id": "11111111-1111-4111-8111-111111111111",
"number": "13125551234"
}
]
}
@@ -1 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_CLASS_99=0
SP_CLASS_102=1
@@ -1 +1,119 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
; ─── 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
[sp-local-numbers]
include => sp-parkedcalls
; users (sorted by number)
exten => 99,1,Gosub(sp-sub-user,s,1(99,25,0,never))
same => n,Hangup()
exten => 99,hint,PJSIP/99
exten => 102,1,Gosub(sp-sub-user,s,1(102,25,0,all))
same => n,Hangup()
exten => 102,hint,PJSIP/102
; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup()
[sp-outbound-routes] ; M3-T3 (03 §6.12)
exten => pending,1,Hangup()
+49
View File
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// sp-local-numbers (03 §6.6) and the globals file (03 §6.3).
import { byNumber, type Snapshot } from '../load.ts';
import * as s from '../sanitize.ts';
const CLASS_NUMBER = { internal: 0, local: 1, national: 2, international: 3 } as const;
// TODO(M4): voicemail on. Until then every user gets ARG3 = 0 (09 M2-T4).
const VOICEMAIL_READY = false;
export function renderLocalNumbers(snap: Snapshot): string {
const lines = [
'[sp-local-numbers]',
'include => sp-parkedcalls',
'',
'; users (sorted by number)',
];
for (const e of [...snap.extensions].sort(byNumber)) {
const num = s.digits(e.number);
const ring = s.digits(String(e.ringSeconds));
const vm = VOICEMAIL_READY && e.voicemailEnabled ? 1 : 0;
lines.push(
`exten => ${num},1,Gosub(sp-sub-user,s,1(${num},${ring},${vm},${e.recordMode}))`,
' same => n,Hangup()',
`exten => ${num},hint,PJSIP/${num}${e.webrtcEnabled ? `&PJSIP/${num}-web` : ''}`,
);
}
// M6/M8: ring groups, queues, IVRs, conferences and paging groups add one line each here.
return lines.join('\n') + '\n';
}
export function renderGlobals(snap: Snapshot): string {
const st = snap.settings;
const didNumber = (id: string | null) => {
const d = id ? snap.dids.find((x) => x.id === id) : undefined;
return d ? s.digits(d.number) : '';
};
const defaultCid = didNumber(st['routing.defaultCallerIdDidId']);
const lines = [
`SP_DEFAULT_CID=${defaultCid}`,
`SP_DEFAULT_E911_CID=${didNumber(st['emergency.defaultCallerIdDidId']) || defaultCid}`,
'SP_REC_ANNOUNCE=', // M7: recordings.announcementSoundId → sound name
];
const exts = [...snap.extensions].sort(byNumber);
for (const e of exts)
lines.push(`SP_CLASS_${s.digits(e.number)}=${CLASS_NUMBER[e.outboundClass]}`);
for (const e of exts) if (e.isAdmin) lines.push(`SP_TCADMIN_${s.digits(e.number)}=1`);
return lines.join('\n') + '\n';
}
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// sp-from-ext includes these contexts; until their features exist they hold one undialable
// extension, because Asterisk doesn't create an empty context and then warns "tries to include
// nonexistent context". Each one moves to its feature's renderer.
export const PENDING_CONTEXTS = `; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup()
[sp-outbound-routes] ; M3-T3 (03 §6.12)
exten => pending,1,Hangup()
`;
+102
View File
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Static dialplan (03 §6.2): no DB data, but kept in code so all dialplan lives in one place.
// Variables set with a "__" prefix are inherited by every channel the call creates (03 §9).
export const STATIC_DIALPLAN = `; ─── 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
`;
+2
View File
@@ -18,6 +18,7 @@ export function loadCase(name: string): Snapshot {
today?: string;
settings?: Record<string, unknown>;
extensions?: Partial<Snapshot['extensions'][number]>[];
dids?: Snapshot['dids'];
};
const settings = Object.fromEntries(
(Object.keys(SettingsSchemas) as SettingKey[])
@@ -27,6 +28,7 @@ export function loadCase(name: string): Snapshot {
return {
today: raw.today ?? '2026-10-17',
settings,
dids: raw.dids ?? [],
extensions: (raw.extensions ?? []).map((e, i) => ({
id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
number: '101',
+6
View File
@@ -10,6 +10,9 @@ import { generatorRuns } from '../db/schema.ts';
import { getSetting, setSetting } from '../services/settings.ts';
import { loadSnapshot, type Snapshot } from './load.ts';
import { renderPjsip, renderRtp, renderTransports } from './pjsip.ts';
import { STATIC_DIALPLAN } from './dialplan/static.ts';
import { renderGlobals, renderLocalNumbers } from './dialplan/local.ts';
import { PENDING_CONTEXTS } from './dialplan/pending.ts';
export { renderTransports };
@@ -35,6 +38,9 @@ export type RenderedFiles = Record<GeneratedFileName, string>;
const BODIES: Partial<Record<GeneratedFileName, (snap: Snapshot) => string>> = {
'pjsip_selfphoneng.conf': renderPjsip,
'rtp_selfphoneng.conf': renderRtp,
'extensions_globals_selfphoneng.conf': renderGlobals,
'extensions_selfphoneng.conf': (snap) =>
`${STATIC_DIALPLAN}\n${renderLocalNumbers(snap)}\n${PENDING_CONTEXTS}`,
};
/** PURE: no I/O, no clock, no randomness. Same snapshot → byte-identical files. */
+5
View File
@@ -26,11 +26,14 @@ export type SnapExtension = {
isAdmin: boolean; // the extension's user is an admin (time-condition override codes)
};
export type SnapDid = { id: string; number: string };
export type Snapshot = {
/** "YYYY-MM-DD" in general.timezone; render() never reads the clock. */
today: string;
settings: Awaited<ReturnType<typeof getAllSettings>>;
extensions: SnapExtension[];
dids: SnapDid[]; // M3 adds routing fields
};
export function todayIn(timezone: string, now = new Date()): string {
@@ -54,9 +57,11 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
.leftJoin(outDid, eq(outDid.id, extensions.outboundCallerIdDidId))
.leftJoin(e911Did, eq(e911Did.id, extensions.emergencyCallerIdDidId));
const didRows = await db.select({ id: dids.id, number: dids.number }).from(dids);
return {
today: todayIn(settings['general.timezone'], now),
settings,
dids: didRows,
extensions: rows.map(({ ext, role, outCid, e911Cid }) => ({
id: ext.id,
number: ext.number,
+2
View File
@@ -0,0 +1,2 @@
; Call completion: defaults are fine. Present only so Asterisk does not warn at boot.
[general]