paging: paging groups, *80 intercom, auto-answer headers
ci / check (push) Successful in 1m7s
ci / images (push) Successful in 3m20s

/api/paging-groups CRUD (members as memberIds). The generator renders
sp-page-{id} per 03 §6.11 (desk phones only, auto-answer headers via
sp-sub-autoanswer) plus the number in sp-local-numbers. Added the `s`
option so phones already on a call aren't paged, which also keeps a
pager who is a member from being paged themselves. A group with no
members just hangs up.

*80<ext> intercom checks the target first: digits only and an endpoint
in sp-from-ext, else ss-noservice (the spec's one-liner passed
${EXTEN:3} straight into Page()). Both written back into 03.

Web: Paging page (list, drawer form with member checkboxes). Golden
case paging. e2e m8-t4 with baresip phones that honor the headers
(sip_autoanswer): an ordinary call rings, a page is answered by itself
and muted, the busy pager is skipped, *80 is two-way, *80999 refused.
The check on a real Yealink/Grandstream stays open (09).

Task: M8-T4
This commit is contained in:
iamdoubz committed 2026-10-19 09:40:22 -05:00
1 parent a6c423de97
commit e95271e694
47 files changed
+1466 -22

No files matched your search

+2
View File
@@ -25,6 +25,7 @@ import { soundRoutes } from './routes/sounds.ts';
import { ringGroupRoutes } from './routes/ringgroups.ts';
import { queueRoutes } from './routes/queues.ts';
import { conferenceRoutes } from './routes/conferences.ts';
import { pagingRoutes } from './routes/paging.ts';
import { ivrRoutes } from './routes/ivrs.ts';
import { timeConditionRoutes } from './routes/timeconditions.ts';
import { announcementRoutes } from './routes/announcements.ts';
@@ -108,6 +109,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(ringGroupRoutes);
app.register(queueRoutes);
app.register(conferenceRoutes);
app.register(pagingRoutes);
app.register(ivrRoutes);
app.register(timeConditionRoutes);
app.register(announcementRoutes);
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -147,6 +147,14 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
same => n(deny),Answer()
same => n,Playback(ss-noservice)
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)
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -147,6 +147,14 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
same => n(deny),Answer()
same => n,Playback(ss-noservice)
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)
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -147,6 +147,14 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
same => n(deny),Answer()
same => n,Playback(ss-noservice)
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)
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -147,6 +147,14 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
same => n(deny),Answer()
same => n,Playback(ss-noservice)
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)
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -147,6 +147,14 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
same => n(deny),Answer()
same => n,Playback(ss-noservice)
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)
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -147,6 +147,14 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
same => n(deny),Answer()
same => n,Playback(ss-noservice)
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)
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -147,6 +147,14 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
same => n(deny),Answer()
same => n,Playback(ss-noservice)
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)
@@ -0,0 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[sp_admin_user]
type = user
admin = yes
marked = yes
music_on_hold_when_empty = yes
@@ -0,0 +1,7 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_CLASS_99=2
SP_CLASS_101=2
SP_CLASS_102=2
@@ -0,0 +1,245 @@
; 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,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
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()
; ─── 911 alert ring-out. Originated by the api (04 §5.3): Channel PJSIP/<notify ext>,
; Exten = the extension that dialed 911.
[sp-emergency-alert]
exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
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()
; ─── Feature codes (03 §6.13).
[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: features.conf pickupexten catches it straight from a phone's INVITE; this
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
exten => *8,1,Pickup()
same => n,Hangup()
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,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
same => n,Set(DB(${K})=1)
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,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
same => n,Playback(agent-loginok)
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
same => n(deny),Answer()
same => n,Playback(ss-noservice)
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()
[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,1,never))
same => n,Hangup()
exten => 99,hint,PJSIP/99&PJSIP/99-web
exten => 101,1,Gosub(sp-sub-user,s,1(101,25,1,never))
same => n,Hangup()
exten => 101,hint,PJSIP/101&PJSIP/101-web
exten => 102,1,Gosub(sp-sub-user,s,1(102,25,1,never))
same => n,Hangup()
exten => 102,hint,PJSIP/102&PJSIP/102-web
; paging groups
exten => 850,1,Goto(sp-page-66666666-6666-4666-8666-666666666850,s,1)
exten => 851,1,Goto(sp-page-66666666-6666-4666-8666-666666666851,s,1)
exten => 852,1,Goto(sp-page-66666666-6666-4666-8666-666666666852,s,1)
; ─── Paging group 850 "All phones loud"
[sp-page-66666666-6666-4666-8666-666666666850]
exten => s,1,Page(PJSIP/99&PJSIP/101&PJSIP/102,iqsb(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
; ─── Paging group 851 "Intercom"
[sp-page-66666666-6666-4666-8666-666666666851]
exten => s,1,Page(PJSIP/102,diqsb(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
; ─── Paging group 852 "Nobody yet"
[sp-page-66666666-6666-4666-8666-666666666852]
exten => s,1,Hangup() ; no members
; ─── Office hours overrides (admins only, see sp-featurecodes)
[sp-tc-override]
exten => _*3[123]X,1,Playback(ss-noservice)
same => n,Hangup()
[sp-inbound]
; anything else
exten => _[0-9a-z].,1,Hangup()
[sp-outbound-routes]
exten => pending,1,Hangup() ; no routes yet
[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,Set(CDR(SP_DIRECTION)=outbound)
same => n,Hangup()
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,246 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[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:5060
[transport-tcp]
type = transport
protocol = tcp
bind = 0.0.0.0:5060
[transport-ws]
type = transport
protocol = ws
bind = 0.0.0.0
; ── extension 99 (Front desk) ──
[99]
type = endpoint
context = sp-from-ext
disallow = all
allow = g722,ulaw,alaw
auth = 99-auth
aors = 99
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_pai = yes
callerid = "Front desk" <99>
mailboxes = 99@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=99
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[99-auth]
type = auth
auth_type = userpass
username = 99
password = Qw2eRt4yUi6oPa8sDf0gHj1k
[99]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30
mailboxes = 99@default
[99-web]
type = endpoint
context = sp-from-ext
webrtc = yes
disallow = all
allow = g722,ulaw
auth = 99-web-auth
aors = 99-web
from_domain = selfphoneng.invalid
callerid = "Front desk" <99>
mailboxes = 99@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=99
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[99-web-auth]
type = auth
auth_type = userpass
username = 99-web
password = Qw2eRt4yUi6oPa8sDf0gHj1k
[99-web]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 0
; ── extension 101 (Dan Doe) ──
[101]
type = endpoint
context = sp-from-ext
disallow = all
allow = g722,ulaw,alaw
auth = 101-auth
aors = 101
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_pai = yes
callerid = "Dan Doe" <101>
mailboxes = 101@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=101
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[101-auth]
type = auth
auth_type = userpass
username = 101
password = Ab3dEf9hIj2kLm4nOp6qRs8t
[101]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30
mailboxes = 101@default
[101-web]
type = endpoint
context = sp-from-ext
webrtc = yes
disallow = all
allow = g722,ulaw
auth = 101-web-auth
aors = 101-web
from_domain = selfphoneng.invalid
callerid = "Dan Doe" <101>
mailboxes = 101@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=101
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[101-web-auth]
type = auth
auth_type = userpass
username = 101-web
password = Ab3dEf9hIj2kLm4nOp6qRs8t
[101-web]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 0
; ── extension 102 (Ann Lee) ──
[102]
type = endpoint
context = sp-from-ext
disallow = all
allow = g722,ulaw,alaw
auth = 102-auth
aors = 102
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_pai = yes
callerid = "Ann Lee" <102>
mailboxes = 102@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=102
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[102-auth]
type = auth
auth_type = userpass
username = 102
password = Zx8cVb7nMq6wEr5tYu4iOp3a
[102]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30
mailboxes = 102@default
[102-web]
type = endpoint
context = sp-from-ext
webrtc = yes
disallow = all
allow = g722,ulaw
auth = 102-web-auth
aors = 102-web
from_domain = selfphoneng.invalid
callerid = "Ann Lee" <102>
mailboxes = 102@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=102
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[102-web-auth]
type = auth
auth_type = userpass
username = 102-web
password = Zx8cVb7nMq6wEr5tYu4iOp3a
[102-web]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 0
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,2 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
icesupport = yes
@@ -0,0 +1,25 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[general]
format = wav
attach = no ; the api sends emails itself
maxmsg = 100
maxsecs = 300
minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
envelope = yes
saycid = yes
review = no
nextaftercmd = yes
sendvoicemail = no
pollmailboxes = yes
pollfreq = 30
[zonemessages]
local = America/Chicago|'vm-received' Q 'digits/at' IMp
[default]
99 => 4826,Front desk,,,tz=local|attach=no
101 => 4826,Dan Doe,,,tz=local|attach=no
102 => 4826,Ann Lee,,,tz=local|attach=no
@@ -0,0 +1,38 @@
{
"extensions": [
{
"id": "00000000-0000-4000-8000-000000000101",
"number": "101",
"name": "Dan Doe",
"sipPassword": "Ab3dEf9hIj2kLm4nOp6qRs8t"
},
{
"id": "00000000-0000-4000-8000-000000000102",
"number": "102",
"name": "Ann Lee",
"sipPassword": "Zx8cVb7nMq6wEr5tYu4iOp3a"
},
{
"id": "00000000-0000-4000-8000-000000000099",
"number": "99",
"name": "Front desk",
"sipPassword": "Qw2eRt4yUi6oPa8sDf0gHj1k"
}
],
"pagingGroups": [
{
"id": "66666666-6666-4666-8666-666666666850",
"number": "850",
"name": "All phones; \"loud\"",
"members": ["102", "99", "101"]
},
{
"id": "66666666-6666-4666-8666-666666666851",
"number": "851",
"name": "Intercom",
"duplex": true,
"members": ["102"]
},
{ "id": "66666666-6666-4666-8666-666666666852", "number": "852", "name": "Nobody yet" }
]
}
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -147,6 +147,14 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
same => n(deny),Answer()
same => n,Playback(ss-noservice)
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)
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -147,6 +147,14 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
same => n(deny),Answer()
same => n,Playback(ss-noservice)
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)
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -147,6 +147,14 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
same => n(deny),Answer()
same => n,Playback(ss-noservice)
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)
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -147,6 +147,14 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
same => n(deny),Answer()
same => n,Playback(ss-noservice)
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)
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -147,6 +147,14 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
same => n(deny),Answer()
same => n,Playback(ss-noservice)
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)
+3 -1
View File
@@ -33,7 +33,9 @@ export function renderLocalNumbers(snap: Snapshot): string {
if (snap.ivrs.length) lines.push('', '; digital receptionists');
for (const r of [...snap.ivrs].sort(byNumber))
lines.push(`exten => ${s.digits(r.number)},1,Goto(sp-ivr-${s.ident(r.id)},s,1)`);
// M8-T4: paging groups add one line each here.
if (snap.pagingGroups.length) lines.push('', '; paging groups');
for (const g of [...snap.pagingGroups].sort(byNumber))
lines.push(`exten => ${s.digits(g.number)},1,Goto(sp-page-${s.ident(g.id)},s,1)`);
return lines.join('\n') + '\n';
}
+28
View File
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Paging groups (03 §6.11): sp-page-{id} calls every member's desk phone (PJSIP/<ext>, never -web)
// with the auto-answer headers. `s` skips phones that are in use, the pager's own included; `d`
// lets the paged phones talk back (intercom).
import { byNumber, type Snapshot } from '../load.ts';
import * as s from '../sanitize.ts';
export function renderPaging(snap: Snapshot): string {
return [...snap.pagingGroups]
.sort(byNumber)
.map((g) => {
const phones = [...g.members]
.sort((a, b) => Number(a) - Number(b))
.map((n) => `PJSIP/${s.digits(n)}`);
const lines = [
`; ─── Paging group ${s.digits(g.number)} "${s.name(g.name)}"`,
`[sp-page-${s.ident(g.id)}]`,
];
if (!phones.length) lines.push('exten => s,1,Hangup() ; no members');
else
lines.push(
`exten => s,1,Page(${phones.join('&')},${g.duplex ? 'd' : ''}iqsb(sp-sub-autoanswer^s^1),30)`,
' same => n,Hangup()',
);
return lines.join('\n') + '\n';
})
.join('\n');
}
+9 -1
View File
@@ -89,7 +89,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(\${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(\${SP_EXT}@default)
@@ -150,6 +150,14 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=\${FILTER(0-9,\${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["\${T}" != "\${EXTEN:3}"]?deny)
same => n,GotoIf($["\${PJSIP_ENDPOINT(\${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/\${T},db(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
same => n(deny),Answer()
same => n,Playback(ss-noservice)
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)
+9
View File
@@ -20,6 +20,7 @@ export function loadCase(name: string): Snapshot {
ringGroups?: Partial<Snapshot['ringGroups'][number]>[];
queues?: Partial<Snapshot['queues'][number]>[];
conferences?: Partial<Snapshot['conferences'][number]>[];
pagingGroups?: Partial<Snapshot['pagingGroups'][number]>[];
ivrs?: Partial<Snapshot['ivrs'][number]>[];
timeConditions?: Partial<Snapshot['timeConditions'][number]>[];
holidays?: Snapshot['holidays'];
@@ -115,6 +116,14 @@ export function loadCase(name: string): Snapshot {
id: c.id!,
number: c.number!,
})),
pagingGroups: (raw.pagingGroups ?? []).map((g, i) => ({
name: `Paging ${i + 1}`,
duplex: false,
members: [],
...g,
id: g.id!,
number: g.number!,
})),
routes: (raw.routes ?? []).map((r, i) => ({
name: `Route ${i + 1}`,
position: i,
+1
View File
@@ -27,6 +27,7 @@ describe('golden', () => {
'ivr-business-hours',
'queue',
'conferences',
'paging',
]),
);
});
+2
View File
@@ -21,6 +21,7 @@ import { renderRingGroups } from './dialplan/ringgroups.ts';
import { renderQueueDialplan } from './dialplan/queues.ts';
import { renderQueues } from './queues.ts';
import { renderConfbridge, renderConferences } from './dialplan/conferences.ts';
import { renderPaging } from './dialplan/paging.ts';
import { renderIvrs } from './dialplan/ivrs.ts';
import { renderTimeConditions } from './dialplan/timeconditions.ts';
import { renderAnnouncements } from './dialplan/announcements.ts';
@@ -61,6 +62,7 @@ const BODIES: Partial<Record<GeneratedFileName, (snap: Snapshot) => string>> = {
renderRingGroups(snap),
renderQueueDialplan(snap),
renderConferences(snap),
renderPaging(snap),
renderIvrs(snap),
renderTimeConditions(snap),
renderAnnouncements(snap),
+24
View File
@@ -10,6 +10,8 @@ import {
extensions,
holidays,
ivrOptions,
pagingGroupMembers,
pagingGroups,
ivrs,
outboundRoutes,
queueMembers,
@@ -126,6 +128,15 @@ export type SnapConference = {
record: boolean;
};
export type SnapPagingGroup = {
id: string;
number: string;
name: string;
duplex: boolean;
/** Extension numbers. */
members: string[];
};
export type SnapIvr = {
id: string;
number: string;
@@ -179,6 +190,7 @@ export type Snapshot = {
ringGroups: SnapRingGroup[];
queues: SnapQueue[];
conferences: SnapConference[];
pagingGroups: SnapPagingGroup[];
ivrs: SnapIvr[];
timeConditions: SnapTimeCondition[];
holidays: SnapHoliday[];
@@ -233,6 +245,11 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
.from(queueMembers)
.innerJoin(extensions, eq(extensions.id, queueMembers.extensionId));
const confRows = await db.select().from(conferences);
const pageRows = await db.select().from(pagingGroups);
const pagedRows = await db
.select({ groupId: pagingGroupMembers.pagingGroupId, number: extensions.number })
.from(pagingGroupMembers)
.innerJoin(extensions, eq(extensions.id, pagingGroupMembers.extensionId));
const ivrRows = await db.select().from(ivrs);
const tcRows = await db.select().from(timeConditions);
const holidayRows = await db.select().from(holidays);
@@ -335,6 +352,13 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
maxMembers: c.maxMembers,
record: c.record,
})),
pagingGroups: pageRows.map((g) => ({
id: g.id,
number: g.number,
name: g.name,
duplex: g.duplex,
members: pagedRows.filter((m) => m.groupId === g.id).map((m) => m.number),
})),
routes: routeRows.map((r) => ({
id: r.id,
name: r.name,
+161
View File
@@ -0,0 +1,161 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/paging-groups (04 §7.3): members are sent as `memberIds`. Paging groups aren't call
// destinations, so nothing else points at them and delete never needs a check. Admin only.
import { asc, eq, inArray } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
PagingGroupCreateSchema,
PagingGroupUpdateSchema,
type ListResponse,
type PagingGroupDto,
} from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts';
import type { Db } from '../db/client.ts';
import { extensions, pagingGroupMembers, pagingGroups } from '../db/schema.ts';
import { badRequest, conflict, notFound } from '../errors.ts';
import { audit } from '../services/audit.ts';
import { assertNumberAvailable } from '../services/numbering.ts';
type Row = typeof pagingGroups.$inferSelect;
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
async function toDto(db: Db, g: Row): Promise<PagingGroupDto> {
const members = await db
.select({ id: pagingGroupMembers.extensionId })
.from(pagingGroupMembers)
.innerJoin(extensions, eq(extensions.id, pagingGroupMembers.extensionId))
.where(eq(pagingGroupMembers.pagingGroupId, g.id))
.orderBy(asc(extensions.number));
return {
id: g.id,
number: g.number,
name: g.name,
duplex: g.duplex,
memberIds: members.map((m) => m.id),
createdAt: g.createdAt.toISOString(),
};
}
async function assertMembers(db: Db, ids: string[]) {
if (!ids.length) return;
const found = await db
.select({ id: extensions.id })
.from(extensions)
.where(inArray(extensions.id, ids));
if (found.length !== ids.length) throw badRequest('One of the members no longer exists');
}
async function setMembers(db: Db | Tx, id: string, ids: string[]) {
await db.delete(pagingGroupMembers).where(eq(pagingGroupMembers.pagingGroupId, id));
if (ids.length)
await db
.insert(pagingGroupMembers)
.values(ids.map((extensionId) => ({ pagingGroupId: id, extensionId })));
}
const IdParams = z.object({ id: z.uuid() });
const isUniqueViolation = (e: unknown) =>
((e as { cause?: { code?: string } }).cause?.code ?? (e as { code?: string }).code) === '23505';
export async function pagingRoutes(app: FastifyInstance) {
const { db } = app;
app.addHook('preHandler', requireAdmin);
async function getGroup(id: string) {
const [g] = await db.select().from(pagingGroups).where(eq(pagingGroups.id, id));
if (!g) throw notFound('Paging group not found');
return g;
}
app.get('/api/paging-groups', async (): Promise<ListResponse<PagingGroupDto>> => {
const rows = await db.select().from(pagingGroups).orderBy(asc(pagingGroups.number));
return { items: await Promise.all(rows.map((g) => toDto(db, g))), nextCursor: null };
});
app.get('/api/paging-groups/:id', async (req) =>
toDto(db, await getGroup(IdParams.parse(req.params).id)),
);
app.post('/api/paging-groups', async (req, reply) => {
const { memberIds = [], ...body } = PagingGroupCreateSchema.parse(req.body);
await assertNumberAvailable(db, body.number, 'paging');
await assertMembers(db, memberIds);
let created: Row;
try {
created = await db.transaction(async (tx) => {
const [g] = (await tx.insert(pagingGroups).values(body).returning()) as [Row];
await setMembers(tx, g.id, memberIds);
return g;
});
} catch (e) {
if (isUniqueViolation(e)) throw conflict(`${body.number} is already taken`);
throw e;
}
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'create',
entityType: 'paging_group',
entityId: created.id,
summary: `Created paging group ${created.number} (${created.name})`,
diff: { after: { ...body, memberIds } },
});
app.generator.requestApply('api-change');
return reply.code(201).send(await toDto(db, created));
});
app.patch('/api/paging-groups/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const { memberIds, ...body } = PagingGroupUpdateSchema.parse(req.body);
const before = await getGroup(id);
if (body.number && body.number !== before.number)
await assertNumberAvailable(db, body.number, 'paging', id);
if (memberIds) await assertMembers(db, memberIds);
let after: Row;
try {
after = await db.transaction(async (tx) => {
const [g] = Object.keys(body).length
? ((await tx
.update(pagingGroups)
.set(body)
.where(eq(pagingGroups.id, id))
.returning()) as [Row])
: [before];
if (memberIds) await setMembers(tx, id, memberIds);
return g;
});
} catch (e) {
if (isUniqueViolation(e)) throw conflict(`${body.number} is already taken`);
throw e;
}
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'update',
entityType: 'paging_group',
entityId: id,
summary: `Updated paging group ${after.number} (${after.name})`,
diff: { after: { ...body, ...(memberIds ? { memberIds } : {}) } },
});
app.generator.requestApply('api-change');
return toDto(db, after);
});
app.delete('/api/paging-groups/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const g = await getGroup(id);
await db.delete(pagingGroups).where(eq(pagingGroups.id, id));
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'delete',
entityType: 'paging_group',
entityId: id,
summary: `Deleted paging group ${g.number} (${g.name})`,
diff: { before: g },
});
app.generator.requestApply('api-change');
return { ok: true };
});
}
+1
View File
@@ -67,6 +67,7 @@ function snapshot(): Snapshot {
ringGroups: [],
queues: [],
conferences: [],
pagingGroups: [],
ivrs: [],
timeConditions: [],
holidays: [],
+1
View File
@@ -156,6 +156,7 @@ function snapshot(): Snapshot {
: ({ type: 'voicemail', id: CDR_IDS.ext('102') } as const),
})),
conferences: [],
pagingGroups: [],
ivrs: [
{
id: CDR_IDS.ivr,
+1
View File
@@ -106,6 +106,7 @@ function snapshot(): Snapshot {
ringGroups: [],
queues: [],
conferences: [],
pagingGroups: [],
ivrs: [],
timeConditions: [],
holidays: [],
+1
View File
@@ -62,6 +62,7 @@ function snapshot(): Snapshot {
ringGroups: [],
queues: [],
conferences: [],
pagingGroups: [],
ivrs: [],
timeConditions: [],
holidays: [],
+76
View File
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M8-T4: /api/paging-groups (03 §6.11, 04 §7.3).
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('/api/paging-groups', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
const ext: Record<string, string> = {};
let groupId: string;
const dialplan = async () => {
await t.app.generator.apply({ trigger: 'test' });
return readFile(join(t.genDir, 'extensions_selfphoneng.conf'), 'utf8');
};
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
req = (method, url, payload) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
});
for (const [number, name] of [
['102', 'Ann Lee'],
['101', 'Dan Doe'],
] as const)
ext[number] = (await req('POST', '/api/extensions', { number, name })).json().id;
});
afterAll(() => t?.close());
it('creates a group: desk phones only, auto-answer, busy phones skipped', async () => {
const r = await req('POST', '/api/paging-groups', {
number: '850',
name: 'Warehouse',
memberIds: [ext['102'], ext['101']],
});
expect(r.statusCode, r.body).toBe(201);
groupId = r.json().id;
expect(r.json()).toMatchObject({ duplex: false, memberIds: [ext['101'], ext['102']] });
const d = await dialplan();
expect(d).toContain(`exten => 850,1,Goto(sp-page-${groupId},s,1)`);
expect(d).toContain('exten => s,1,Page(PJSIP/101&PJSIP/102,iqsb(sp-sub-autoanswer^s^1),30)');
});
it('checks the number and the members', async () => {
const bad = (body: object) =>
req('POST', '/api/paging-groups', { number: '851', name: 'X', ...body });
expect((await bad({ number: '800' })).statusCode).toBe(400); // conference range
expect((await bad({ number: '850' })).statusCode).toBe(409);
expect((await bad({ memberIds: [ext['101'], ext['101']] })).statusCode).toBe(400);
expect((await bad({ memberIds: ['00000000-0000-4000-8000-000000000000'] })).statusCode).toBe(
400,
);
});
it('two-way and fewer members; delete removes it from the dialplan', async () => {
const r = await req('PATCH', `/api/paging-groups/${groupId}`, {
duplex: true,
memberIds: [ext['102']],
});
expect(r.statusCode, r.body).toBe(200);
expect(await dialplan()).toContain(
'exten => s,1,Page(PJSIP/102,diqsb(sp-sub-autoanswer^s^1),30)',
);
expect((await req('DELETE', `/api/paging-groups/${groupId}`)).statusCode).toBe(200);
expect(await dialplan()).not.toContain('sp-page-');
});
});
+11
View File
@@ -34,6 +34,9 @@ import type {
ConferenceCreate,
ConferenceDto,
ConferenceUpdate,
PagingGroupCreate,
PagingGroupDto,
PagingGroupUpdate,
QueueCreate,
QueueDto,
QueueStats,
@@ -261,6 +264,14 @@ export const conferencesApi = {
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/conferences/${id}`),
};
export const pagingApi = {
list: () => api<ListResponse<PagingGroupDto>>('GET', '/api/paging-groups').then((r) => r.items),
create: (g: PagingGroupCreate) => api<PagingGroupDto>('POST', '/api/paging-groups', g),
update: (id: string, g: PagingGroupUpdate) =>
api<PagingGroupDto>('PATCH', `/api/paging-groups/${id}`, g),
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/paging-groups/${id}`),
};
export const ivrsApi = {
list: () => api<ListResponse<IvrDto>>('GET', '/api/ivrs').then((r) => r.items),
create: (r: IvrCreate) => api<IvrDto>('POST', '/api/ivrs', r),
+3
View File
@@ -18,6 +18,7 @@ import { SoundsPage } from './pages/admin/SoundsPage.tsx';
import { RingGroupsPage } from './pages/admin/RingGroupsPage.tsx';
import { QueuesPage } from './pages/admin/QueuesPage.tsx';
import { ConferencesPage } from './pages/admin/ConferencesPage.tsx';
import { PagingPage } from './pages/admin/PagingPage.tsx';
import { IvrsPage } from './pages/admin/IvrsPage.tsx';
import { OfficeHoursPage } from './pages/admin/OfficeHoursPage.tsx';
import { BlockedPage } from './pages/admin/BlockedPage.tsx';
@@ -58,6 +59,8 @@ const router = createBrowserRouter([
<QueuesPage />
) : p.path === 'conferences' ? (
<ConferencesPage />
) : p.path === 'paging' ? (
<PagingPage />
) : p.path === 'ivrs' ? (
<IvrsPage />
) : p.path === 'hours' ? (
+1 -1
View File
@@ -20,7 +20,7 @@ export const ADMIN_PAGES = [
{ path: 'ivrs', label: 'Digital receptionist', milestone: null },
{ path: 'hours', label: 'Office hours', milestone: null },
{ path: 'conferences', label: 'Conferences', milestone: null },
{ path: 'paging', label: 'Paging', milestone: 'M8' },
{ path: 'paging', label: 'Paging', milestone: null },
{ path: 'sounds', label: 'Sounds & music', milestone: null },
{ path: 'devices', label: 'Phones', milestone: 'M10' },
{ path: 'contacts', label: 'Contacts', milestone: 'M10' },
+205
View File
@@ -0,0 +1,205 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Paging (05 §2, 03 §6.11): number, name, members, duplex. Dialing the number makes the members'
// desk phones answer by themselves and play what the caller says.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
PagingGroupCreateSchema,
type PagingGroupCreate,
type PagingGroupDto,
} from '@selfphoneng/shared';
import { extensionsApi, pagingApi } from '../../api/client.ts';
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
export function PagingPage() {
const qc = useQueryClient();
const groups = useQuery({ queryKey: ['paging-groups'], queryFn: pagingApi.list });
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
const [editing, setEditing] = useState<PagingGroupDto | 'new' | null>(null);
const [deleting, setDeleting] = useState<PagingGroupDto | null>(null);
const refresh = () => qc.invalidateQueries({ queryKey: ['paging-groups'] });
const remove = useMutation({
mutationFn: (id: string) => pagingApi.remove(id),
onSuccess: async () => {
setDeleting(null);
await refresh();
},
});
const nameOf = (id: string) => exts.data?.find((x) => x.id === id)?.name ?? '?';
return (
<section className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Paging</h1>
<Button variant="primary" onClick={() => setEditing('new')}>
Add paging group
</Button>
</div>
{groups.isError && <Alert>{groups.error.message}</Alert>}
{groups.data?.length === 0 && (
<p className="text-sm text-zinc-500">
No paging groups yet. Dialing a paging group's number (like 850) makes its desk phones
answer by themselves, so everyone near them hears you. To talk to one desk phone this way,
dial *80 and its extension (like *80101).
</p>
)}
{groups.data && groups.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{groups.data.map((g) => (
<li
key={g.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="font-mono">{g.number}</span>
<span className="font-medium">{g.name}</span>
<span className="min-w-0 flex-1 truncate text-zinc-500">
{g.duplex ? 'Two-way' : 'One-way'}:{' '}
{g.memberIds.map(nameOf).join(', ') || 'nobody yet'}
</span>
<Button onClick={() => setEditing(g)}>Edit</Button>
<Button variant="ghost" onClick={() => setDeleting(g)}>
Delete
</Button>
</li>
))}
</ul>
)}
{editing && (
<PagingForm
group={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
onSaved={refresh}
/>
)}
<ConfirmDialog
open={deleting !== null}
title="Delete paging group"
message={
<p>
Delete paging group {deleting?.number} ({deleting?.name})?
</p>
}
confirmLabel="Delete"
busy={remove.isPending}
error={remove.error?.message}
onCancel={() => {
setDeleting(null);
remove.reset();
}}
onConfirm={() => deleting && remove.mutate(deleting.id)}
/>
</section>
);
}
function PagingForm({
group,
onClose,
onSaved,
}: {
group: PagingGroupDto | null;
onClose: () => void;
onSaved: () => Promise<unknown>;
}) {
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
const next = useQuery({
queryKey: ['next-number', 'paging'],
queryFn: () => extensionsApi.nextNumber('paging'),
enabled: !group,
});
const [form, setForm] = useState({
number: group?.number ?? '',
name: group?.name ?? '',
duplex: group?.duplex ?? false,
});
const [members, setMembers] = useState<string[]>(group?.memberIds ?? []);
const [errors, setErrors] = useState<Record<string, string>>({});
const number = form.number || (group ? '' : (next.data ?? ''));
const save = useMutation({
mutationFn: (body: PagingGroupCreate) =>
group ? pagingApi.update(group.id, body) : pagingApi.create(body),
onSuccess: async () => {
await onSaved();
onClose();
},
});
function submit() {
const parsed = PagingGroupCreateSchema.safeParse({ ...form, number, memberIds: members });
if (!parsed.success) {
setErrors(Object.fromEntries(parsed.error.issues.map((i) => [String(i.path[0]), i.message])));
return;
}
setErrors({});
save.mutate(parsed.data);
}
const text = (k: 'number' | 'name', label: string, hint?: string) => (
<Field label={label} error={errors[k]} hint={hint}>
{(p) => (
<input
{...p}
className={inputClass}
inputMode={k === 'number' ? 'numeric' : undefined}
value={k === 'number' ? number : form[k]}
onChange={(e) => setForm((f) => ({ ...f, [k]: e.target.value }))}
/>
)}
</Field>
);
return (
<Dialog
open
drawer
onClose={onClose}
title={group ? `Paging group ${group.number}` : 'New paging group'}
footer={
<>
<Button onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={submit} disabled={save.isPending}>
Save
</Button>
</>
}
>
<div className="flex flex-col gap-4">
{text('number', 'Number', 'Dial this to page the group')}
{text('name', 'Name')}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.duplex}
onChange={(e) => setForm((f) => ({ ...f, duplex: e.target.checked }))}
/>
Two-way (the people paged can answer back)
</label>
<fieldset className="flex flex-col gap-1">
<legend className="mb-1 text-sm font-medium">Members</legend>
{errors.memberIds && <Alert>{errors.memberIds}</Alert>}
{exts.data?.map((x) => (
<label key={x.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={members.includes(x.id)}
onChange={(e) =>
setMembers((ms) =>
e.target.checked ? [...ms, x.id] : ms.filter((m) => m !== x.id),
)
}
/>
{x.number} {x.name}
</label>
))}
<p className="text-xs text-zinc-500">
Only desk phones are paged, not the browser phone, and phones already on a call are
skipped. Most Yealink, Grandstream, Polycom and Snom phones answer pages by themselves.
</p>
</fieldset>
{save.error && <Alert>{save.error.message}</Alert>}
</div>
</Dialog>
);
}