featurecodes: DND, forwarding, pickup, echo, say-ext; /me settings

Feature codes per 03 §6.13: *78/*79 DND, *72<number>/*73 forward always,
**<ext> directed pickup, *8 group pickup (also as a dialplan line, since
pickupexten only catches a phone's own INVITE), *43 echo test, *65 say
my extension; queue login and intercom stay for M8. SpToggle DND/CFU
update user_runtime_state; a *72 target the user's calling permission
doesn't allow is removed from AstDB, audited (forward_rejected) and sent
to the user as a toast (new private live message). Forward targets are
checked the same way everywhere (services/forwards.ts: an existing
internal number, or an outside number some outbound rule allows them).

GET/PATCH /api/me: DND, cfu/cfb/cfna, voicemail email options and PIN;
PostgreSQL first, then AstDB; AstDB resync on connect now includes DND
and forwards. Web: /me/settings (forwarding, DND, voicemail PIN and
email options), a status card on Home, toasts.

e2e m6-t7 with real phones covers every code, the refused forward toast,
and the settings page reaching AstDB; it found the settings forms losing
"Saved." on remount.

Task: M6-T7
This commit is contained in:
iamdoubz committed 2026-10-18 11:19:21 -05:00
1 parent d746772b97
commit 57e0e3dac2
31 files changed
+1270 -33

No files matched your search

@@ -85,7 +85,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
same => n,Playback(do-not-disturb&activated)
same => n,Hangup()
exten => *79,1,Answer() ; DND off
same => n,Set(X=${DB_DELETE(SP/DND/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
same => n,Playback(call-fwd-unconditional&activated)
same => n,Hangup()
exten => *73,1,Answer() ; cancel forward always
same => n,Set(X=${DB_DELETE(SP/CFU/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
exten => *8,1,Pickup()
same => n,Hangup()
exten => *43,1,Answer() ; echo test
same => n,Playback(demo-echotest)
same => n,Echo()
same => n,Playback(demo-echodone)
same => n,Hangup()
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
@@ -85,7 +85,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
same => n,Playback(do-not-disturb&activated)
same => n,Hangup()
exten => *79,1,Answer() ; DND off
same => n,Set(X=${DB_DELETE(SP/DND/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
same => n,Playback(call-fwd-unconditional&activated)
same => n,Hangup()
exten => *73,1,Answer() ; cancel forward always
same => n,Set(X=${DB_DELETE(SP/CFU/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
exten => *8,1,Pickup()
same => n,Hangup()
exten => *43,1,Answer() ; echo test
same => n,Playback(demo-echotest)
same => n,Echo()
same => n,Playback(demo-echodone)
same => n,Hangup()
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
@@ -85,7 +85,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
same => n,Playback(do-not-disturb&activated)
same => n,Hangup()
exten => *79,1,Answer() ; DND off
same => n,Set(X=${DB_DELETE(SP/DND/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
same => n,Playback(call-fwd-unconditional&activated)
same => n,Hangup()
exten => *73,1,Answer() ; cancel forward always
same => n,Set(X=${DB_DELETE(SP/CFU/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
exten => *8,1,Pickup()
same => n,Hangup()
exten => *43,1,Answer() ; echo test
same => n,Playback(demo-echotest)
same => n,Echo()
same => n,Playback(demo-echodone)
same => n,Hangup()
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
@@ -85,7 +85,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
same => n,Playback(do-not-disturb&activated)
same => n,Hangup()
exten => *79,1,Answer() ; DND off
same => n,Set(X=${DB_DELETE(SP/DND/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
same => n,Playback(call-fwd-unconditional&activated)
same => n,Hangup()
exten => *73,1,Answer() ; cancel forward always
same => n,Set(X=${DB_DELETE(SP/CFU/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
exten => *8,1,Pickup()
same => n,Hangup()
exten => *43,1,Answer() ; echo test
same => n,Playback(demo-echotest)
same => n,Echo()
same => n,Playback(demo-echodone)
same => n,Hangup()
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
@@ -85,7 +85,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
same => n,Playback(do-not-disturb&activated)
same => n,Hangup()
exten => *79,1,Answer() ; DND off
same => n,Set(X=${DB_DELETE(SP/DND/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
same => n,Playback(call-fwd-unconditional&activated)
same => n,Hangup()
exten => *73,1,Answer() ; cancel forward always
same => n,Set(X=${DB_DELETE(SP/CFU/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
exten => *8,1,Pickup()
same => n,Hangup()
exten => *43,1,Answer() ; echo test
same => n,Playback(demo-echotest)
same => n,Echo()
same => n,Playback(demo-echodone)
same => n,Hangup()
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
@@ -85,7 +85,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
same => n,Playback(do-not-disturb&activated)
same => n,Hangup()
exten => *79,1,Answer() ; DND off
same => n,Set(X=${DB_DELETE(SP/DND/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
same => n,Playback(call-fwd-unconditional&activated)
same => n,Hangup()
exten => *73,1,Answer() ; cancel forward always
same => n,Set(X=${DB_DELETE(SP/CFU/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
exten => *8,1,Pickup()
same => n,Hangup()
exten => *43,1,Answer() ; echo test
same => n,Playback(demo-echotest)
same => n,Echo()
same => n,Playback(demo-echodone)
same => n,Hangup()
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
@@ -85,7 +85,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
same => n,Playback(do-not-disturb&activated)
same => n,Hangup()
exten => *79,1,Answer() ; DND off
same => n,Set(X=${DB_DELETE(SP/DND/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
same => n,Playback(call-fwd-unconditional&activated)
same => n,Hangup()
exten => *73,1,Answer() ; cancel forward always
same => n,Set(X=${DB_DELETE(SP/CFU/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
exten => *8,1,Pickup()
same => n,Hangup()
exten => *43,1,Answer() ; echo test
same => n,Playback(demo-echotest)
same => n,Echo()
same => n,Playback(demo-echodone)
same => n,Hangup()
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
@@ -85,7 +85,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
same => n,Playback(do-not-disturb&activated)
same => n,Hangup()
exten => *79,1,Answer() ; DND off
same => n,Set(X=${DB_DELETE(SP/DND/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
same => n,Playback(call-fwd-unconditional&activated)
same => n,Hangup()
exten => *73,1,Answer() ; cancel forward always
same => n,Set(X=${DB_DELETE(SP/CFU/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
exten => *8,1,Pickup()
same => n,Hangup()
exten => *43,1,Answer() ; echo test
same => n,Playback(demo-echotest)
same => n,Echo()
same => n,Playback(demo-echodone)
same => n,Hangup()
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
@@ -85,7 +85,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
same => n,Playback(do-not-disturb&activated)
same => n,Hangup()
exten => *79,1,Answer() ; DND off
same => n,Set(X=${DB_DELETE(SP/DND/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
same => n,Playback(call-fwd-unconditional&activated)
same => n,Hangup()
exten => *73,1,Answer() ; cancel forward always
same => n,Set(X=${DB_DELETE(SP/CFU/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
exten => *8,1,Pickup()
same => n,Hangup()
exten => *43,1,Answer() ; echo test
same => n,Playback(demo-echotest)
same => n,Echo()
same => n,Playback(demo-echodone)
same => n,Hangup()
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
@@ -85,7 +85,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
same => n,Playback(do-not-disturb&activated)
same => n,Hangup()
exten => *79,1,Answer() ; DND off
same => n,Set(X=${DB_DELETE(SP/DND/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
same => n,Playback(call-fwd-unconditional&activated)
same => n,Hangup()
exten => *73,1,Answer() ; cancel forward always
same => n,Set(X=${DB_DELETE(SP/CFU/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
exten => *8,1,Pickup()
same => n,Hangup()
exten => *43,1,Answer() ; echo test
same => n,Playback(demo-echotest)
same => n,Echo()
same => n,Playback(demo-echodone)
same => n,Hangup()
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
+36 -1
View File
@@ -88,7 +88,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(\${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(\${SP_EXT}@default)
@@ -96,6 +96,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/\${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: \${SP_EXT},Value: 1)
same => n,Playback(do-not-disturb&activated)
same => n,Hangup()
exten => *79,1,Answer() ; DND off
same => n,Set(X=\${DB_DELETE(SP/DND/\${SP_EXT})})
same => n,UserEvent(SpToggle,Key: DND,Ext: \${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(\${EXTEN:3}))
same => n,Set(DB(SP/CFU/\${SP_EXT})=\${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: \${SP_EXT},Value: \${GOSUB_RETVAL})
same => n,Playback(call-fwd-unconditional&activated)
same => n,Hangup()
exten => *73,1,Answer() ; cancel forward always
same => n,Set(X=\${DB_DELETE(SP/CFU/\${SP_EXT})})
same => n,UserEvent(SpToggle,Key: CFU,Ext: \${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,PickupChan(PJSIP/\${EXTEN:2}-&PJSIP/\${EXTEN:2}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
exten => *8,1,Pickup()
same => n,Hangup()
exten => *43,1,Answer() ; echo test
same => n,Playback(demo-echotest)
same => n,Echo()
same => n,Playback(demo-echodone)
same => n,Hangup()
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(\${SP_EXT})
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["\${GLOBAL(SP_TCADMIN_\${SP_EXT})}" != "1"]?deny)
+1 -1
View File
@@ -55,7 +55,7 @@ app.decorate('ami', ami);
const sendMail = smtpMailer(db);
app.decorate('sendMail', sendMail);
watchEmergencies({ db, ami, live, send: sendMail, log: app.log }, ami);
watchToggles({ db, log: app.log }, ami);
watchToggles({ db, ami, live, log: app.log }, ami);
const voicemail = watchVoicemail(
{
db,
+4 -2
View File
@@ -36,10 +36,12 @@ export async function liveRoutes(app: FastifyInstance) {
? `emergency:${m.data.at}:${m.data.ext}` // never merge two 911 calls
: m.type === 'voicemail.new'
? `voicemail:${m.data.id}`
: m.type;
: m.type === 'toast'
? `toast:${m.data.message}`
: m.type;
const maySee = (c: Client, m: LiveMessage) =>
m.type === 'voicemail.new'
m.type === 'voicemail.new' || m.type === 'toast'
? m.data.userId === c.userId // private, even from admins
: c.admin ||
(m.type === 'emergency.call'
+76 -2
View File
@@ -8,14 +8,19 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
GREETINGS,
MeUpdateSchema,
type ListResponse,
type MeDto,
type VoicemailDto,
type WebRtcCreds,
} from '@selfphoneng/shared';
import { decrypt } from '../crypto/secrets.ts';
import { decrypt, encrypt, isTrivialPin } from '../crypto/secrets.ts';
import { setDnd, setForward } from '../services/astdb.ts';
import { audit } from '../services/audit.ts';
import { checkForward, FORWARD_KINDS, type ForwardKind } from '../services/forwards.ts';
import { getSetting } from '../services/settings.ts';
import { requireAuth } from '../auth/guards.ts';
import { extensions, voicemailMessages } from '../db/schema.ts';
import { extensions, userRuntimeState, voicemailMessages } from '../db/schema.ts';
import { badRequest, conflict, HttpError, notFound } from '../errors.ts';
import { AudioError, MAX_UPLOAD_BYTES, toPcmWav } from '../media/audio.ts';
import { toVoicemailDto, type watchVoicemail } from '../services/voicemail.ts';
@@ -79,6 +84,75 @@ export async function meRoutes(app: FastifyInstance) {
}
/** The browser softphone's identity: <ext>-web, same SIP password as the desk phone (03 §5.2). */
async function myState(userId: string): Promise<MeDto> {
const [row] = await db
.select({ e: extensions, s: userRuntimeState })
.from(extensions)
.leftJoin(userRuntimeState, eq(userRuntimeState.extensionId, extensions.id))
.where(eq(extensions.userId, userId));
if (!row) throw notFound("You don't have an extension yet. Ask your admin.");
const { e, s } = row;
return {
extension: { number: e.number, name: e.name },
dnd: s?.dnd ?? false,
cfu: s?.cfu ?? null,
cfb: s?.cfb ?? null,
cfna: s?.cfna ?? null,
voicemailEnabled: e.voicemailEnabled,
voicemailEmail: e.voicemailEmail,
voicemailAttachAudio: e.voicemailAttachAudio,
voicemailDeleteAfterEmail: e.voicemailDeleteAfterEmail,
};
}
app.get('/api/me', async (req) => myState(req.user!.id));
/** DND, forwards and voicemail options: PostgreSQL first, then AstDB (04 §5.5). */
app.patch('/api/me', async (req) => {
const body = MeUpdateSchema.parse(req.body);
const ext = await myExtension(req.user!.id);
const runtime: { dnd?: boolean } & Partial<Record<ForwardKind, string | null>> = {};
if (body.dnd !== undefined) runtime.dnd = body.dnd;
for (const k of FORWARD_KINDS) {
const v = body[k];
if (v !== undefined) runtime[k] = v ? await checkForward(db, ext, v) : null;
}
if (body.voicemailPin && isTrivialPin(body.voicemailPin, ext.number))
throw badRequest('That PIN is too easy to guess (repeated, sequential or your extension)');
const { dnd: _d, cfu: _u, cfb: _b, cfna: _n, voicemailPin, ...vm } = body;
await db.transaction(async (tx) => {
if (Object.keys(runtime).length)
await tx
.insert(userRuntimeState)
.values({ extensionId: ext.id, ...runtime })
.onConflictDoUpdate({ target: userRuntimeState.extensionId, set: runtime });
if (Object.keys(vm).length || voicemailPin)
await tx
.update(extensions)
.set({ ...vm, ...(voicemailPin ? { voicemailPinEnc: encrypt(voicemailPin) } : {}) })
.where(eq(extensions.id, ext.id));
});
// Offline Asterisk catches up from PostgreSQL on its next connect.
if (app.ami.isConnected()) {
if (runtime.dnd !== undefined) await setDnd(app.ami, ext.number, runtime.dnd);
for (const k of FORWARD_KINDS)
if (runtime[k] !== undefined) await setForward(app.ami, k, ext.number, runtime[k]);
}
if (runtime.dnd !== undefined) await app.live.loadFromDb();
if (voicemailPin) app.generator.requestApply('api-change');
const changed = Object.keys(body).map((k) => (k === 'voicemailPin' ? 'voicemail PIN' : k));
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'update',
entityType: 'extension',
entityId: ext.id,
summary: `${ext.number} changed their own ${changed.join(', ')}`,
diff: { after: { ...runtime, ...vm } },
});
return myState(req.user!.id);
});
app.get('/api/me/webrtc', async (req): Promise<WebRtcCreds> => {
const [ext] = await db.select().from(extensions).where(eq(extensions.userId, req.user!.id));
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
+26 -3
View File
@@ -1,9 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// AstDB holds what the dialplan reads at call time (overrides, DND, forwards, blocks). PostgreSQL
// is the truth: routes write it first, then call one of these; every AMI connect resyncs (04 §5.5).
import { isNotNull } from 'drizzle-orm';
import { eq, isNotNull } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { timeConditions } from '../db/schema.ts';
import { extensions, timeConditions, userRuntimeState } from '../db/schema.ts';
export type AstDbAmi = { action(fields: Record<string, string>): Promise<unknown> };
@@ -16,7 +16,21 @@ const del = (ami: AstDbAmi, key: string) =>
export const setTcOverride = (ami: AstDbAmi, id: string, state: 'open' | 'closed' | null) =>
state ? put(ami, `TCOVR/${id}`, state) : del(ami, `TCOVR/${id}`);
/** Wipe our keys and write them again from PostgreSQL. M6-T7/T8 add DND, forwards, blocks. */
export const setDnd = (ami: AstDbAmi, ext: string, on: boolean) =>
on ? put(ami, `DND/${ext}`, '1') : del(ami, `DND/${ext}`);
/** kind: cfu (always), cfb (busy), cfna (no answer); null turns it off. */
export const setForward = (
ami: AstDbAmi,
kind: 'cfu' | 'cfb' | 'cfna',
ext: string,
target: string | null,
) =>
target
? put(ami, `${kind.toUpperCase()}/${ext}`, target)
: del(ami, `${kind.toUpperCase()}/${ext}`);
/** Wipe our keys and write them again from PostgreSQL. M6-T8 adds blocks, M8 queue pauses. */
export async function syncAstDb(db: Db, ami: AstDbAmi): Promise<void> {
await ami.action({ Action: 'DBDelTree', Family: 'SP' }).catch(() => undefined); // empty tree
const overrides = await db
@@ -24,4 +38,13 @@ export async function syncAstDb(db: Db, ami: AstDbAmi): Promise<void> {
.from(timeConditions)
.where(isNotNull(timeConditions.overrideState));
for (const o of overrides) await setTcOverride(ami, o.id, o.state as 'open' | 'closed');
const runtime = await db
.select({ ext: extensions.number, s: userRuntimeState })
.from(userRuntimeState)
.innerJoin(extensions, eq(extensions.id, userRuntimeState.extensionId));
for (const { ext, s } of runtime) {
if (s.dnd) await setDnd(ami, ext, true);
for (const kind of ['cfu', 'cfb', 'cfna'] as const)
if (s[kind]) await setForward(ami, kind, ext, s[kind]);
}
}
+41
View File
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Call forwarding targets (04 §5.3): an internal number, or an outside number the user's calling
// permission allows (the same outbound rules a call would use). Used by PATCH /api/me and by
// *72 from a phone (SpToggle CFU).
import { dialedDigits, formatNumber, normalizeNumber } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { badRequest } from '../errors.ts';
import { ownerOf } from './numbering.ts';
import { testRoute } from './outbound.ts';
export const FORWARD_KINDS = ['cfu', 'cfb', 'cfna'] as const;
export type ForwardKind = (typeof FORWARD_KINDS)[number];
/** Returns what to store (internal number or normalized outside number), or throws 400. */
export async function checkForward(
db: Db,
ext: { id: string; number: string },
input: string,
): Promise<string> {
const digits = dialedDigits(input);
if (!digits) throw badRequest('Enter a number');
if (digits === ext.number) throw badRequest("You can't forward calls to yourself");
if (/^\d{3,6}$/.test(digits)) {
if (await ownerOf(db, digits)) return digits;
throw badRequest(`${digits} isn't an extension or group here`);
}
let n: string;
try {
n = normalizeNumber(input);
} catch {
throw badRequest('A phone number like (312) 555-1234');
}
const r = await testRoute(db, ext.id, n);
if (!r.ok)
throw badRequest(
r.routeId
? `Your calling permission doesn't allow forwarding to ${formatNumber(n)}`
: `Calls to ${formatNumber(n)} can't go out (no outbound rule matches)`,
);
return n;
}
+1 -1
View File
@@ -28,7 +28,7 @@ const OWNERS = [
const PARKING = { from: 700, to: 720 }; // res_parking.conf: 700 + slots 701–720
/** Who holds `number` right now (ignoring row `excludeId`), or null. */
async function ownerOf(db: Db, number: string, excludeId?: string): Promise<string | null> {
export async function ownerOf(db: Db, number: string, excludeId?: string): Promise<string | null> {
const n = Number(number);
if (n >= PARKING.from && n <= PARKING.to) return 'parking';
for (const { table, label } of OWNERS) {
+52 -4
View File
@@ -1,29 +1,77 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Feature codes change AstDB from a phone, then raise UserEvent(SpToggle, Key, ...) so PostgreSQL
// follows (04 §5.3). M6-T4: TCOVR (*31N/*32N/*33N). M6-T7 adds DND and forwards.
// follows (04 §5.3): TCOVR (*31N/*32N/*33N), DND (*78/*79), CFU (*72<number>/*73).
import type { EventEmitter } from 'node:events';
import { eq } from 'drizzle-orm';
import type { LiveMessage } from '@selfphoneng/shared';
import { field, type AmiMessage } from '../ami/events.ts';
import type { Db } from '../db/client.ts';
import { timeConditions } from '../db/schema.ts';
import { extensions, timeConditions, userRuntimeState } from '../db/schema.ts';
import { HttpError } from '../errors.ts';
import { setForward, type AstDbAmi } from './astdb.ts';
import { audit } from './audit.ts';
import { checkForward } from './forwards.ts';
export type ToggleDeps = {
db: Db;
ami: AstDbAmi;
live: { emit(event: 'message', m: LiveMessage): unknown; loadFromDb(): Promise<void> };
log: { info(o: object, msg: string): void; error(o: object, msg: string): void };
};
export async function handleToggle(d: ToggleDeps, m: AmiMessage): Promise<void> {
const key = field(m, 'Key');
const value = field(m, 'Value') ?? '';
if (key === 'TCOVR') {
const id = field(m, 'Id') ?? '';
const value = field(m, 'Value');
if (!/^[0-9a-f-]{36}$/.test(id) || !['open', 'closed', 'auto'].includes(value ?? '')) return;
if (!/^[0-9a-f-]{36}$/.test(id) || !['open', 'closed', 'auto'].includes(value)) return;
await d.db
.update(timeConditions)
.set({ overrideState: value === 'auto' ? null : value })
.where(eq(timeConditions.id, id));
d.log.info({ id, value }, 'office hours override from a phone');
return;
}
if (key !== 'DND' && key !== 'CFU') return;
const [ext] = await d.db
.select({ id: extensions.id, number: extensions.number, userId: extensions.userId })
.from(extensions)
.where(eq(extensions.number, field(m, 'Ext') ?? ''));
if (!ext) return;
if (key === 'DND') {
await d.db
.update(userRuntimeState)
.set({ dnd: value === '1' })
.where(eq(userRuntimeState.extensionId, ext.id));
} else {
let target: string | null = null;
if (value) {
try {
target = await checkForward(d.db, ext, value);
} catch (e) {
if (!(e instanceof HttpError)) throw e;
// Not allowed: undo it in Asterisk, keep nothing, tell the user (04 §5.3).
await setForward(d.ami, 'cfu', ext.number, null);
await audit(d.db, {
action: 'forward_rejected',
entityType: 'extension',
entityId: ext.id,
summary: `Forward of ${ext.number} to ${value} refused: ${e.message}`,
});
if (ext.userId)
d.live.emit('message', {
type: 'toast',
data: { userId: ext.userId, message: `Call forwarding not turned on: ${e.message}` },
});
}
}
await d.db
.update(userRuntimeState)
.set({ cfu: target })
.where(eq(userRuntimeState.extensionId, ext.id));
}
await d.live.loadFromDb(); // DND shows on everyone's presence
}
export function watchToggles(d: ToggleDeps, ami: Pick<EventEmitter, 'on'>) {
+133
View File
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M6-T7: GET/PATCH /api/me (DND, forwards, voicemail options) and SpToggle DND/CFU (04 §5.3).
import { eq } from 'drizzle-orm';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { LiveMessage } from '@selfphoneng/shared';
import { auditLog, extensions } from '../src/db/schema.ts';
import { handleToggle } from '../src/services/toggles.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('/api/me settings and phone toggles', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let admin: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let dan: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let danId: string;
const live: LiveMessage[] = [];
const toggle = (Key: string, Ext: string, Value: string) =>
handleToggle(
{ db: t.db, ami: t.ami, live: t.live, log: t.app.log },
{ Event: 'UserEvent', UserEvent: 'SpToggle', Key, Ext, Value },
);
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const as = (cookie: string) => (method: string, url: string, payload?: unknown) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
});
admin = as(await t.login(ADMIN.email, ADMIN.password));
danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
await admin('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: danId });
await admin('POST', '/api/extensions', { number: '102', name: 'Sue Smith' });
const ext = (await admin('GET', '/api/extensions'))
.json()
.items.find((e: { number: string }) => e.number === '101');
await admin('PATCH', `/api/extensions/${ext.id}`, { outboundClass: 'local' });
// Outbound rules exist once a trunk does (M3 defaults: toll-free, US/Canada national, intl).
await admin('POST', '/api/trunks', {
name: 'voip.ms',
serverHost: '127.0.0.1',
username: '123456_pbx',
password: 'pw',
});
dan = as(await t.login('dan@example.com', 'dan-password-123'));
t.live.on('message', (m) => live.push(m));
});
afterAll(() => t?.close());
it('GET /api/me shows my extension and settings', async () => {
expect((await dan('GET', '/api/me')).json()).toEqual({
extension: { number: '101', name: 'Dan Doe' },
dnd: false,
cfu: null,
cfb: null,
cfna: null,
voicemailEnabled: true,
voicemailEmail: true,
voicemailAttachAudio: true,
voicemailDeleteAfterEmail: false,
});
});
it('DND and forwards: stored, then written to AstDB', async () => {
t.ami.actions.length = 0;
const r = await dan('PATCH', '/api/me', { dnd: true, cfb: '102', cfna: '1-800-555-0100' });
expect(r.statusCode, r.body).toBe(200);
expect(r.json()).toMatchObject({ dnd: true, cfb: '102', cfna: '18005550100' });
expect(t.ami.actions).toEqual([
{ Action: 'DBPut', Family: 'SP', Key: 'DND/101', Val: '1' },
{ Action: 'DBPut', Family: 'SP', Key: 'CFB/101', Val: '102' },
{ Action: 'DBPut', Family: 'SP', Key: 'CFNA/101', Val: '18005550100' },
]);
const off = await dan('PATCH', '/api/me', { dnd: false, cfb: null });
expect(off.json()).toMatchObject({ dnd: false, cfb: null, cfna: '18005550100' });
expect(t.ami.actions.slice(-2)).toEqual([
{ Action: 'DBDel', Family: 'SP', Key: 'DND/101' },
{ Action: 'DBDel', Family: 'SP', Key: 'CFB/101' },
]);
});
it('forward targets: my calling permission applies; no self, no unknown extension', async () => {
const fwd = (cfu: string) => dan('PATCH', '/api/me', { cfu });
const national = await fwd('(312) 555-1234'); // class "local" may not call national numbers
expect(national.statusCode).toBe(400);
expect(national.json().error.message).toMatch(/calling permission doesn't allow/);
expect((await fwd('101')).json().error.message).toMatch(/yourself/);
expect((await fwd('199')).json().error.message).toMatch(/isn't an extension/);
expect((await fwd('102')).json().cfu).toBe('102');
});
it('voicemail options and PIN (trivial PINs refused; voicemail.conf regenerated)', async () => {
expect((await dan('PATCH', '/api/me', { voicemailPin: '1111' })).statusCode).toBe(400);
const r = await dan('PATCH', '/api/me', { voicemailPin: '4826', voicemailEmail: false });
expect(r.json().voicemailEmail).toBe(false);
const [row] = await t.db.select().from(extensions).where(eq(extensions.number, '101'));
expect(row!.voicemailEmail).toBe(false);
});
it('*78 / *79 from the phone update DND and everyone’s presence', async () => {
await toggle('DND', '101', '1');
expect((await dan('GET', '/api/me')).json().dnd).toBe(true);
expect(
live.some((m) => m.type === 'extension.updated' && m.data.number === '101' && m.data.dnd),
).toBe(true);
await toggle('DND', '101', '0');
expect((await dan('GET', '/api/me')).json().dnd).toBe(false);
});
it('*72<number>: allowed targets are kept; refused ones are undone, audited, toasted', async () => {
await toggle('CFU', '101', '102');
expect((await dan('GET', '/api/me')).json().cfu).toBe('102');
t.ami.actions.length = 0;
await toggle('CFU', '101', '13125551234');
expect((await dan('GET', '/api/me')).json().cfu).toBeNull();
expect(t.ami.actions).toEqual([{ Action: 'DBDel', Family: 'SP', Key: 'CFU/101' }]);
expect(live).toContainEqual({
type: 'toast',
data: { userId: danId, message: expect.stringMatching(/^Call forwarding not turned on/) },
});
const audits = await t.db
.select()
.from(auditLog)
.where(eq(auditLog.action, 'forward_rejected'));
expect(audits).toHaveLength(1);
await toggle('CFU', '101', '');
expect((await dan('GET', '/api/me')).json().cfu).toBeNull();
});
});
+2 -2
View File
@@ -98,13 +98,13 @@ describe.skipIf(!hasTestDb)('office hours and holidays', () => {
it('*311 from a phone (SpToggle TCOVR) updates the database', async () => {
await handleToggle(
{ db: t.db, log: t.app.log },
{ db: t.db, ami: t.ami, live: t.live, log: t.app.log },
{ Event: 'UserEvent', UserEvent: 'SpToggle', Key: 'TCOVR', Id: id, Value: 'open' },
);
const [row] = await t.db.select().from(timeConditions).where(eq(timeConditions.id, id));
expect(row!.overrideState).toBe('open');
await handleToggle(
{ db: t.db, log: t.app.log },
{ db: t.db, ami: t.ami, live: t.live, log: t.app.log },
{ Event: 'UserEvent', UserEvent: 'SpToggle', Key: 'TCOVR', Id: id, Value: 'auto' },
);
expect((await req('GET', `/api/time-conditions/${id}`)).json().overrideState).toBeNull();
+4
View File
@@ -18,6 +18,8 @@ import type {
IvrDto,
IvrUpdate,
ListResponse,
MeDto,
MeUpdate,
NumberKind,
OutboundRouteCreate,
OutboundRouteDto,
@@ -181,6 +183,8 @@ export const systemApi = {
};
export const meApi = {
get: () => api<MeDto>('GET', '/api/me'),
update: (m: MeUpdate) => api<MeDto>('PATCH', '/api/me', m),
webrtc: () => api<WebRtcCreds>('GET', '/api/me/webrtc'),
clickToCall: (number: string) => api<{ ok: true }>('POST', '/api/me/click-to-call', { number }),
voicemail: () => api<ListResponse<VoicemailDto>>('GET', '/api/me/voicemail').then((r) => r.items),
+5
View File
@@ -14,6 +14,8 @@ type LiveStore = {
emergencies: EmergencyCall[];
/** Bumped on every voicemail.new, so the voicemail list knows to refetch. */
voicemailSeq: number;
/** Short messages for this user (e.g. forwarding refused); dismissed ones are removed. */
toasts: { id: number; message: string }[];
};
export const useLive = create<LiveStore>(() => ({
@@ -23,6 +25,7 @@ export const useLive = create<LiveStore>(() => ({
trunks: {},
emergencies: [],
voicemailSeq: 0,
toasts: [],
}));
function apply(m: LiveMessage) {
@@ -49,6 +52,8 @@ function apply(m: LiveMessage) {
}
case 'emergency.call':
return { emergencies: [...s.emergencies, m.data] };
case 'toast':
return { toasts: [...s.toasts, { id: Date.now(), message: m.data.message }] };
case 'voicemail.new':
return { voicemailSeq: s.voicemailSeq + 1 };
case 'ami.status':
+56
View File
@@ -12,6 +12,7 @@ import { subscribeLive, useLive } from '../../live/store.ts';
import { SoftphoneDock } from '../../softphone/SoftphoneDock.tsx';
import { PresencePanel } from '../../softphone/PresencePanel.tsx';
import { VoicemailPage, callerOf, isNew, mmss } from './VoicemailPage.tsx';
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
const link = 'text-sky-700 underline dark:text-sky-400';
@@ -24,6 +25,12 @@ export function MePage({ user }: { user: AuthUser }) {
useEffect(() => {
if (voicemailSeq) void qc.invalidateQueries({ queryKey: ['me', 'voicemail'] });
}, [voicemailSeq, qc]);
// *78/*79 or *72 from the desk phone: refresh my settings when my presence changes.
const myDnd = useLive((s) => (me ? s.extensions[me]?.dnd : undefined));
useEffect(() => {
void qc.invalidateQueries({ queryKey: ['me', 'settings'] });
}, [myDnd, qc]);
const toasts = useLive((s) => s.toasts);
const tab = ({ isActive }: { isActive: boolean }) =>
`rounded-md px-3 py-2 text-sm font-medium ${isActive ? 'bg-zinc-100 dark:bg-zinc-800' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`;
@@ -60,16 +67,39 @@ export function MePage({ user }: { user: AuthUser }) {
<NavLink to="/me/voicemail" className={tab}>
Voicemail
</NavLink>
<NavLink to="/me/settings" className={tab}>
Settings
</NavLink>
{/* People is a right-hand column on wide screens, a tab on small ones (05 §4.1). */}
<NavLink to="/me/people" className={(s) => `${tab(s)} lg:hidden`}>
People
</NavLink>
</nav>
{toasts.map((t) => (
<div
key={t.id}
role="alert"
className="flex items-start gap-2 rounded-md bg-amber-100 p-3 text-sm text-amber-900 dark:bg-amber-900 dark:text-amber-100"
>
<span className="flex-1">{t.message}</span>
<Button
variant="ghost"
className="px-2 py-0"
aria-label="Dismiss"
onClick={() =>
useLive.setState((s) => ({ toasts: s.toasts.filter((x) => x.id !== t.id) }))
}
>
✕
</Button>
</div>
))}
<div className="grid flex-1 gap-8 lg:grid-cols-[1fr_20rem]">
<div className="flex min-w-0 flex-col gap-6">
<Routes>
<Route index element={<Home user={user} />} />
<Route path="voicemail" element={<VoicemailPage />} />
<Route path="settings" element={<MeSettingsPage />} />
<Route path="people" element={<PresencePanel me={me} />} />
</Routes>
<SoftphoneDock />
@@ -88,6 +118,7 @@ function Home({ user }: { user: AuthUser }) {
return (
<div className="flex flex-col gap-6">
<p>Hi {user.displayName}.</p>
{user.extension && <StatusCard />}
<section aria-labelledby="new-vm" className="flex flex-col gap-2">
<h2 id="new-vm" className="text-lg font-semibold">
New voicemails
@@ -118,3 +149,28 @@ function Home({ user }: { user: AuthUser }) {
</div>
);
}
/** Do not disturb and forwarding at a glance (05 §4 Home). */
function StatusCard() {
const me = useMe();
if (!me.data) return null;
const fwd = [
me.data.cfu && `all calls go to ${showTarget(me.data.cfu)}`,
me.data.cfb && `busy → ${showTarget(me.data.cfb)}`,
me.data.cfna && `no answer → ${showTarget(me.data.cfna)}`,
].filter(Boolean);
return (
<section
aria-label="My status"
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"
>
<DndSwitch me={me.data} />
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{fwd.length ? `Forwarding: ${fwd.join(', ')}.` : 'Not forwarding.'}{' '}
<Link className={link} to="/me/settings">
Change
</Link>
</p>
</section>
);
}
+191
View File
@@ -0,0 +1,191 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// My settings (05 §4): call forwarding, do not disturb, voicemail PIN and email options.
// Softphone devices and notifications live in the softphone panel; phone apps (QR) come in M10.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Link } from 'react-router';
import { formatNumber, type MeDto, type MeUpdate } from '@selfphoneng/shared';
import { meApi } from '../../api/client.ts';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
/** "102" stays as is; outside numbers get their usual format. */
export const showTarget = (n: string) => (n.length > 6 ? formatNumber(n) : n);
export function useMe() {
return useQuery({ queryKey: ['me', 'settings'], queryFn: meApi.get });
}
export function useUpdateMe() {
const qc = useQueryClient();
return useMutation({
mutationFn: (m: MeUpdate) => meApi.update(m),
onSuccess: (me) => qc.setQueryData(['me', 'settings'], me),
});
}
export function DndSwitch({ me }: { me: MeDto }) {
const save = useUpdateMe();
return (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
role="switch"
checked={me.dnd}
disabled={save.isPending}
onChange={(e) => save.mutate({ dnd: e.target.checked })}
/>
Do not disturb
<span className="text-xs text-zinc-500">(also *78 on, *79 off from your phone)</span>
</label>
);
}
export function MeSettingsPage() {
const me = useMe();
if (me.error) return <Alert>{me.error.message}</Alert>;
if (!me.data) return <p className="text-sm text-zinc-500">Loading…</p>;
// ponytail: the forms start from what was loaded; a *72 from the phone while this page is open
// shows after a reload (remounting on every change would also wipe "Saved." and errors).
return <SettingsForms me={me.data} />;
}
function SettingsForms({ me }: { me: MeDto }) {
return (
<div className="flex flex-col gap-8">
<section aria-labelledby="fwd-h" className="flex flex-col gap-3">
<h2 id="fwd-h" className="text-lg font-semibold">
Calls
</h2>
<DndSwitch me={me} />
<Forwarding me={me} />
</section>
{me.voicemailEnabled && <Voicemail me={me} />}
<section className="flex flex-col gap-2">
<h2 className="text-lg font-semibold">Password</h2>
<Link className="text-sm text-sky-700 underline dark:text-sky-400" to="/change-password">
Change password
</Link>
</section>
</div>
);
}
const FORWARDS = [
['cfu', 'Forward all calls to', 'Your phones don’t ring at all (also *72<number> / *73)'],
['cfb', 'When you’re on a call, forward to', 'Instead of busy / voicemail'],
['cfna', 'When you don’t answer, forward to', 'Instead of voicemail'],
] as const;
function Forwarding({ me }: { me: MeDto }) {
const save = useUpdateMe();
const [form, setForm] = useState({
cfu: me.cfu ? showTarget(me.cfu) : '',
cfb: me.cfb ? showTarget(me.cfb) : '',
cfna: me.cfna ? showTarget(me.cfna) : '',
});
return (
<form
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
save.mutate({
cfu: form.cfu.trim() || null,
cfb: form.cfb.trim() || null,
cfna: form.cfna.trim() || null,
});
}}
>
{FORWARDS.map(([k, label, hint]) => (
<Field key={k} label={label} hint={hint}>
{(p) => (
<input
{...p}
className={inputClass}
inputMode="tel"
placeholder="Off — or an extension or phone number"
value={form[k]}
onChange={(e) => setForm({ ...form, [k]: e.target.value })}
/>
)}
</Field>
))}
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" disabled={save.isPending}>
Save forwarding
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
);
}
function Voicemail({ me }: { me: MeDto }) {
const save = useUpdateMe();
const [pin, setPin] = useState('');
const [opts, setOpts] = useState({
voicemailEmail: me.voicemailEmail,
voicemailAttachAudio: me.voicemailAttachAudio,
voicemailDeleteAfterEmail: me.voicemailDeleteAfterEmail,
});
const check = (k: keyof typeof opts, label: string, disabled = false) => (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={opts[k] && !disabled}
disabled={disabled}
onChange={(e) => setOpts({ ...opts, [k]: e.target.checked })}
/>
{label}
</label>
);
return (
<section aria-labelledby="vm-set-h" className="flex flex-col gap-3">
<h2 id="vm-set-h" className="text-lg font-semibold">
Voicemail
</h2>
<form
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
save.mutate(
{
...opts,
voicemailDeleteAfterEmail:
opts.voicemailDeleteAfterEmail && opts.voicemailAttachAudio && opts.voicemailEmail,
...(pin ? { voicemailPin: pin } : {}),
},
{ onSuccess: () => setPin('') },
);
}}
>
<Field label="New voicemail PIN" hint="4 to 10 digits; leave empty to keep the current one">
{(p) => (
<input
{...p}
className={`${inputClass} max-w-48`}
inputMode="numeric"
autoComplete="off"
value={pin}
onChange={(e) => setPin(e.target.value)}
/>
)}
</Field>
{check('voicemailEmail', 'Email me new voicemails')}
{check('voicemailAttachAudio', 'Attach the recording', !opts.voicemailEmail)}
{check(
'voicemailDeleteAfterEmail',
'Delete it from the phone system once emailed',
!opts.voicemailEmail || !opts.voicemailAttachAudio,
)}
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" disabled={save.isPending}>
Save voicemail settings
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
</section>
);
}
+4
View File
@@ -857,6 +857,10 @@ target against the user's outbound class (04 §5.3). If it isn't allowed, the ap
deletes the AstDB key, stores nothing, and logs an audit entry. Dialplan also
limits forwards via `SP_CLASS_<ext>` in `sp-sub-forward`.
As built (M6-T7): everything above except `*45` (queues) and `*80` (intercom), which come with M8.
`*8` also has a dialplan line (`Pickup()`): `pickupexten` only catches it straight from a phone's
INVITE. All the sounds used exist in the image (checked).
Sound names like `do-not-disturb`, `call-fwd-unconditional`, `agent-loginok`
come from the Asterisk core/extra sound packs. If one is missing, swap in
`beep` and note it in the spec.
+1
View File
@@ -16,3 +16,4 @@ export * from './ringgroups.ts';
export * from './ivrs.ts';
export * from './timeconditions.ts';
export * from './announcements.ts';
export * from './me.ts';
+2
View File
@@ -62,6 +62,8 @@ export type LiveMessage =
| { type: 'emergency.call'; data: EmergencyCall }
/** Only to the mailbox owner. */
| { type: 'voicemail.new'; data: VoicemailDto & { userId: string } }
/** A short message for one user (e.g. "forwarding refused"). */
| { type: 'toast'; data: { userId: string; message: string } }
| { type: 'ami.status'; data: { connected: boolean } }
| {
type: 'system.configApplied';
+40
View File
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// GET/PATCH /api/me (04 §7.3): the signed-in user's own phone settings.
import { z } from 'zod';
/** What a person types for a forward: "101", "(312) 555-1234", "+44 20 …"; null = off. */
const ForwardInput = z
.string()
.trim()
.max(40)
.regex(/^[0-9*#+()\s.-]*[0-9][0-9*#+()\s.-]*$/, 'A number like 101 or (312) 555-1234')
.nullable();
export const MeUpdateSchema = z.object({
dnd: z.boolean().optional(),
/** Forward always / when busy / when not answered. */
cfu: ForwardInput.optional(),
cfb: ForwardInput.optional(),
cfna: ForwardInput.optional(),
voicemailEmail: z.boolean().optional(),
voicemailAttachAudio: z.boolean().optional(),
voicemailDeleteAfterEmail: z.boolean().optional(),
voicemailPin: z
.string()
.regex(/^\d{4,10}$/, 'Use 4 to 10 digits')
.optional(),
});
export type MeUpdate = z.input<typeof MeUpdateSchema>;
export type MeDto = {
extension: { number: string; name: string };
dnd: boolean;
/** Stored normalized: an internal number, or an outside number like 13125551234. */
cfu: string | null;
cfb: string | null;
cfna: string | null;
voicemailEnabled: boolean;
voicemailEmail: boolean;
voicemailAttachAudio: boolean;
voicemailDeleteAfterEmail: boolean;
};
+11
View File
@@ -94,3 +94,14 @@ Force closed / Force open route a call to 196 / 195 and set or clear AstDB `SP/T
and `*331` from the admin's own phone (197) must reach the database and the page, `*311` from a
non-admin phone must get "no service", and the 2026 US federal holiday import must reach the
dialplan.
`m6-t7.mjs` (after `m1-t4.mjs`) checks the feature codes from a real phone (198, belonging to user
`fwd@example.com`):
- `*78`/`*79` set DND (the database, AstDB and the page follow, and calls meanwhile go to
voicemail).
- `*72199`/`*73` forward everything to 199. A `*72` to an outside number this user may not call is
refused, removed from AstDB, and shown as a toast.
- `/me/settings` saves a no-answer forward.
- `*43` echo test and `*65` say my extension.
- `**200` and `*8` pick up a call ringing at 200.
-7
View File
@@ -105,13 +105,6 @@ const fill = async (label, value) => {
await p.fill(label, value);
};
const clickNext = () => p.click('Next');
const choose = (ariaLabel, value) =>
p.evaluate(`(() => {
const el = document.querySelector('select[aria-label="${ariaLabel}"]');
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set.call(el, ${JSON.stringify(value)});
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()`);
try {
// 1. Password (forced change on first sign-in)
await p.goto(`${base}/login`);
+224
View File
@@ -0,0 +1,224 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M6-T7 with real phones: feature codes *78/*79 (DND), *72/*73 (forward always), *43 (echo),
// *65 (say my extension), **N (directed pickup), *8 (group pickup); a refused *72 shows a toast;
// forwarding set on /me/settings reaches AstDB and routes calls.
// User fwd@example.com has desk phone 198; 199 auto-answers; 200 rings without answering.
// Runs on the m2-live setup after m1-t4.mjs.
// Usage: node scripts/e2e/m6-t7.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 USER = { email: 'fwd@example.com', password: 'fwd-new-password-123' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const cli = (cmd) =>
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
// Asterisk colors its log (e.g. the arguments in "Executing" lines): strip the escape codes.
// eslint-disable-next-line no-control-regex -- matching ANSI escapes is the point
const ANSI = /\x1b\[[0-9;]*m/g;
const astLog = () =>
(spawnSync('docker', ['logs', AST], { encoding: 'utf8' }).stdout ?? '').replace(ANSI, '');
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 created = await api('POST', '/api/users', { email: USER.email, displayName: 'Fay Forward' });
const desk = async (number, name, extra = {}) => {
const e = await api('POST', '/api/extensions', { number, name, ...extra });
return (await api('GET', `/api/extensions/${e.id}/sip-credentials`)).password;
};
const phones = startPhones({
name: 'sp-live-phones-m6t7',
net: NET,
ast: AST,
phones: [
{
ext: '198',
password: await desk('198', 'Fay Forward', {
userId: created.user.id,
outboundClass: 'internal',
pickupGroup: 'front',
}),
answer: 'auto',
},
{ ext: '199', password: await desk('199', 'Gus Target'), answer: 'auto', toneHz: 660 },
{
ext: '200',
password: await desk('200', 'Hal Ringing', { pickupGroup: 'front' }),
answer: 'manual',
},
],
});
const waitUntil = async (fn, what, ms = 20_000) => {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn();
if (v) return v;
await sleep(200);
}
throw new Error(`timeout waiting for: ${what}`);
};
const channels = () =>
cli('core show channels concise')
.split('\n')
.filter((l) => /^(PJSIP|Local)\//.test(l))
.map((l) => {
const f = l.split('!');
return { name: f[0], bridge: f[12] };
});
const hangupAll = async () => {
cli('channel request hangup all');
await waitUntil(() => channels().length === 0, 'calls ended');
await sleep(500);
};
/** Phone 198 dials a code (it auto-answers the originate, then runs sp-from-ext). */
async function dial(code, done) {
const at = astLog().length;
cli(`channel originate PJSIP/198 extension ${code}@sp-from-ext`);
await waitUntil(() => done(astLog().slice(at)), `${code} ran`);
await hangupAll();
}
const me = () => fetch(`${base}/api/me`, { headers: { cookie: userCookie } }).then((r) => r.json());
const astdb = () => cli('database show SP');
let userCookie = '';
const p = await launch({ shotsDir });
try {
await waitUntil(
() =>
['198', '199', '200'].every((n) =>
new RegExp(`${n}/sip:.*Avail`).test(cli('pjsip show contacts')),
),
'phones registered',
180_000,
);
// The user signs in (first login: change password) and opens Settings.
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.fill('Email', USER.email);
await p.fill('Password', created.temporaryPassword);
await p.click('Sign in');
await p.waitFor(`location.pathname === '/change-password'`);
await p.fill('Current password', created.temporaryPassword);
await p.fill('New password', USER.password);
await p.fill('Repeat new password', USER.password);
await p.click('Change password');
await p.waitFor(`location.pathname === '/me'`);
userCookie = (
await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
body: JSON.stringify(USER),
})
).headers
.getSetCookie()
.map((c) => c.split(';')[0])
.join('; ');
await p.waitFor(`document.body.innerText.includes('Not forwarding.')`, 'status card');
// *78: DND on from the phone → database, AstDB, the page's switch; calls get busy → voicemail.
await dial('*78', (l) => /Playing 'do-not-disturb/.test(l));
await waitUntil(async () => (await me()).dnd, 'DND in the database');
assert.match(astdb(), /\/SP\/DND\/198\s*:\s*1/);
await p.waitFor(`document.querySelector('[role=switch]')?.checked === true`, 'switch on', 10_000);
let at = astLog().length;
cli('channel originate Local/198@sp-from-ext application Wait 10');
await waitUntil(
() => /VoiceMail\("[^"]+", "198@default,b"\)/.test(astLog().slice(at)),
'busy voicemail',
);
await hangupAll();
await dial('*79', (l) => /Playing 'do-not-disturb/.test(l));
await waitUntil(async () => !(await me()).dnd, 'DND off');
console.log('*78/*79: DND in database, AstDB and the page; a call meanwhile went to voicemail');
// *72199: forward everything to 199; a call to 198 rings 199.
await dial('*72199', (l) => /Playing 'call-fwd-unconditional/.test(l));
await waitUntil(async () => (await me()).cfu === '199', 'CFU stored');
at = astLog().length;
cli('channel originate Local/198@sp-from-ext application Wait 10');
await waitUntil(() => /Called PJSIP\/199\//.test(astLog().slice(at)), 'forwarded to 199');
assert.doesNotMatch(astLog().slice(at), /Called PJSIP\/198\//);
await hangupAll();
await dial('*73', (l) => /Playing 'call-fwd-unconditional/.test(l));
await waitUntil(async () => (await me()).cfu === null, 'CFU off');
console.log('*72199 / *73: a call to 198 went to 199 while forwarded');
// *72 to an outside number: 198 may only call internally → refused, undone, toast.
await dial('*7213125551234', (l) => /Playing 'call-fwd-unconditional/.test(l));
await p.waitFor(
`document.body.innerText.includes('Call forwarding not turned on')`,
'toast',
10_000,
);
assert.equal((await me()).cfu, null);
assert.doesNotMatch(astdb(), /CFU\/198/);
await p.shot('m6-t7-01-toast');
console.log('*72 to an outside number: refused, removed from AstDB, toast shown');
// Settings page: "when you don't answer, forward to 199".
await p.click('Settings');
await p.fill('When you don’t answer, forward to', '199');
await p.click('Save forwarding');
await p.waitFor(`document.body.innerText.includes('Saved.')`);
assert.match(astdb(), /\/SP\/CFNA\/198\s*:\s*199/);
await p.fill('Forward all calls to', '(312) 555-1234');
await p.click('Save forwarding');
await p.waitFor(
`/doesn't allow|can't go out/.test(document.body.innerText)`,
'refused in the form',
);
await p.shot('m6-t7-02-settings');
console.log('settings page: no-answer forward saved to AstDB; an outside number refused');
await dial('*43', (l) => /Playing 'demo-echotest/.test(l)); // Echo() follows the long prompt
await dial('*65', (l) => /SayDigits\("[^"]+", "198"\)/.test(l));
console.log('*43 echo test and *65 say my extension');
// Pickup: 200 rings (never answers); 198 takes it with **200, then with *8 (same group).
for (const code of ['**200', '*8']) {
cli('channel originate Local/200@sp-from-ext application Wait 30');
await waitUntil(() => channels().some((c) => c.name.startsWith('PJSIP/200-')), '200 rings');
at = astLog().length;
cli(`channel originate PJSIP/198 extension ${code}@sp-from-ext`);
await waitUntil(
() =>
/Pickup|picked up/i.test(astLog().slice(at)) &&
!channels().some((c) => c.name.startsWith('PJSIP/200-')),
`${code} picks up`,
);
console.log(`${code}: 198 picked up the call ringing at 200`);
await hangupAll();
}
console.log('M6-T7 (feature codes, forwarding): PASS');
} catch (e) {
await p.shot('m6-t7-fail').catch(() => {});
console.log(`FAIL: ${e.message}`);
console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`);
process.exitCode = 1;
} finally {
await p.close();
phones.stop();
}