paging: paging groups, *80 intercom, auto-answer headers
/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:
1 parent
a6c423de97
commit
e95271e694
47 files changed
+1466
-22
No files matched your search
@@ -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)
|
||||
|
||||
+9
-1
@@ -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)
|
||||
|
||||
+9
-1
@@ -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)
|
||||
|
||||
+9
-1
@@ -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)
|
||||
|
||||
+9
-1
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('golden', () => {
|
||||
'ivr-business-hours',
|
||||
'queue',
|
||||
'conferences',
|
||||
'paging',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -67,6 +67,7 @@ function snapshot(): Snapshot {
|
||||
ringGroups: [],
|
||||
queues: [],
|
||||
conferences: [],
|
||||
pagingGroups: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
|
||||
@@ -156,6 +156,7 @@ function snapshot(): Snapshot {
|
||||
: ({ type: 'voicemail', id: CDR_IDS.ext('102') } as const),
|
||||
})),
|
||||
conferences: [],
|
||||
pagingGroups: [],
|
||||
ivrs: [
|
||||
{
|
||||
id: CDR_IDS.ivr,
|
||||
|
||||
@@ -106,6 +106,7 @@ function snapshot(): Snapshot {
|
||||
ringGroups: [],
|
||||
queues: [],
|
||||
conferences: [],
|
||||
pagingGroups: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
|
||||
@@ -62,6 +62,7 @@ function snapshot(): Snapshot {
|
||||
ringGroups: [],
|
||||
queues: [],
|
||||
conferences: [],
|
||||
pagingGroups: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
|
||||
@@ -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-');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user