ringgroups: ring-all and hunt groups, API, page, golden case
/api/ring-groups CRUD with members as ordered memberIds (unique, must
exist), number from the ring-group range, a no-answer destination
(never itself), and delete refused while something points at it. The
generator renders sp-rg-{id} (Dial through sp-rg-member: all at once with
the total ring time, or one at a time with the per-member time, music
instead of ringing on request), adds the number to sp-local-numbers, and
destApp sends ring_group destinations there. Golden case ringgroups.
The DestinationPicker is now generic over targets picked by id and gains
"Voicemail of a user" and "Ring group" (M6-T6 completes it). Caller-name
prefixes (DIDs and ring groups) get a space before the caller's name.
Ring groups page with an ordered member list.
e2e m6-t2 found the new-group form failing on an unset no-answer
destination (fixed: defaults to hang up). It checks ring-all (both ring,
191 answers) and hunt (190 alone for 5 s, then 191) with real calls. The
generator debounce test now waits for the apply instead of a fixed 150 ms.
Task: M6-T2
This commit is contained in:
1 parent
17db68a8c5
commit
f87d4d47dd
35 files changed
+1604
-29
No files matched your search
@@ -22,6 +22,7 @@ import { outboundRouteRoutes } from './routes/outbound.ts';
|
||||
import { liveRoutes } from './live/hub.ts';
|
||||
import { meRoutes } from './routes/me.ts';
|
||||
import { soundRoutes } from './routes/sounds.ts';
|
||||
import { ringGroupRoutes } from './routes/ringgroups.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -95,6 +96,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
app.register(liveRoutes);
|
||||
app.register(meRoutes);
|
||||
app.register(soundRoutes);
|
||||
app.register(ringGroupRoutes);
|
||||
|
||||
// Always registered: reply.sendFile also streams voicemail audio (with Range) from the spool.
|
||||
app.register(fastifyStatic, {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
+7
@@ -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_101=2
|
||||
SP_CLASS_102=2
|
||||
SP_CLASS_103=2
|
||||
@@ -0,0 +1,206 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
; ─── Normalize a number. ARG1 = raw. Returns normalized in GOSUB_RETVAL. (matches 00 §8)
|
||||
[sp-sub-normalize]
|
||||
exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
same => n,ExecIf($["${N:0:2}" = "+1"]?Set(N=${N:1}))
|
||||
same => n,ExecIf($["${N:0:1}" = "+"]?Set(N=011${N:1}))
|
||||
same => n,ExecIf($[${LEN(${N})} = 10]?Set(N=1${N}))
|
||||
same => n,Return(${N})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
|
||||
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
|
||||
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
|
||||
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── Call a user with all their personal rules.
|
||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||
[sp-sub-user]
|
||||
exten => s,1,Set(__SP_TARGET=${ARG1})
|
||||
same => n,ExecIf($["${SP_DIRECTION}" = "inbound" & ("${ARG4}" = "inbound" | "${ARG4}" = "all")]?Gosub(sp-sub-record,s,1))
|
||||
same => n,GotoIf($["${DB(SP/DND/${ARG1})}" = "1"]?busy)
|
||||
same => n,Set(FWD=${DB(SP/CFU/${ARG1})})
|
||||
same => n,GotoIf($["${FWD}" != ""]?forward)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${ARG1},${ARG2}))
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
|
||||
same => n(noanswer),Set(FWD=${DB(SP/CFNA/${ARG1})})
|
||||
same => n,GotoIf($["${FWD}" != ""]?forward)
|
||||
same => n,GotoIf($["${ARG3}" = "1"]?vmu)
|
||||
same => n,Return()
|
||||
same => n(vmu),VoiceMail(${ARG1}@default,u)
|
||||
same => n,Return()
|
||||
same => n(busy),Set(FWD=${DB(SP/CFB/${ARG1})})
|
||||
same => n,GotoIf($["${FWD}" != ""]?forward)
|
||||
same => n,GotoIf($["${ARG3}" = "1"]?vmb)
|
||||
same => n,Busy(5)
|
||||
same => n,Return()
|
||||
same => n(vmb),VoiceMail(${ARG1}@default,b)
|
||||
same => n,Return()
|
||||
same => n(forward),Gosub(sp-sub-forward,s,1(${FWD},${ARG1}))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Forward a call. ARG1 = target number, ARG2 = ext whose forward rule this is.
|
||||
; Stops after 3 hops so 101→102→101 can't loop forever.
|
||||
[sp-sub-forward]
|
||||
exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} + 1])
|
||||
same => n,GotoIf($[${SP_FWDHOPS} > 3]?stop)
|
||||
same => n,GotoIf($[${DIALPLAN_EXISTS(sp-local-numbers,${ARG1},1)}]?internal)
|
||||
same => n,Set(SP_CLASS=${GLOBAL(SP_CLASS_${ARG2})})
|
||||
same => n,Set(__SP_FORWARDED=1)
|
||||
same => n,Goto(sp-outbound-routes,${ARG1},1)
|
||||
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
[sp-sub-autoanswer]
|
||||
exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=<sip:${SP_EXT}>\;answer-after=0)
|
||||
same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer)
|
||||
same => n,Return()
|
||||
|
||||
; ─── 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). M4: voicemail only; M6-T7 adds the rest.
|
||||
[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()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-ext-forward]
|
||||
exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external destination
|
||||
same => n,Set(__SP_FORWARDED=1)
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
include => sp-emergency-dial
|
||||
include => sp-featurecodes
|
||||
include => sp-local-numbers
|
||||
include => sp-outbound-routes
|
||||
|
||||
[sp-local-numbers]
|
||||
include => sp-parkedcalls
|
||||
|
||||
; users (sorted by number)
|
||||
exten => 101,1,Gosub(sp-sub-user,s,1(101,25,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
|
||||
exten => 103,1,Gosub(sp-sub-user,s,1(103,25,1,never))
|
||||
same => n,Hangup()
|
||||
exten => 103,hint,PJSIP/103
|
||||
|
||||
; ring groups
|
||||
exten => 500,1,Goto(sp-rg-22222222-2222-4222-8222-222222222500,s,1)
|
||||
exten => 501,1,Goto(sp-rg-22222222-2222-4222-8222-222222222501,s,1)
|
||||
exten => 502,1,Goto(sp-rg-22222222-2222-4222-8222-222222222502,s,1)
|
||||
|
||||
; ─── Ring group 500 "Sales" (ringall)
|
||||
[sp-rg-22222222-2222-4222-8222-222222222500]
|
||||
exten => s,1,Set(__SP_TARGET=500)
|
||||
same => n,Set(CALLERID(name)=Sales: ${CALLERID(name)})
|
||||
same => n,Dial(Local/101@sp-rg-member&Local/102@sp-rg-member&Local/103@sp-rg-member,25,tm)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
||||
same => n,Goto(sp-vm-direct,101,1)
|
||||
same => n(done),Hangup()
|
||||
|
||||
; ─── Ring group 501 "Support line" (hunt)
|
||||
[sp-rg-22222222-2222-4222-8222-222222222501]
|
||||
exten => s,1,Set(__SP_TARGET=501)
|
||||
same => n,Dial(Local/102@sp-rg-member,15,t)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
||||
same => n,Dial(Local/101@sp-rg-member,15,t)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
||||
same => n,Goto(sp-rg-22222222-2222-4222-8222-222222222500,s,1)
|
||||
same => n(done),Hangup()
|
||||
|
||||
; ─── Ring group 502 "Empty group test" (ringall)
|
||||
[sp-rg-22222222-2222-4222-8222-222222222502]
|
||||
exten => s,1,Set(__SP_TARGET=502)
|
||||
same => n,Goto(sp-ext-forward,13125550000,1)
|
||||
same => n(done),Hangup()
|
||||
|
||||
; ─── Calls from trunk "voip.ms"
|
||||
[sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
|
||||
exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1)
|
||||
exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN}))
|
||||
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
||||
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
|
||||
; Request-URI didn't hold one of our DIDs: try the To header (06 §3)
|
||||
same => n,Set(TOU=${CUT(CUT(PJSIP_HEADER(read,To),@,1),:,2)})
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${TOU}))
|
||||
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
||||
same => n(caller),Gosub(sp-sub-normalize,s,1(${CALLERID(num)}))
|
||||
same => n,Set(CALLERID(num)=${GOSUB_RETVAL})
|
||||
same => n,GotoIf($["${DB(SP/BLOCK/${CALLERID(num)})}" = "1"]?blocked)
|
||||
same => n,Goto(sp-inbound,${IF($["${SP_IN}" = ""]?unknown:${SP_IN})},1)
|
||||
same => n(blocked),Hangup(21)
|
||||
|
||||
[sp-did-exists]
|
||||
exten => 13125551234,1,NoOp()
|
||||
|
||||
[sp-inbound]
|
||||
; DID 13125551234
|
||||
exten => 13125551234,1,Set(__SP_DID=13125551234)
|
||||
same => n,Goto(sp-rg-22222222-2222-4222-8222-222222222500,s,1)
|
||||
; 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,Dial(PJSIP/911@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37)
|
||||
same => n,Hangup()
|
||||
@@ -0,0 +1 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
@@ -0,0 +1,262 @@
|
||||
; 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 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 (Sue Smith) ──
|
||||
[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 = "Sue Smith" <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 = Bb3dEf9hIj2kLm4nOp6qRs8t
|
||||
|
||||
[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 = "Sue Smith" <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 = Bb3dEf9hIj2kLm4nOp6qRs8t
|
||||
|
||||
[102-web]
|
||||
type = aor
|
||||
max_contacts = 3
|
||||
remove_existing = yes
|
||||
qualify_frequency = 0
|
||||
|
||||
; ── extension 103 (Ann Lee) ──
|
||||
[103]
|
||||
type = endpoint
|
||||
context = sp-from-ext
|
||||
disallow = all
|
||||
allow = g722,ulaw,alaw
|
||||
auth = 103-auth
|
||||
aors = 103
|
||||
direct_media = no
|
||||
rtp_symmetric = yes
|
||||
force_rport = yes
|
||||
rewrite_contact = yes
|
||||
send_pai = yes
|
||||
callerid = "Ann Lee" <103>
|
||||
mailboxes = 103@default
|
||||
mwi_subscribe_replaces_unsolicited = yes
|
||||
dtmf_mode = rfc4733
|
||||
allow_subscribe = yes
|
||||
language = en
|
||||
moh_suggest = default
|
||||
tone_zone = us
|
||||
set_var = SP_EXT=103
|
||||
set_var = SP_CLASS=2
|
||||
set_var = SP_RECMODE=never
|
||||
set_var = __SP_DIRECTION=internal
|
||||
|
||||
[103-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = 103
|
||||
password = Cb3dEf9hIj2kLm4nOp6qRs8t
|
||||
|
||||
[103]
|
||||
type = aor
|
||||
max_contacts = 5
|
||||
remove_existing = yes
|
||||
qualify_frequency = 30
|
||||
mailboxes = 103@default
|
||||
|
||||
; ── trunk "voip.ms" ──
|
||||
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = 123456_pbx
|
||||
password = TrunkPassw0rd
|
||||
|
||||
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-reg]
|
||||
type = registration
|
||||
transport = transport-udp
|
||||
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
|
||||
server_uri = sip:chicago1.voip.ms:5060
|
||||
client_uri = sip:123456_pbx@chicago1.voip.ms:5060
|
||||
contact_user = 123456_pbx
|
||||
retry_interval = 60
|
||||
forbidden_retry_interval = 600
|
||||
fatal_retry_interval = 600
|
||||
max_retries = 10000
|
||||
expiration = 300
|
||||
line = yes
|
||||
endpoint = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
|
||||
|
||||
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
|
||||
type = aor
|
||||
contact = sip:chicago1.voip.ms:5060
|
||||
qualify_frequency = 60
|
||||
|
||||
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
|
||||
type = endpoint
|
||||
transport = transport-udp
|
||||
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
|
||||
disallow = all
|
||||
allow = ulaw
|
||||
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
|
||||
aors = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
|
||||
from_user = 123456_pbx
|
||||
from_domain = chicago1.voip.ms
|
||||
direct_media = no
|
||||
rtp_symmetric = yes
|
||||
force_rport = yes
|
||||
rewrite_contact = yes
|
||||
send_rpid = yes
|
||||
trust_id_inbound = yes
|
||||
dtmf_mode = rfc4733
|
||||
|
||||
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-identify]
|
||||
type = identify
|
||||
endpoint = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
|
||||
match = chicago1.voip.ms
|
||||
@@ -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]
|
||||
101 => 4826,Dan Doe,,,tz=local|attach=no
|
||||
102 => 4826,Sue Smith,,,tz=local|attach=no
|
||||
103 => 4826,Ann Lee,,,tz=local|attach=no
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"extensions": [
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000101",
|
||||
"number": "101",
|
||||
"name": "Dan Doe",
|
||||
"sipPassword": "Ab3dEf9hIj2kLm4nOp6qRs8t"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000102",
|
||||
"number": "102",
|
||||
"name": "Sue Smith",
|
||||
"sipPassword": "Bb3dEf9hIj2kLm4nOp6qRs8t"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000103",
|
||||
"number": "103",
|
||||
"name": "Ann Lee",
|
||||
"sipPassword": "Cb3dEf9hIj2kLm4nOp6qRs8t",
|
||||
"webrtcEnabled": false
|
||||
}
|
||||
],
|
||||
"ringGroups": [
|
||||
{
|
||||
"id": "22222222-2222-4222-8222-222222222500",
|
||||
"number": "500",
|
||||
"name": "Sales",
|
||||
"members": ["101", "102", "103"],
|
||||
"callerIdNamePrefix": "Sales:",
|
||||
"musicInsteadOfRing": true,
|
||||
"noAnswerDestination": { "type": "voicemail", "id": "00000000-0000-4000-8000-000000000101" }
|
||||
},
|
||||
{
|
||||
"id": "22222222-2222-4222-8222-222222222501",
|
||||
"number": "501",
|
||||
"name": "Support line",
|
||||
"strategy": "hunt",
|
||||
"ringSeconds": 15,
|
||||
"members": ["102", "101"],
|
||||
"noAnswerDestination": { "type": "ring_group", "id": "22222222-2222-4222-8222-222222222500" }
|
||||
},
|
||||
{
|
||||
"id": "22222222-2222-4222-8222-222222222502",
|
||||
"number": "502",
|
||||
"name": "Empty \"group\"; test",
|
||||
"noAnswerDestination": { "type": "external", "number": "13125550000" }
|
||||
}
|
||||
],
|
||||
"dids": [
|
||||
{
|
||||
"id": "11111111-1111-4111-8111-111111111111",
|
||||
"number": "13125551234",
|
||||
"destination": { "type": "ring_group", "id": "22222222-2222-4222-8222-222222222500" }
|
||||
}
|
||||
],
|
||||
"trunks": [{ "id": "7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37" }]
|
||||
}
|
||||
@@ -154,7 +154,7 @@ exten => 13125551234,1,Set(__SP_DID=13125551234)
|
||||
same => n,Goto(sp-local-numbers,101,1)
|
||||
; DID 13125559876 "Sales line"
|
||||
exten => 13125559876,1,Set(__SP_DID=13125559876)
|
||||
same => n,Set(CALLERID(name)=SALES:${CALLERID(name)})
|
||||
same => n,Set(CALLERID(name)=SALES: ${CALLERID(name)})
|
||||
same => n,Goto(sp-ext-forward,13125550000,1)
|
||||
; anything else
|
||||
exten => _[0-9a-z].,1,Hangup()
|
||||
|
||||
@@ -17,8 +17,12 @@ export function destApp(d: Destination, snap: Snapshot): string {
|
||||
return `Goto(sp-ext-forward,${s.dialable(d.number)},1)`;
|
||||
case 'hangup':
|
||||
return 'Hangup()';
|
||||
case 'ring_group':
|
||||
return snap.ringGroups.some((g) => g.id === d.id)
|
||||
? `Goto(sp-rg-${s.ident(d.id)},s,1)`
|
||||
: 'Hangup()';
|
||||
default:
|
||||
// TODO(M6, M8): ring groups, queues, IVRs, time conditions, conferences, announcements.
|
||||
// TODO(M6, M8): queues, IVRs, time conditions, conferences, announcements.
|
||||
return 'Hangup()';
|
||||
}
|
||||
}
|
||||
@@ -69,7 +73,7 @@ export function renderDids(snap: Snapshot): string {
|
||||
inbound.push(
|
||||
`; DID ${num}${d.label ? ` "${s.name(d.label)}"` : ''}`,
|
||||
`exten => ${num},1,Set(__SP_DID=${num})`,
|
||||
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix}\${CALLERID(name)})`] : []),
|
||||
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []),
|
||||
// TODO(M7): record_inbound (announcement + sp-sub-record).
|
||||
` same => n,${destApp(d.destination, snap)}`,
|
||||
);
|
||||
|
||||
@@ -21,7 +21,10 @@ export function renderLocalNumbers(snap: Snapshot): string {
|
||||
`exten => ${num},hint,PJSIP/${num}${e.webrtcEnabled ? `&PJSIP/${num}-web` : ''}`,
|
||||
);
|
||||
}
|
||||
// M6/M8: ring groups, queues, IVRs, conferences and paging groups add one line each here.
|
||||
if (snap.ringGroups.length) lines.push('', '; ring groups');
|
||||
for (const g of [...snap.ringGroups].sort(byNumber))
|
||||
lines.push(`exten => ${s.digits(g.number)},1,Goto(sp-rg-${s.ident(g.id)},s,1)`);
|
||||
// M6/M8: queues, IVRs, conferences and paging groups add one line each here.
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Ring groups (03 §6.7). Members ring through sp-rg-member, which skips DND and ignores the
|
||||
// member's forwarding and voicemail on purpose; unanswered calls go to the group's destination.
|
||||
import { byNumber, type Snapshot } from '../load.ts';
|
||||
import * as s from '../sanitize.ts';
|
||||
import { destApp } from './inbound.ts';
|
||||
|
||||
export function renderRingGroups(snap: Snapshot): string {
|
||||
return [...snap.ringGroups]
|
||||
.sort(byNumber)
|
||||
.map((g) => {
|
||||
const num = s.digits(g.number);
|
||||
const secs = s.digits(String(g.ringSeconds));
|
||||
const opts = g.musicInsteadOfRing ? 'tm' : 't';
|
||||
const dial = (members: string[]) =>
|
||||
[
|
||||
` same => n,Dial(${members.map((m) => `Local/${s.digits(m)}@sp-rg-member`).join('&')},${secs},${opts})`,
|
||||
' same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)',
|
||||
].join('\n');
|
||||
const prefix = g.callerIdNamePrefix ? s.name(g.callerIdNamePrefix) : '';
|
||||
return [
|
||||
`; ─── Ring group ${num} "${s.name(g.name)}" (${g.strategy})`,
|
||||
`[sp-rg-${s.ident(g.id)}]`,
|
||||
`exten => s,1,Set(__SP_TARGET=${num})`,
|
||||
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []),
|
||||
...(g.members.length === 0
|
||||
? []
|
||||
: g.strategy === 'ringall'
|
||||
? [dial(g.members)]
|
||||
: g.members.map((m) => dial([m]))),
|
||||
` same => n,${destApp(g.noAnswerDestination, snap)}`,
|
||||
' same => n(done),Hangup()',
|
||||
'',
|
||||
].join('\n');
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export function loadCase(name: string): Snapshot {
|
||||
dids?: Partial<Snapshot['dids'][number]>[];
|
||||
trunks?: Partial<Snapshot['trunks'][number]>[];
|
||||
routes?: Partial<Snapshot['routes'][number]>[];
|
||||
ringGroups?: Partial<Snapshot['ringGroups'][number]>[];
|
||||
};
|
||||
const settings = Object.fromEntries(
|
||||
(Object.keys(SettingsSchemas) as SettingKey[])
|
||||
@@ -35,6 +36,18 @@ export function loadCase(name: string): Snapshot {
|
||||
id: d.id!,
|
||||
number: d.number!,
|
||||
})),
|
||||
ringGroups: (raw.ringGroups ?? []).map((g, i) => ({
|
||||
name: `Group ${i + 1}`,
|
||||
strategy: 'ringall',
|
||||
ringSeconds: 25,
|
||||
callerIdNamePrefix: null,
|
||||
musicInsteadOfRing: false,
|
||||
members: [],
|
||||
noAnswerDestination: { type: 'hangup' as const },
|
||||
...g,
|
||||
id: g.id!,
|
||||
number: g.number!,
|
||||
})),
|
||||
routes: (raw.routes ?? []).map((r, i) => ({
|
||||
name: `Route ${i + 1}`,
|
||||
position: i,
|
||||
|
||||
@@ -21,6 +21,7 @@ describe('golden', () => {
|
||||
'webrtc-disabled',
|
||||
'hostile-names',
|
||||
'voipms-basic',
|
||||
'ringgroups',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { renderGlobals, renderLocalNumbers } from './dialplan/local.ts';
|
||||
import { renderDids, renderTrunkEntries } from './dialplan/inbound.ts';
|
||||
import { renderOutbound } from './dialplan/outbound.ts';
|
||||
import { renderEmergency } from './dialplan/emergency.ts';
|
||||
import { renderRingGroups } from './dialplan/ringgroups.ts';
|
||||
import { renderVoicemail } from './voicemail.ts';
|
||||
|
||||
export { renderTransports };
|
||||
@@ -48,6 +49,7 @@ const BODIES: Partial<Record<GeneratedFileName, (snap: Snapshot) => string>> = {
|
||||
[
|
||||
STATIC_DIALPLAN,
|
||||
renderLocalNumbers(snap),
|
||||
renderRingGroups(snap),
|
||||
renderTrunkEntries(snap),
|
||||
renderDids(snap),
|
||||
renderOutbound(snap),
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Everything render() needs, loaded in one go (03 §1). render() itself does no I/O.
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { dids, extensions, outboundRoutes, trunks, users } from '../db/schema.ts';
|
||||
import {
|
||||
dids,
|
||||
extensions,
|
||||
outboundRoutes,
|
||||
ringGroupMembers,
|
||||
ringGroups,
|
||||
trunks,
|
||||
users,
|
||||
} from '../db/schema.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { getAllSettings } from '../services/settings.ts';
|
||||
import type { Destination, OutboundClass, RoutePattern } from '@selfphoneng/shared';
|
||||
@@ -65,6 +73,19 @@ export type SnapTrunk = {
|
||||
maxChannels: number | null;
|
||||
};
|
||||
|
||||
export type SnapRingGroup = {
|
||||
id: string;
|
||||
number: string;
|
||||
name: string;
|
||||
strategy: 'ringall' | 'hunt';
|
||||
ringSeconds: number;
|
||||
callerIdNamePrefix: string | null;
|
||||
musicInsteadOfRing: boolean;
|
||||
/** Extension numbers in ring order. */
|
||||
members: string[];
|
||||
noAnswerDestination: Destination;
|
||||
};
|
||||
|
||||
export type Snapshot = {
|
||||
/** "YYYY-MM-DD" in general.timezone; render() never reads the clock. */
|
||||
today: string;
|
||||
@@ -73,6 +94,7 @@ export type Snapshot = {
|
||||
dids: SnapDid[];
|
||||
trunks: SnapTrunk[];
|
||||
routes: SnapRoute[];
|
||||
ringGroups: SnapRingGroup[];
|
||||
};
|
||||
|
||||
export function todayIn(timezone: string, now = new Date()): string {
|
||||
@@ -108,11 +130,28 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
|
||||
.from(dids);
|
||||
const trunkRows = await db.select().from(trunks);
|
||||
const routeRows = await db.select().from(outboundRoutes);
|
||||
const groupRows = await db.select().from(ringGroups);
|
||||
const memberRows = await db
|
||||
.select({ groupId: ringGroupMembers.ringGroupId, number: extensions.number })
|
||||
.from(ringGroupMembers)
|
||||
.innerJoin(extensions, eq(extensions.id, ringGroupMembers.extensionId))
|
||||
.orderBy(asc(ringGroupMembers.position));
|
||||
const didNumber = new Map(didRows.map((d) => [d.id, d.number]));
|
||||
return {
|
||||
today: todayIn(settings['general.timezone'], now),
|
||||
settings,
|
||||
dids: didRows.map((d) => ({ ...d, destination: d.destination as Destination })),
|
||||
ringGroups: groupRows.map((g) => ({
|
||||
id: g.id,
|
||||
number: g.number,
|
||||
name: g.name,
|
||||
strategy: g.strategy,
|
||||
ringSeconds: g.ringSeconds,
|
||||
callerIdNamePrefix: g.callerIdNamePrefix,
|
||||
musicInsteadOfRing: g.musicInsteadOfRing,
|
||||
members: memberRows.filter((m) => m.groupId === g.id).map((m) => m.number),
|
||||
noAnswerDestination: g.noAnswerDestination as Destination,
|
||||
})),
|
||||
routes: routeRows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/ring-groups (04 §7.3): members are sent as `memberIds` in ring order. Admin only.
|
||||
import { asc, eq, inArray } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
RingGroupCreateSchema,
|
||||
RingGroupUpdateSchema,
|
||||
type Destination,
|
||||
type ListResponse,
|
||||
type RingGroupDto,
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { extensions, ringGroupMembers, ringGroups } from '../db/schema.ts';
|
||||
import { badRequest, conflict, notFound } from '../errors.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import {
|
||||
assertDestinationExists,
|
||||
describeDestination,
|
||||
findReferences,
|
||||
} from '../services/destinations.ts';
|
||||
import { assertNumberAvailable } from '../services/numbering.ts';
|
||||
|
||||
type Row = typeof ringGroups.$inferSelect;
|
||||
|
||||
async function memberIdsOf(db: Db, id: string): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ id: ringGroupMembers.extensionId })
|
||||
.from(ringGroupMembers)
|
||||
.where(eq(ringGroupMembers.ringGroupId, id))
|
||||
.orderBy(asc(ringGroupMembers.position));
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
async function toDto(db: Db, g: Row): Promise<RingGroupDto> {
|
||||
const noAnswerDestination = g.noAnswerDestination as Destination;
|
||||
return {
|
||||
id: g.id,
|
||||
number: g.number,
|
||||
name: g.name,
|
||||
strategy: g.strategy,
|
||||
ringSeconds: g.ringSeconds,
|
||||
callerIdNamePrefix: g.callerIdNamePrefix,
|
||||
musicInsteadOfRing: g.musicInsteadOfRing,
|
||||
memberIds: await memberIdsOf(db, g.id),
|
||||
noAnswerDestination,
|
||||
noAnswerSummary: await describeDestination(db, noAnswerDestination),
|
||||
createdAt: g.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const IdParams = z.object({ id: z.uuid() });
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
/** Replaces the member list (position = index). */
|
||||
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
|
||||
|
||||
async function setMembers(db: Db | Tx, id: string, ids: string[]) {
|
||||
await db.delete(ringGroupMembers).where(eq(ringGroupMembers.ringGroupId, id));
|
||||
if (ids.length)
|
||||
await db
|
||||
.insert(ringGroupMembers)
|
||||
.values(ids.map((extensionId, position) => ({ ringGroupId: id, extensionId, position })));
|
||||
}
|
||||
|
||||
const isUniqueViolation = (e: unknown) =>
|
||||
((e as { cause?: { code?: string } }).cause?.code ?? (e as { code?: string }).code) === '23505';
|
||||
|
||||
export async function ringGroupRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
|
||||
async function getGroup(id: string) {
|
||||
const [g] = await db.select().from(ringGroups).where(eq(ringGroups.id, id));
|
||||
if (!g) throw notFound('Ring group not found');
|
||||
return g;
|
||||
}
|
||||
|
||||
app.get('/api/ring-groups', async (): Promise<ListResponse<RingGroupDto>> => {
|
||||
const rows = await db.select().from(ringGroups).orderBy(asc(ringGroups.number));
|
||||
return { items: await Promise.all(rows.map((g) => toDto(db, g))), nextCursor: null };
|
||||
});
|
||||
|
||||
app.get('/api/ring-groups/:id', async (req) =>
|
||||
toDto(db, await getGroup(IdParams.parse(req.params).id)),
|
||||
);
|
||||
|
||||
app.post('/api/ring-groups', async (req, reply) => {
|
||||
const { memberIds = [], ...body } = RingGroupCreateSchema.parse(req.body);
|
||||
await assertNumberAvailable(db, body.number, 'ringGroup');
|
||||
await assertDestinationExists(db, body.noAnswerDestination);
|
||||
await assertMembers(db, memberIds);
|
||||
let created: Row;
|
||||
try {
|
||||
created = await db.transaction(async (tx) => {
|
||||
const [g] = (await tx.insert(ringGroups).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: 'ring_group',
|
||||
entityId: created.id,
|
||||
summary: `Created ring 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/ring-groups/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const { memberIds, ...body } = RingGroupUpdateSchema.parse(req.body);
|
||||
const before = await getGroup(id);
|
||||
if (body.number && body.number !== before.number)
|
||||
await assertNumberAvailable(db, body.number, 'ringGroup', id);
|
||||
if (body.noAnswerDestination) {
|
||||
if (body.noAnswerDestination.type === 'ring_group' && body.noAnswerDestination.id === id)
|
||||
throw badRequest("A ring group can't send unanswered calls to itself");
|
||||
await assertDestinationExists(db, body.noAnswerDestination);
|
||||
}
|
||||
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(ringGroups).set(body).where(eq(ringGroups.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: 'ring_group',
|
||||
entityId: id,
|
||||
summary: `Updated ring group ${after.number} (${after.name})`,
|
||||
diff: { after: { ...body, ...(memberIds ? { memberIds } : {}) } },
|
||||
});
|
||||
app.generator.requestApply('api-change');
|
||||
return toDto(db, after);
|
||||
});
|
||||
|
||||
app.delete('/api/ring-groups/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const g = await getGroup(id);
|
||||
const usedBy = await findReferences(db, 'ring_group', id);
|
||||
if (usedBy.length)
|
||||
throw conflict(`Ring group ${g.name} is still used by ${usedBy.join(', ')}`, { usedBy });
|
||||
await db.delete(ringGroups).where(eq(ringGroups.id, id));
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'delete',
|
||||
entityType: 'ring_group',
|
||||
entityId: id,
|
||||
summary: `Deleted ring group ${g.number} (${g.name})`,
|
||||
diff: { before: g },
|
||||
});
|
||||
app.generator.requestApply('api-change');
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -64,6 +64,7 @@ function snapshot(): Snapshot {
|
||||
dids: [],
|
||||
trunks: [],
|
||||
routes: [],
|
||||
ringGroups: [],
|
||||
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ function snapshot(): Snapshot {
|
||||
},
|
||||
],
|
||||
trunks: [trunk(GOOD, 'voip.ms', SUB.pass), trunk(BAD, 'wrong password', 'NotThePassword1')],
|
||||
ringGroups: [],
|
||||
routes: [
|
||||
{
|
||||
id: 'aaaaaaaa-0000-4000-8000-000000000001',
|
||||
|
||||
@@ -59,6 +59,7 @@ function snapshot(): Snapshot {
|
||||
dids: [],
|
||||
trunks: [],
|
||||
routes: [],
|
||||
ringGroups: [],
|
||||
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ describe.skipIf(!hasTestDb)('DIDs API', () => {
|
||||
expect(bad.statusCode).toBe(400);
|
||||
await req('PATCH', `/api/dids/${didId}`, { callerIdNamePrefix: 'SALES:' });
|
||||
await settle();
|
||||
expect(dialplan()).toContain(' same => n,Set(CALLERID(name)=SALES:${CALLERID(name)})');
|
||||
expect(dialplan()).toContain(' same => n,Set(CALLERID(name)=SALES: ${CALLERID(name)})');
|
||||
});
|
||||
|
||||
it('a number that is the emergency caller ID cannot be removed', async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { asc } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Db } from '../src/db/client.ts';
|
||||
import { generatorRuns } from '../src/db/schema.ts';
|
||||
import {
|
||||
@@ -154,6 +154,8 @@ describe.skipIf(!hasTestDb)('generator apply', () => {
|
||||
|
||||
applied.length = 0;
|
||||
for (let i = 0; i < 5; i++) gen.requestApply('api-change');
|
||||
// Wait for the apply (slow under a full parallel run), then make sure no second one follows.
|
||||
await vi.waitFor(() => expect(applied.length).toBeGreaterThan(0), { timeout: 5000 });
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
expect(applied).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M6-T2: /api/ring-groups (03 §6.7, 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/ring-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 [n, name] of [
|
||||
['101', 'Dan Doe'],
|
||||
['102', 'Sue Smith'],
|
||||
] as const)
|
||||
ext[n] = (await req('POST', '/api/extensions', { number: n, name })).json().id;
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
it('creates a group with ordered members; the dialplan rings them', async () => {
|
||||
const r = await req('POST', '/api/ring-groups', {
|
||||
number: '500',
|
||||
name: 'Sales',
|
||||
memberIds: [ext['102'], ext['101']],
|
||||
noAnswerDestination: { type: 'voicemail', id: ext['101'] },
|
||||
});
|
||||
expect(r.statusCode, r.body).toBe(201);
|
||||
groupId = r.json().id;
|
||||
expect(r.json()).toMatchObject({
|
||||
strategy: 'ringall',
|
||||
ringSeconds: 25,
|
||||
memberIds: [ext['102'], ext['101']],
|
||||
noAnswerSummary: 'Voicemail of Dan Doe (101)',
|
||||
});
|
||||
const conf = await dialplan();
|
||||
expect(conf).toContain(`exten => 500,1,Goto(sp-rg-${groupId},s,1)`);
|
||||
expect(conf).toContain('Dial(Local/102@sp-rg-member&Local/101@sp-rg-member,25,t)');
|
||||
});
|
||||
|
||||
it('checks the number, members and destination', async () => {
|
||||
const bad = (body: object) =>
|
||||
req('POST', '/api/ring-groups', {
|
||||
number: '501',
|
||||
name: 'X',
|
||||
noAnswerDestination: { type: 'hangup' },
|
||||
...body,
|
||||
});
|
||||
expect((await bad({ number: '101' })).statusCode).toBe(400); // outside 500–549
|
||||
expect((await bad({ number: '500' })).statusCode).toBe(409);
|
||||
expect((await bad({ memberIds: [ext['101'], ext['101']] })).statusCode).toBe(400);
|
||||
expect((await bad({ memberIds: ['00000000-0000-4000-8000-000000000999'] })).statusCode).toBe(
|
||||
400,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await bad({
|
||||
noAnswerDestination: { type: 'ivr', id: '00000000-0000-4000-8000-000000000999' },
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(
|
||||
await req('PATCH', `/api/ring-groups/${groupId}`, {
|
||||
noAnswerDestination: { type: 'ring_group', id: groupId },
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it('hunt: one member at a time; reorder via PATCH', async () => {
|
||||
const r = await req('PATCH', `/api/ring-groups/${groupId}`, {
|
||||
strategy: 'hunt',
|
||||
ringSeconds: 15,
|
||||
memberIds: [ext['101'], ext['102']],
|
||||
});
|
||||
expect(r.statusCode, r.body).toBe(200);
|
||||
const conf = await dialplan();
|
||||
expect(conf.indexOf('Dial(Local/101@sp-rg-member,15,t)')).toBeLessThan(
|
||||
conf.indexOf('Dial(Local/102@sp-rg-member,15,t)'),
|
||||
);
|
||||
});
|
||||
|
||||
it('a phone number can point at it; delete is refused while it does', async () => {
|
||||
const trunkId = (
|
||||
await req('POST', '/api/trunks', {
|
||||
name: 'voip.ms',
|
||||
serverHost: '127.0.0.1',
|
||||
username: '123456_pbx',
|
||||
password: 'pw',
|
||||
})
|
||||
).json().id;
|
||||
const did = await req('POST', '/api/dids', {
|
||||
number: '3125551234',
|
||||
trunkId,
|
||||
destination: { type: 'ring_group', id: groupId },
|
||||
});
|
||||
expect(did.json().destinationSummary).toBe('Ring group Sales');
|
||||
expect(await dialplan()).toContain(`Goto(sp-rg-${groupId},s,1)`);
|
||||
const busy = await req('DELETE', `/api/ring-groups/${groupId}`);
|
||||
expect(busy.statusCode).toBe(409);
|
||||
await req('PATCH', `/api/dids/${did.json().id}`, { destination: { type: 'hangup' } });
|
||||
expect((await req('DELETE', `/api/ring-groups/${groupId}`)).statusCode).toBe(200);
|
||||
expect(await dialplan()).not.toContain(`sp-rg-${groupId}`);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,9 @@ import type {
|
||||
OutboundRouteCreate,
|
||||
OutboundRouteDto,
|
||||
OutboundRouteUpdate,
|
||||
RingGroupCreate,
|
||||
RingGroupDto,
|
||||
RingGroupUpdate,
|
||||
RouteTestResult,
|
||||
SettingsDto,
|
||||
SipCredentials,
|
||||
@@ -194,3 +197,11 @@ export const soundsApi = {
|
||||
rename: (id: string, name: string) => api<SoundDto>('PATCH', `/api/sounds/${id}`, { name }),
|
||||
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/sounds/${id}`),
|
||||
};
|
||||
|
||||
export const ringGroupsApi = {
|
||||
list: () => api<ListResponse<RingGroupDto>>('GET', '/api/ring-groups').then((r) => r.items),
|
||||
create: (g: RingGroupCreate) => api<RingGroupDto>('POST', '/api/ring-groups', g),
|
||||
update: (id: string, g: RingGroupUpdate) =>
|
||||
api<RingGroupDto>('PATCH', `/api/ring-groups/${id}`, g),
|
||||
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/ring-groups/${id}`),
|
||||
};
|
||||
@@ -1,38 +1,60 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// "Where should the call go?" (05 §3). M3: a user, an outside number, or hang up. Voicemail (M4),
|
||||
// ring groups, queues, IVRs, office hours, conferences and announcements join later (M6-T6).
|
||||
// "Where should the call go?" (05 §3): a type, then the target (or a number).
|
||||
// Types join as their features land; M6-T6 finishes the list.
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useId, useState } from 'react';
|
||||
import { normalizeNumber, type Destination } from '@selfphoneng/shared';
|
||||
import { extensionsApi } from '../api/client.ts';
|
||||
import { extensionsApi, ringGroupsApi } from '../api/client.ts';
|
||||
import { inputClass } from './ui.tsx';
|
||||
|
||||
type Kind = 'user' | 'voicemail' | 'external' | 'hangup';
|
||||
type IdKind = 'user' | 'voicemail' | 'ring_group';
|
||||
type Kind = IdKind | 'external' | 'hangup';
|
||||
|
||||
const KINDS: { kind: Kind; label: string }[] = [
|
||||
{ kind: 'user', label: 'Ring a user' },
|
||||
{ kind: 'voicemail', label: 'Voicemail of a user' },
|
||||
{ kind: 'ring_group', label: 'Ring group' },
|
||||
{ kind: 'external', label: 'Forward to an outside number' },
|
||||
{ kind: 'hangup', label: 'Hang up' },
|
||||
];
|
||||
const isIdKind = (k: Kind): k is IdKind => k !== 'external' && k !== 'hangup';
|
||||
/** Kinds whose targets are the same rows: switching between them keeps the pick. */
|
||||
const family = (k: Kind) => (k === 'voicemail' ? 'user' : k);
|
||||
|
||||
export function DestinationPicker({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
exclude,
|
||||
}: {
|
||||
label: string;
|
||||
value: Destination;
|
||||
onChange: (d: Destination | null) => void; // null = incomplete (e.g. half-typed number)
|
||||
error?: string;
|
||||
/** The id of the thing being edited, so it can't send calls to itself. */
|
||||
exclude?: string;
|
||||
}) {
|
||||
const id = useId();
|
||||
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
|
||||
const groups = useQuery({ queryKey: ['ring-groups'], queryFn: ringGroupsApi.list });
|
||||
const [kind, setKind] = useState<Kind>(value.type as Kind);
|
||||
const [number, setNumber] = useState(value.type === 'external' ? value.number : '');
|
||||
const [numberError, setNumberError] = useState<string>();
|
||||
const extId = value.type === 'user' || value.type === 'voicemail' ? value.id : '';
|
||||
const targetId = 'id' in value ? value.id : '';
|
||||
|
||||
const targets = (k: IdKind) =>
|
||||
(k === 'ring_group'
|
||||
? (groups.data ?? []).map((g) => ({ id: g.id, label: `${g.number} ${g.name}` }))
|
||||
: (exts.data ?? []).map((x) => ({ id: x.id, label: `${x.number} ${x.name}` }))
|
||||
).filter((t) => t.id !== exclude);
|
||||
|
||||
function pickKind(k: Kind) {
|
||||
setKind(k);
|
||||
if (k === 'hangup') onChange({ type: 'hangup' });
|
||||
else if (k === 'external') typed(number);
|
||||
else if (extId) onChange({ type: k, id: extId });
|
||||
else onChange(null); // the admin picks the user: never guess
|
||||
else if (targetId && family(k) === family(kind)) onChange({ type: k, id: targetId });
|
||||
else onChange(null); // the admin picks the target: never guess
|
||||
}
|
||||
|
||||
function typed(v: string) {
|
||||
@@ -46,6 +68,7 @@ export function DestinationPicker({
|
||||
}
|
||||
}
|
||||
|
||||
const what = KINDS.find((k) => k.kind === kind)?.label.toLowerCase() ?? '';
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-2" aria-describedby={error ? `${id}-err` : undefined}>
|
||||
<legend className="mb-1 text-sm font-medium">{label}</legend>
|
||||
@@ -55,21 +78,23 @@ export function DestinationPicker({
|
||||
onChange={(e) => pickKind(e.target.value as Kind)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="user">Ring a user</option>
|
||||
<option value="external">Forward to an outside number</option>
|
||||
<option value="hangup">Hang up</option>
|
||||
{KINDS.map((k) => (
|
||||
<option key={k.kind} value={k.kind}>
|
||||
{k.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(kind === 'user' || kind === 'voicemail') && (
|
||||
{isIdKind(kind) && (
|
||||
<select
|
||||
aria-label={`${label}: user`}
|
||||
value={extId}
|
||||
aria-label={`${label}: ${kind === 'ring_group' ? 'ring group' : 'user'}`}
|
||||
value={targetId}
|
||||
onChange={(e) => onChange({ type: kind, id: e.target.value })}
|
||||
className={inputClass}
|
||||
>
|
||||
{!extId && <option value="">Pick a user…</option>}
|
||||
{exts.data?.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.number} {x.name}
|
||||
{!targetId && <option value="">Pick one…</option>}
|
||||
{targets(kind).map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -90,9 +115,11 @@ export function DestinationPicker({
|
||||
{numberError ?? error}
|
||||
</p>
|
||||
)}
|
||||
{exts.data?.length === 0 && (kind === 'user' || kind === 'voicemail') && (
|
||||
<p className="text-xs text-zinc-500">No users with an extension yet.</p>
|
||||
)}
|
||||
{isIdKind(kind) &&
|
||||
(kind === 'ring_group' ? groups : exts).data &&
|
||||
targets(kind).length === 0 && (
|
||||
<p className="text-xs text-zinc-500">Nothing to pick yet ({what}).</p>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { NumbersPage } from './pages/admin/NumbersPage.tsx';
|
||||
import { OutboundPage } from './pages/admin/OutboundPage.tsx';
|
||||
import { SettingsPage } from './pages/admin/SettingsPage.tsx';
|
||||
import { SoundsPage } from './pages/admin/SoundsPage.tsx';
|
||||
import { RingGroupsPage } from './pages/admin/RingGroupsPage.tsx';
|
||||
import { MePage } from './pages/user/MePage.tsx';
|
||||
import { SetupPage } from './pages/SetupPage.tsx';
|
||||
import './index.css';
|
||||
@@ -44,6 +45,8 @@ const router = createBrowserRouter([
|
||||
<TrunksPage />
|
||||
) : p.path === 'sounds' ? (
|
||||
<SoundsPage />
|
||||
) : p.path === 'ring-groups' ? (
|
||||
<RingGroupsPage />
|
||||
) : (
|
||||
<ComingSoon label={p.label} milestone={p.milestone ?? ''} />
|
||||
),
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ADMIN_PAGES = [
|
||||
{ path: 'trunks', label: 'Trunks', milestone: null },
|
||||
{ path: 'numbers', label: 'Phone numbers', milestone: null },
|
||||
{ path: 'outbound', label: 'Outbound rules', milestone: null },
|
||||
{ path: 'ring-groups', label: 'Ring groups', milestone: 'M6' },
|
||||
{ path: 'ring-groups', label: 'Ring groups', milestone: null },
|
||||
{ path: 'queues', label: 'Queues', milestone: 'M8' },
|
||||
{ path: 'ivrs', label: 'Digital receptionist', milestone: 'M6' },
|
||||
{ path: 'hours', label: 'Office hours', milestone: 'M6' },
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Ring groups (05 §2, 03 §6.7): one number that rings several people, everyone at once or one at
|
||||
// a time, then goes somewhere else when nobody answers.
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
RingGroupCreateSchema,
|
||||
type Destination,
|
||||
type RingGroupCreate,
|
||||
type RingGroupDto,
|
||||
} from '@selfphoneng/shared';
|
||||
import { extensionsApi, ringGroupsApi } from '../../api/client.ts';
|
||||
import { DestinationPicker } from '../../components/DestinationPicker.tsx';
|
||||
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
|
||||
export function RingGroupsPage() {
|
||||
const qc = useQueryClient();
|
||||
const groups = useQuery({ queryKey: ['ring-groups'], queryFn: ringGroupsApi.list });
|
||||
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
|
||||
const [editing, setEditing] = useState<RingGroupDto | 'new' | null>(null);
|
||||
const [deleting, setDeleting] = useState<RingGroupDto | null>(null);
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['ring-groups'] });
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => ringGroupsApi.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">Ring groups</h1>
|
||||
<Button variant="primary" onClick={() => setEditing('new')}>
|
||||
Add ring group
|
||||
</Button>
|
||||
</div>
|
||||
{groups.isError && <Alert>{groups.error.message}</Alert>}
|
||||
{groups.data?.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
No ring groups yet. A ring group is one number (like 500 for Sales) that rings several
|
||||
people.
|
||||
</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.strategy === 'ringall' ? 'Everyone at once' : 'One at a time'}:{' '}
|
||||
{g.memberIds.map(nameOf).join(', ') || 'nobody yet'} · then {g.noAnswerSummary}
|
||||
</span>
|
||||
<Button onClick={() => setEditing(g)}>Edit</Button>
|
||||
<Button variant="ghost" onClick={() => setDeleting(g)}>
|
||||
Delete
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{editing && (
|
||||
<RingGroupForm
|
||||
group={editing === 'new' ? null : editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
title="Delete ring group"
|
||||
message={
|
||||
<p>
|
||||
Delete ring 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 RingGroupForm({
|
||||
group,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
group: RingGroupDto | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => Promise<unknown>;
|
||||
}) {
|
||||
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
|
||||
const next = useQuery({
|
||||
queryKey: ['next-number', 'ringGroup'],
|
||||
queryFn: () => extensionsApi.nextNumber('ringGroup'),
|
||||
enabled: !group,
|
||||
});
|
||||
const [form, setForm] = useState({
|
||||
number: group?.number ?? '',
|
||||
name: group?.name ?? '',
|
||||
strategy: group?.strategy ?? 'ringall',
|
||||
ringSeconds: String(group?.ringSeconds ?? 25),
|
||||
callerIdNamePrefix: group?.callerIdNamePrefix ?? '',
|
||||
musicInsteadOfRing: group?.musicInsteadOfRing ?? false,
|
||||
});
|
||||
const [members, setMembers] = useState<string[]>(group?.memberIds ?? []);
|
||||
const [dest, setDest] = useState<Destination | null>(
|
||||
group?.noAnswerDestination ?? { type: 'hangup' },
|
||||
);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const number = form.number || (group ? '' : (next.data ?? ''));
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: RingGroupCreate) =>
|
||||
group ? ringGroupsApi.update(group.id, body) : ringGroupsApi.create(body),
|
||||
onSuccess: async () => {
|
||||
await onSaved();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
function submit() {
|
||||
const parsed = RingGroupCreateSchema.safeParse({
|
||||
number,
|
||||
name: form.name,
|
||||
strategy: form.strategy,
|
||||
ringSeconds: Number(form.ringSeconds),
|
||||
callerIdNamePrefix: form.callerIdNamePrefix.trim() || null,
|
||||
musicInsteadOfRing: form.musicInsteadOfRing,
|
||||
memberIds: members,
|
||||
noAnswerDestination: dest ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
setErrors(Object.fromEntries(parsed.error.issues.map((i) => [String(i.path[0]), i.message])));
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
save.mutate(parsed.data);
|
||||
}
|
||||
|
||||
const set = (k: keyof typeof form) => (v: string | boolean) => setForm((f) => ({ ...f, [k]: v }));
|
||||
const move = (i: number, by: number) =>
|
||||
setMembers((m) => {
|
||||
const out = [...m];
|
||||
[out[i], out[i + by]] = [out[i + by]!, out[i]!];
|
||||
return out;
|
||||
});
|
||||
const label = (id: string) => {
|
||||
const x = exts.data?.find((e) => e.id === id);
|
||||
return x ? `${x.number} ${x.name}` : '?';
|
||||
};
|
||||
const candidates = exts.data?.filter((x) => !members.includes(x.id)) ?? [];
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
drawer
|
||||
onClose={onClose}
|
||||
title={group ? `Ring group ${group.number}` : 'New ring group'}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={submit} disabled={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field label="Number" error={errors.number} hint="Callers and colleagues dial this">
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
className={inputClass}
|
||||
inputMode="numeric"
|
||||
value={number}
|
||||
onChange={(e) => set('number')(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Name" error={errors.name}>
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
className={inputClass}
|
||||
value={form.name}
|
||||
placeholder="Sales"
|
||||
onChange={(e) => set('name')(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="How to ring">
|
||||
{(p) => (
|
||||
<select
|
||||
{...p}
|
||||
className={inputClass}
|
||||
value={form.strategy}
|
||||
onChange={(e) => set('strategy')(e.target.value)}
|
||||
>
|
||||
<option value="ringall">Everyone at once</option>
|
||||
<option value="hunt">One at a time, in order</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Ring for (seconds)"
|
||||
error={errors.ringSeconds}
|
||||
hint={form.strategy === 'hunt' ? 'For each person' : 'In total'}
|
||||
>
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
className={inputClass}
|
||||
inputMode="numeric"
|
||||
value={form.ringSeconds}
|
||||
onChange={(e) => set('ringSeconds')(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="mb-1 text-sm font-medium">Members</legend>
|
||||
{errors.memberIds && <Alert>{errors.memberIds}</Alert>}
|
||||
<ol className="flex flex-col gap-1">
|
||||
{members.map((m, i) => (
|
||||
<li key={m} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-5 text-zinc-500">{i + 1}.</span>
|
||||
<span className="flex-1">{label(m)}</span>
|
||||
<Button
|
||||
className="px-2 py-1"
|
||||
aria-label={`Move ${label(m)} up`}
|
||||
disabled={i === 0}
|
||||
onClick={() => move(i, -1)}
|
||||
>
|
||||
↑
|
||||
</Button>
|
||||
<Button
|
||||
className="px-2 py-1"
|
||||
aria-label={`Move ${label(m)} down`}
|
||||
disabled={i === members.length - 1}
|
||||
onClick={() => move(i, 1)}
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1"
|
||||
aria-label={`Remove ${label(m)}`}
|
||||
onClick={() => setMembers((ms) => ms.filter((x) => x !== m))}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{candidates.length > 0 && (
|
||||
<select
|
||||
aria-label="Add a member"
|
||||
className={inputClass}
|
||||
value=""
|
||||
onChange={(e) => e.target.value && setMembers((m) => [...m, e.target.value])}
|
||||
>
|
||||
<option value="">Add a member…</option>
|
||||
{candidates.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.number} {x.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<p className="text-xs text-zinc-500">
|
||||
Members' call forwarding and voicemail don't apply here; Do not disturb does.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<DestinationPicker
|
||||
label="If nobody answers"
|
||||
value={dest ?? { type: 'hangup' }}
|
||||
onChange={setDest}
|
||||
error={errors.noAnswerDestination}
|
||||
exclude={group?.id}
|
||||
/>
|
||||
<Field
|
||||
label="Caller name prefix"
|
||||
error={errors.callerIdNamePrefix}
|
||||
hint='Optional, e.g. "Sales:" so people know which line rings'
|
||||
>
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
className={inputClass}
|
||||
value={form.callerIdNamePrefix}
|
||||
onChange={(e) => set('callerIdNamePrefix')(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.musicInsteadOfRing}
|
||||
onChange={(e) => set('musicInsteadOfRing')(e.target.checked)}
|
||||
/>
|
||||
Callers hear music instead of ringing
|
||||
</label>
|
||||
{save.error && <Alert>{save.error.message}</Alert>}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -569,6 +569,9 @@ exten => s,1,Set(__SP_TARGET=500)
|
||||
```
|
||||
With zero members, leave out the Dial lines.
|
||||
Ring groups deliberately **ignore** members' call forwarding and send nobody to voicemail.
|
||||
The caller-name prefix (here and on DIDs) goes through `name()`, which trims, so one space is
|
||||
always put between it and the caller's name: `Set(CALLERID(name)={prefix} ${CALLERID(name)})`.
|
||||
Golden case `ringgroups`; `scripts/e2e/m6-t2.mjs` checks ring-all and hunt with real calls (M6-T2).
|
||||
|
||||
### 6.8 Queues
|
||||
|
||||
|
||||
@@ -12,3 +12,4 @@ export * from './dids.ts';
|
||||
export * from './outbound.ts';
|
||||
export * from './voicemail.ts';
|
||||
export * from './sounds.ts';
|
||||
export * from './ringgroups.ts';
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Ring groups (02 ring_groups, 03 §6.7): one number that rings several people.
|
||||
import { z } from 'zod';
|
||||
import { DestinationSchema, type Destination } from './destination.ts';
|
||||
import { InternalNumber } from './extensions.ts';
|
||||
|
||||
export const RING_STRATEGIES = ['ringall', 'hunt'] as const;
|
||||
|
||||
const RingGroupFields = z.object({
|
||||
number: InternalNumber,
|
||||
name: z.string().trim().min(1, 'Required').max(40),
|
||||
/** ringall: everyone at once; hunt: one at a time, in member order. */
|
||||
strategy: z.enum(RING_STRATEGIES),
|
||||
/** Per member for hunt, total for ringall. */
|
||||
ringSeconds: z.number().int().min(5).max(300),
|
||||
/** Shown before the caller's name, e.g. "Sales: ". Same rules as caller ID names (03 §2). */
|
||||
callerIdNamePrefix: z
|
||||
.string()
|
||||
.max(20)
|
||||
.regex(/^[^"'\\;,()[\]{}$<>|]*$/, 'No quotes, commas, brackets or ; $ < > |')
|
||||
.nullable(),
|
||||
musicInsteadOfRing: z.boolean(),
|
||||
/** Extension ids, in ring order. */
|
||||
memberIds: z
|
||||
.array(z.uuid())
|
||||
.max(50)
|
||||
.refine((ids) => new Set(ids).size === ids.length, 'Someone is listed twice'),
|
||||
noAnswerDestination: DestinationSchema,
|
||||
});
|
||||
|
||||
export const RingGroupCreateSchema = RingGroupFields.partial().required({
|
||||
number: true,
|
||||
name: true,
|
||||
noAnswerDestination: true,
|
||||
});
|
||||
export const RingGroupUpdateSchema = RingGroupFields.partial();
|
||||
export type RingGroupCreate = z.input<typeof RingGroupCreateSchema>;
|
||||
export type RingGroupUpdate = z.input<typeof RingGroupUpdateSchema>;
|
||||
|
||||
export type RingGroupDto = z.output<typeof RingGroupFields> & {
|
||||
id: string;
|
||||
noAnswerDestination: Destination;
|
||||
noAnswerSummary: string;
|
||||
createdAt: string;
|
||||
};
|
||||
@@ -79,3 +79,7 @@ Then a second browser, as a new user with desk phone 174 and the softphone off,
|
||||
and the api with `SOUNDS_DIR=<soundsDir>`. It drops a generated wav on **Sounds & music**, uploads it,
|
||||
checks that Asterisk can play `custom/<id>`, renames it, and deletes it, which is refused while it's
|
||||
the recording notice. `cdp.mjs` `fill()`/`click()` take an optional selector to search within.
|
||||
|
||||
`m6-t2.mjs` (after `m1-t4.mjs`) creates ring group 500 in the UI with members 190 (never answers)
|
||||
and 191 (auto-answer), and checks that a call from 192 rings both and is answered by 191. It then
|
||||
switches the group to "one at a time" with 5 s each and checks that 190 rings alone before 191.
|
||||
@@ -0,0 +1,181 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M6-T2 in a browser + real calls: create ring group 500 in the UI with members 190 (never
|
||||
// answers) and 191 (auto-answer); a call from 192 is answered by 191. Then switch it to "one at a
|
||||
// time" with 190 first: 190 rings alone for 5 s, then 191 rings and answers.
|
||||
// Runs on the m2-live setup after m1-t4.mjs.
|
||||
// Usage: node scripts/e2e/m6-t2.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 = () => spawnSync('docker', ['logs', AST], { encoding: 'utf8' }).stdout ?? '';
|
||||
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 desk = async (number, name) => {
|
||||
const e = await api('POST', '/api/extensions', { number, name });
|
||||
return (await api('GET', `/api/extensions/${e.id}/sip-credentials`)).password;
|
||||
};
|
||||
const phones = startPhones({
|
||||
name: 'sp-live-phones-m6t2',
|
||||
net: NET,
|
||||
ast: AST,
|
||||
phones: [
|
||||
{ ext: '190', password: await desk('190', 'Nora Never'), answer: 'manual' },
|
||||
{ ext: '191', password: await desk('191', 'Abe Answer'), answer: 'auto' },
|
||||
{ ext: '192', password: await desk('192', 'Cal Caller'), answer: 'auto', toneHz: 660 },
|
||||
],
|
||||
});
|
||||
|
||||
const channels = () =>
|
||||
cli('core show channels concise')
|
||||
.split('\n')
|
||||
.filter((l) => l.startsWith('PJSIP/'))
|
||||
.map((l) => {
|
||||
const f = l.split('!');
|
||||
return { name: f[0], state: f[4], bridge: f[12], seq: Number(f[13]?.split('.')[1]) };
|
||||
});
|
||||
const chan = (n) => channels().find((c) => c.name.startsWith(`PJSIP/${n}-`));
|
||||
const waitUntil = async (fn, what, ms = 15_000) => {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
const v = fn();
|
||||
if (v) return v;
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error(`timeout waiting for: ${what}`);
|
||||
};
|
||||
const hangupAll = async () => {
|
||||
cli('channel request hangup all');
|
||||
await waitUntil(() => channels().length === 0, 'calls ended');
|
||||
await sleep(500);
|
||||
};
|
||||
const pick = (label, text) =>
|
||||
p.evaluate(`(() => {
|
||||
const s = [...document.querySelectorAll('select')].find(s => s.getAttribute('aria-label') === ${JSON.stringify(label)} || s.labels?.[0]?.textContent === ${JSON.stringify(label)});
|
||||
const o = [...s.options].find(o => o.textContent.includes(${JSON.stringify(text)}));
|
||||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set.call(s, o.value);
|
||||
s.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
})()`);
|
||||
|
||||
const p = await launch({ shotsDir });
|
||||
try {
|
||||
await waitUntil(
|
||||
() =>
|
||||
['190', '191', '192'].every((n) =>
|
||||
new RegExp(`${n}/sip:.*Avail`).test(cli('pjsip show contacts')),
|
||||
),
|
||||
'phones registered',
|
||||
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'`);
|
||||
await p.click('Ring groups');
|
||||
await p.click('Add ring group');
|
||||
await p.waitFor(`!!document.querySelector('dialog[open]')`);
|
||||
await p.waitFor(
|
||||
`[...document.querySelectorAll('dialog[open] label')].find(l => l.textContent === 'Number')?.control.value === '500'`,
|
||||
'next free number suggested',
|
||||
);
|
||||
await p.fill('Name', 'Sales', 'dialog[open]');
|
||||
await pick('Add a member', 'Nora Never');
|
||||
await pick('Add a member', 'Abe Answer');
|
||||
await p.click('Save', 'dialog[open]');
|
||||
await p.waitFor(
|
||||
`document.body.innerText.includes('Everyone at once: Nora Never, Abe Answer')`,
|
||||
'saved',
|
||||
);
|
||||
await p.shot('m6-t2-01-list');
|
||||
await waitUntil(
|
||||
() => /Goto\(sp-rg-/.test(cli('dialplan show 500@sp-local-numbers')),
|
||||
'dialplan has 500',
|
||||
);
|
||||
console.log('ring group 500 created in the UI');
|
||||
|
||||
// Everyone at once: 190 and 191 ring together; 191 answers. It answers within milliseconds,
|
||||
// so who was rung comes from Asterisk's log rather than the channel list.
|
||||
const at = astLog().length;
|
||||
cli('channel originate PJSIP/192 extension 500@sp-from-ext');
|
||||
await waitUntil(
|
||||
() => chan('191')?.bridge && chan('191').bridge === chan('192')?.bridge,
|
||||
'191 answers',
|
||||
);
|
||||
const rung = astLog().slice(at);
|
||||
assert.match(rung, /Called PJSIP\/190\//, '190 rang');
|
||||
assert.match(rung, /Called PJSIP\/191\//, '191 rang');
|
||||
await waitUntil(() => !chan('190'), '190 stops ringing');
|
||||
console.log('ring all: 190 and 191 rang together, 191 answered, 190 stopped');
|
||||
await hangupAll();
|
||||
|
||||
// One at a time, 190 first for 5 s.
|
||||
await p.click('Edit');
|
||||
await p.waitFor(`!!document.querySelector('dialog[open]')`);
|
||||
await pick('How to ring', 'One at a time');
|
||||
await p.fill('Ring for (seconds)', '5', 'dialog[open]');
|
||||
await p.click('Save', 'dialog[open]');
|
||||
await p.waitFor(
|
||||
`document.body.innerText.includes('One at a time: Nora Never, Abe Answer')`,
|
||||
'saved hunt',
|
||||
);
|
||||
await waitUntil(
|
||||
() =>
|
||||
/,5,t\)/.test(
|
||||
cli(
|
||||
'dialplan show s@' +
|
||||
cli('dialplan show 500@sp-local-numbers').match(/sp-rg-[0-9a-f-]+/)[0],
|
||||
),
|
||||
),
|
||||
'hunt dialplan',
|
||||
);
|
||||
cli('channel originate PJSIP/192 extension 500@sp-from-ext');
|
||||
const t0 = Date.now();
|
||||
await waitUntil(() => chan('190'), '190 rings first');
|
||||
assert.equal(chan('191'), undefined, '191 does not ring yet');
|
||||
await waitUntil(() => chan('191'), '191 rings after 190', 10_000);
|
||||
const waited = (Date.now() - t0) / 1000;
|
||||
assert.ok(waited >= 4, `191 rang after ${waited.toFixed(1)} s`);
|
||||
await waitUntil(
|
||||
() => chan('191')?.bridge && chan('191').bridge === chan('192')?.bridge,
|
||||
'191 answers',
|
||||
);
|
||||
console.log(`one at a time: 190 alone, then 191 after ${waited.toFixed(1)} s and answered`);
|
||||
await hangupAll();
|
||||
|
||||
console.log('M6-T2 (ring groups): PASS');
|
||||
} catch (e) {
|
||||
await p.shot('m6-t2-fail').catch(() => {});
|
||||
console.log(`FAIL: ${e.message}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await p.close();
|
||||
phones.stop();
|
||||
}
|
||||
Reference in new issue
Block a user