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>
|
||||
);
|
||||
}
|
||||
@@ -717,10 +717,14 @@ exten => s,1,Set(__SP_TARGET={number})
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-page-{id}]
|
||||
exten => s,1,Page(PJSIP/101&PJSIP/102,{d if duplex}iqb(sp-sub-autoanswer^s^1),30)
|
||||
exten => s,1,Page(PJSIP/101&PJSIP/102,{d if duplex}iqsb(sp-sub-autoanswer^s^1),30)
|
||||
same => n,Hangup()
|
||||
```
|
||||
Paging only rings desk endpoints (`PJSIP/<ext>`), not `-web`.
|
||||
Paging only rings desk endpoints (`PJSIP/<ext>`), not `-web`. As built (M8-T4): `s` added, so phones
|
||||
already on a call aren't paged; that includes the pager when they're a member themselves. A group
|
||||
with no members is `exten => s,1,Hangup()`. Paging groups aren't call destinations. Golden case
|
||||
`paging`; `scripts/e2e/m8-t4.mjs` pages with real calls (baresip phones that honor the auto-answer
|
||||
headers, like Yealink/Grandstream do; a real desk phone is still to be checked).
|
||||
|
||||
`confbridge_selfphoneng.conf` always contains the admin profile, plus one bridge profile per room:
|
||||
```ini
|
||||
@@ -855,7 +859,13 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Page(PJSIP/${EXTEN:3},db(sp-sub-autoanswer^s^1),30) ; intercom
|
||||
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()
|
||||
@@ -880,6 +890,8 @@ deletes the AstDB key, stores nothing, and logs an audit entry. Dialplan also
|
||||
limits forwards via `SP_CLASS_<ext>` in `sp-sub-forward`.
|
||||
|
||||
As built (M6-T7): everything above except `*45` (queues) and `*80` (intercom), which come with M8.
|
||||
`*80` (M8-T4) only pages a desk phone: digits only (no `&` sneaking in another channel), and the
|
||||
endpoint must exist with context `sp-from-ext`; anything else plays `ss-noservice`.
|
||||
`*45` (M8-T1) checks `PQMSTATUS`/`UPQMSTATUS`, so a code for a queue the caller isn't an agent of plays
|
||||
`ss-noservice` and stores nothing (the original snippet stored the key and said "logged off" anyway).
|
||||
`*8` also has a dialplan line (`Pickup()`): `pickupexten` only catches it straight from a phone's
|
||||
|
||||
@@ -381,7 +381,7 @@ Admin resources (all `requireAdmin`; standard CRUD = `GET /x`, `POST /x`, `GET /
|
||||
| `/api/holidays` | `POST /import-us-federal {year}` (generates the list in code, no network) |
|
||||
| `/api/announcements` | |
|
||||
| `/api/conferences` | |
|
||||
| `/api/paging-groups` | |
|
||||
| `/api/paging-groups` | members are set via `memberIds: [uuid]` in POST/PATCH (no order). Not a destination, so delete never returns 409 |
|
||||
| `/api/sounds` | upload is `POST /api/sounds?name=&filename=` with the raw file as the body (like greetings, no multipart), `GET /:id/audio`, `PATCH /:id {name}`, `DELETE /:id` (409 with `usedBy`: IVR greeting/invalid sound, announcements, the recording notice), `POST /tts` (M12). M6 lists prompts only; MOH classes and files come with M8 |
|
||||
| `/api/moh-classes` | |
|
||||
| `/api/devices` | `GET /:id/config-preview` (rendered provisioning file, passwords masked) |
|
||||
|
||||
@@ -38,7 +38,7 @@ Left sidebar navigation, in this order:
|
||||
| IVRs (Digital receptionist) | `/admin/ivrs` | Form: number, name, greeting (SoundPicker), keypad grid 0–9 * # where each key gets a DestinationPicker or "unused", timeout, retries, invalid sound, direct dial toggle, timeout and invalid destinations |
|
||||
| Office hours | `/admin/hours` | Time conditions: weekly grid editor (click-drag hours per day) that saves to `open_ranges`; timezone; holidays toggle; open/closed/holiday destinations; override digit; big status chip "Open now / Closed now" with Force open / Force closed / Automatic buttons. Holidays sub-tab: list + "Add US federal holidays for {year}" |
|
||||
| Conferences | `/admin/conferences` | number, name, PIN, admin PIN, max members, record. As built (M8-T3): admins see the PINs in the list (they hand them out); an admin PIN needs a different room PIN |
|
||||
| Paging | `/admin/paging` | number, name, members, duplex |
|
||||
| Paging | `/admin/paging` | number, name, members, duplex. As built (M8-T4): members are checkboxes (no order); the empty state explains `*80<ext>` |
|
||||
| Sounds & music | `/admin/sounds` | Prompts list with play buttons, upload (drag-drop), TTS (M12). MOH classes with their files (M8). An **Announcements** section (play a sound, then a destination) lives here too: no page of its own (M6-T5) |
|
||||
| Phones (devices) | `/admin/devices` | Table: MAC, vendor, model, user, last provisioned. Form: MAC (any format → normalized), vendor, model, extension. Shows the provisioning URL to type into the phone, and "Preview config" |
|
||||
| Contacts | `/admin/contacts` | Company phonebook, CSV import |
|
||||
|
||||
@@ -205,6 +205,7 @@ Legend: 📖 read · 🔨 build · ✅ done when
|
||||
**M8-T2** Parking (`res_parking.conf`, `sp-parkedcalls` include) + parked calls in the live UI + BLF hints for slots.
|
||||
**M8-T3** Conferences with PIN/admin PIN (03 §6.11).
|
||||
**M8-T4** Paging/intercom (`sp-page-*`, `*80`) + auto-answer test on a real Yealink or Grandstream.
|
||||
As built: tested with baresip phones that honor the auto-answer headers (`sip_autoanswer`). **Open:** the check on a real Yealink or Grandstream, when one is available.
|
||||
**M8-T5** MOH classes (03 §8).
|
||||
✅ Golden `queue` case passes. Scenario: 3 callers wait in a queue with 2 agents. One agent pauses with `*45550`. The caller who waits past `max_wait_seconds` goes to the timeout destination. A parked call on 701 can be picked up from another phone.
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export * from './sounds.ts';
|
||||
export * from './ringgroups.ts';
|
||||
export * from './queues.ts';
|
||||
export * from './conferences.ts';
|
||||
export * from './paging.ts';
|
||||
export * from './ivrs.ts';
|
||||
export * from './timeconditions.ts';
|
||||
export * from './announcements.ts';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Paging groups (02 paging_groups, 03 §6.11): a number that makes several desk phones answer by
|
||||
// themselves and play what the caller says.
|
||||
import { z } from 'zod';
|
||||
import { InternalNumber } from './extensions.ts';
|
||||
|
||||
const PagingGroupFields = z.object({
|
||||
number: InternalNumber,
|
||||
name: z.string().trim().min(1, 'Required').max(40),
|
||||
/** false: one-way announcement; true: intercom, the paged phones can talk back. */
|
||||
duplex: z.boolean(),
|
||||
/** Extension ids. */
|
||||
memberIds: z
|
||||
.array(z.uuid())
|
||||
.max(50)
|
||||
.refine((ids) => new Set(ids).size === ids.length, 'Someone is listed twice'),
|
||||
});
|
||||
|
||||
export const PagingGroupCreateSchema = PagingGroupFields.partial().required({
|
||||
number: true,
|
||||
name: true,
|
||||
});
|
||||
export const PagingGroupUpdateSchema = PagingGroupFields.partial();
|
||||
export type PagingGroupCreate = z.input<typeof PagingGroupCreateSchema>;
|
||||
export type PagingGroupUpdate = z.input<typeof PagingGroupUpdateSchema>;
|
||||
|
||||
export type PagingGroupDto = z.output<typeof PagingGroupFields> & { id: string; createdAt: string };
|
||||
@@ -0,0 +1,198 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M8-T4 with real calls: paging group 850 built in the UI (320, 321, 322). 320 and 321 are
|
||||
// "intercom" desk phones: they ring like any phone (checked first), but answer by themselves when
|
||||
// the INVITE carries the auto-answer headers, as Yealink and Grandstream phones do. 322 pages the
|
||||
// group: 320 and 321 answer muted (one-way) and 322, already on the call, isn't paged. *80320 is
|
||||
// a two-way intercom to 320; *80 with something that isn't a desk phone is refused.
|
||||
// No real Yealink/Grandstream here: the hardware check stays open (09 M8-T4).
|
||||
// Runs on m2-live, after m1-t4.mjs.
|
||||
// Usage: node scripts/e2e/m8-t4.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { launch } from './cdp.mjs';
|
||||
import { startPhones } from './phones.mjs';
|
||||
|
||||
const NET = 'sp-live';
|
||||
const AST = 'sp-live-ast';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const cli = (cmd) =>
|
||||
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
|
||||
const astLog = () =>
|
||||
// eslint-disable-next-line no-control-regex -- Asterisk colors its log
|
||||
spawnSync('docker', ['logs', AST], { encoding: 'utf8' }).stdout.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
const [base, email, pw, shotsDir] = process.argv.slice(2);
|
||||
|
||||
const login = await fetch(`${base}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
|
||||
body: JSON.stringify({ email, password: pw }),
|
||||
});
|
||||
const cookie = login.headers
|
||||
.getSetCookie()
|
||||
.map((c) => c.split(';')[0])
|
||||
.join('; ');
|
||||
const api = async (method, url, body) => {
|
||||
const r = await fetch(`${base}${url}`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng', cookie },
|
||||
body: body && JSON.stringify(body),
|
||||
});
|
||||
const json = await r.json();
|
||||
assert.ok(r.ok, `${method} ${url}: ${r.status} ${JSON.stringify(json)}`);
|
||||
return json;
|
||||
};
|
||||
const waitUntil = async (fn, what, ms = 30_000) => {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
const v = await fn();
|
||||
if (v) return v;
|
||||
await sleep(300);
|
||||
}
|
||||
throw new Error(`timeout waiting for: ${what}`);
|
||||
};
|
||||
/** State of each PJSIP/<ext> channel: { '320': 'Up', ... }. */
|
||||
const states = () =>
|
||||
Object.fromEntries(
|
||||
cli('core show channels concise')
|
||||
.split('\n')
|
||||
.filter((l) => l.startsWith('PJSIP/'))
|
||||
.map((l) => l.split('!'))
|
||||
.map((f) => [f[0].slice(6).split('-')[0], f[4]]),
|
||||
);
|
||||
/** Paged participants (channel, flags) of every bridge Page() made. */
|
||||
const paged = () =>
|
||||
[...cli('confbridge list').matchAll(/^(\S+)\s+\d+/gm)]
|
||||
.map((m) => m[1])
|
||||
.filter((c) => c !== 'Conference')
|
||||
.flatMap((room) =>
|
||||
cli(`confbridge list ${room}`)
|
||||
.split('\n')
|
||||
.filter((l) => /^PJSIP\//.test(l))
|
||||
.map((l) => ({ channel: l.split(/\s+/)[0], flags: l.split(/\s+/)[1] ?? '' })),
|
||||
);
|
||||
/** INVITEs Asterisk sent to an extension since log offset `at`. */
|
||||
const invitesTo = (ext, at) =>
|
||||
astLog()
|
||||
.slice(at)
|
||||
.split(/<--- Transmitting SIP request/)
|
||||
.filter((m) => new RegExp(`^[^\\n]*\\n+INVITE sip:${ext}[-@]`).test(m));
|
||||
|
||||
const phoneList = [];
|
||||
const extIds = {};
|
||||
for (const [ext, name, answer] of [
|
||||
['320', 'Pat Page', 'manual'],
|
||||
['321', 'Pia Page', 'manual'],
|
||||
['322', 'Paul Pager', 'auto'],
|
||||
]) {
|
||||
const e = await api('POST', '/api/extensions', { number: ext, name });
|
||||
extIds[ext] = e.id;
|
||||
const pass = (await api('GET', `/api/extensions/${e.id}/sip-credentials`)).password;
|
||||
phoneList.push({ ext, password: pass, answer, intercom: answer === 'manual' });
|
||||
}
|
||||
const phones = startPhones({ name: 'sp-live-phones-m8t4', net: NET, ast: AST, phones: phoneList });
|
||||
|
||||
const p = await launch({ shotsDir });
|
||||
const check = (text) =>
|
||||
p.evaluate(`(() => {
|
||||
const l = [...document.querySelectorAll('dialog[open] label')].find(l => l.textContent.includes(${JSON.stringify(text)}));
|
||||
const box = l.querySelector('input[type=checkbox]');
|
||||
if (!box.checked) box.click();
|
||||
return true;
|
||||
})()`);
|
||||
try {
|
||||
await waitUntil(
|
||||
() =>
|
||||
['320', '321', '322'].every((e) =>
|
||||
new RegExp(`${e}/sip:.*Avail`).test(cli('pjsip show contacts')),
|
||||
),
|
||||
'phones',
|
||||
180_000,
|
||||
);
|
||||
await p.goto(`${base}/login`);
|
||||
await p.waitFor(`!!document.querySelector('input[type=email]')`);
|
||||
await p.fill('Email', email);
|
||||
await p.fill('Password', pw);
|
||||
await p.click('Sign in');
|
||||
await p.waitFor(`location.pathname === '/admin'`);
|
||||
|
||||
// Group 850 in the UI.
|
||||
await p.click('Paging');
|
||||
await p.click('Add paging group');
|
||||
await p.waitFor(`!!document.querySelector('dialog[open]')`);
|
||||
await p.waitFor(
|
||||
`[...document.querySelectorAll('dialog[open] label')].find(l => l.textContent === 'Number')?.control.value === '850'`,
|
||||
'next free paging number suggested',
|
||||
);
|
||||
await p.fill('Name', 'Warehouse', 'dialog[open]');
|
||||
for (const ext of ['320', '321', '322']) await check(`${ext} `);
|
||||
await p.click('Save', 'dialog[open]');
|
||||
await p.waitFor(
|
||||
`document.body.innerText.includes('One-way: Pat Page, Pia Page, Paul Pager')`,
|
||||
'saved',
|
||||
);
|
||||
await waitUntil(
|
||||
() => /sp-page-/.test(cli('dialplan show 850@sp-local-numbers')),
|
||||
'dialplan has 850',
|
||||
);
|
||||
await p.shot('m8-t4-01-paging');
|
||||
console.log('paging group 850 built in the UI');
|
||||
|
||||
// Control: an ordinary call to 320 rings and stays ringing.
|
||||
cli('channel originate PJSIP/322 extension 320@sp-from-ext');
|
||||
await waitUntil(() => states()['320'] === 'Ringing', '320 rings');
|
||||
await sleep(4000);
|
||||
assert.equal(states()['320'], 'Ringing', 'an ordinary call is not auto-answered');
|
||||
cli('channel request hangup all');
|
||||
await waitUntil(() => !Object.keys(states()).length, 'all hung up');
|
||||
console.log('ordinary call: 320 rings and does not answer by itself');
|
||||
|
||||
// 322 pages 850: 320 and 321 answer by themselves, muted; 322 isn't paged.
|
||||
cli('pjsip set logger on');
|
||||
let at = astLog().length;
|
||||
cli('channel originate PJSIP/322 extension 850@sp-from-ext');
|
||||
await waitUntil(() => paged().length === 3, '322 + 320 + 321 in the page', 20_000);
|
||||
const s = states();
|
||||
assert.ok(s['320'] === 'Up' && s['321'] === 'Up', `states: ${JSON.stringify(s)}`);
|
||||
const inv = invitesTo('320', at);
|
||||
assert.equal(inv.length, 1, 'one INVITE to 320');
|
||||
assert.match(inv[0], /Call-Info: <sip:[^>]*>;answer-after=0/);
|
||||
assert.match(inv[0], /Alert-Info: info=alert-autoanswer/);
|
||||
assert.equal(invitesTo('322', at).length, 1, '322 only got the INVITE that started its call');
|
||||
const flags = Object.fromEntries(paged().map((m) => [m.channel.slice(6, 9), m.flags]));
|
||||
console.log(`page 850: flags ${JSON.stringify(flags)}`);
|
||||
assert.ok(/m/.test(flags['320']) && /m/.test(flags['321']), 'paged phones are muted');
|
||||
assert.ok(!/m/.test(flags['322'] ?? ''), 'the pager talks');
|
||||
console.log('page 850: 320 and 321 answered by themselves, muted; 322 (busy) skipped');
|
||||
cli('channel request hangup all');
|
||||
await waitUntil(() => !Object.keys(states()).length, 'all hung up');
|
||||
|
||||
// *80320: two-way intercom.
|
||||
cli('channel originate PJSIP/322 extension *80320@sp-from-ext');
|
||||
await waitUntil(() => paged().length === 2, '322 + 320 in the intercom', 20_000);
|
||||
const iflags = Object.fromEntries(paged().map((m) => [m.channel.slice(6, 9), m.flags]));
|
||||
console.log(`*80320: flags ${JSON.stringify(iflags)}`);
|
||||
assert.ok(!/m/.test(iflags['320']), '320 can talk back');
|
||||
cli('channel request hangup all');
|
||||
await waitUntil(() => !Object.keys(states()).length, 'all hung up');
|
||||
console.log('*80320: 320 answered by itself and can talk back');
|
||||
|
||||
// *80 to something that isn't a desk phone.
|
||||
at = astLog().length;
|
||||
cli('channel originate PJSIP/322 extension *80999@sp-from-ext');
|
||||
await waitUntil(() => /Playing 'ss-noservice/.test(astLog().slice(at)), '*80999 refused');
|
||||
console.log('*80999: refused (ss-noservice)');
|
||||
cli('pjsip set logger off');
|
||||
console.log('M8-T4 (paging): PASS');
|
||||
} catch (e) {
|
||||
await p.shot('m8-t4-fail').catch(() => {});
|
||||
console.log(`FAIL: ${e.message}`);
|
||||
console.log(`confbridge: ${cli('confbridge list')}`);
|
||||
console.log(`channels: ${cli('core show channels concise')}`);
|
||||
console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
cli('pjsip set logger off');
|
||||
cli('channel request hangup all');
|
||||
await p.close();
|
||||
phones.stop();
|
||||
}
|
||||
@@ -12,7 +12,9 @@ const docker = (...a) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* phones: [{ ext, password, answer: 'auto' | 'manual', toneHz = 440 }]
|
||||
* phones: [{ ext, password, answer: 'auto' | 'manual', toneHz = 440, intercom = false }]
|
||||
* intercom: a manual phone still answers by itself when the INVITE asks it to (Call-Info
|
||||
* answer-after, Alert-Info auto-answer), like a Yealink or Grandstream.
|
||||
* Returns { log(ext) → text so far, stop() }.
|
||||
*/
|
||||
export function startPhones({ name, net, ast, phones }) {
|
||||
@@ -37,7 +39,7 @@ export function startPhones({ name, net, ast, phones }) {
|
||||
);
|
||||
writeFileSync(
|
||||
join(d, 'accounts'),
|
||||
`<sip:${p.ext}@${ast};transport=udp>;auth_pass=${p.password};answermode=${p.answer};audio_codecs=PCMU\n`,
|
||||
`<sip:${p.ext}@${ast};transport=udp>;auth_pass=${p.password};answermode=${p.answer}${p.intercom ? ';sip_autoanswer=yes;sip_autoanswer_beep=off' : ''};audio_codecs=PCMU\n`,
|
||||
);
|
||||
});
|
||||
const run = phones.map((p) => `(baresip -f /phones/${p.ext} > /phones/${p.ext}/log 2>&1 &)`);
|
||||
|
||||
Reference in new issue
Block a user