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:
iamdoubz committed 2026-10-18 10:31:05 -05:00
1 parent 17db68a8c5
commit f87d4d47dd
35 files changed
+1604 -29

No files matched your search

+2
View File
@@ -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.
@@ -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()
+6 -2
View File
@@ -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)}`,
);
+4 -1
View File
@@ -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');
}
+13
View File
@@ -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,
+1
View File
@@ -21,6 +21,7 @@ describe('golden', () => {
'webrtc-disabled',
'hostile-names',
'voipms-basic',
'ringgroups',
]),
);
});
+2
View File
@@ -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),
+41 -2
View File
@@ -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,
+185
View File
@@ -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 };
});
}
+1
View File
@@ -64,6 +64,7 @@ function snapshot(): Snapshot {
dids: [],
trunks: [],
routes: [],
ringGroups: [],
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
};
}
+1
View File
@@ -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',
+1
View File
@@ -59,6 +59,7 @@ function snapshot(): Snapshot {
dids: [],
trunks: [],
routes: [],
ringGroups: [],
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
};
}
+1 -1
View File
@@ -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 () => {
+3 -1
View File
@@ -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);
});
+124
View File
@@ -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}`);
});
});
+11
View File
@@ -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}`),
};
+47 -20
View File
@@ -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>
);
}
+3
View File
@@ -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 ?? ''} />
),
+1 -1
View File
@@ -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' },
+323
View File
@@ -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>
);
}
+3
View File
@@ -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
+1
View File
@@ -12,3 +12,4 @@ export * from './dids.ts';
export * from './outbound.ts';
export * from './voicemail.ts';
export * from './sounds.ts';
export * from './ringgroups.ts';
+45
View File
@@ -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;
};
+4
View File
@@ -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.
+181
View File
@@ -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();
}