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
+1269 -32

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,SayDigits(${EXTEN})
same => n,Hangup() 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] [sp-featurecodes]
exten => *97,1,Answer() ; my voicemail exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default) same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox) exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default) same => n,VoiceMailMain(@default)
same => n,Hangup() 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) ; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer() exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny) 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,SayDigits(${EXTEN})
same => n,Hangup() 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] [sp-featurecodes]
exten => *97,1,Answer() ; my voicemail exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default) same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox) exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default) same => n,VoiceMailMain(@default)
same => n,Hangup() 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) ; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer() exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny) 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,SayDigits(${EXTEN})
same => n,Hangup() 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] [sp-featurecodes]
exten => *97,1,Answer() ; my voicemail exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default) same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox) exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default) same => n,VoiceMailMain(@default)
same => n,Hangup() 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) ; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer() exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny) 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,SayDigits(${EXTEN})
same => n,Hangup() 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] [sp-featurecodes]
exten => *97,1,Answer() ; my voicemail exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default) same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox) exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default) same => n,VoiceMailMain(@default)
same => n,Hangup() 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) ; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer() exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny) 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,SayDigits(${EXTEN})
same => n,Hangup() 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] [sp-featurecodes]
exten => *97,1,Answer() ; my voicemail exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default) same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox) exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default) same => n,VoiceMailMain(@default)
same => n,Hangup() 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) ; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer() exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny) 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,SayDigits(${EXTEN})
same => n,Hangup() 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] [sp-featurecodes]
exten => *97,1,Answer() ; my voicemail exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default) same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox) exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default) same => n,VoiceMailMain(@default)
same => n,Hangup() 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) ; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer() exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny) 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,SayDigits(${EXTEN})
same => n,Hangup() 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] [sp-featurecodes]
exten => *97,1,Answer() ; my voicemail exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default) same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox) exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default) same => n,VoiceMailMain(@default)
same => n,Hangup() 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) ; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer() exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny) 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,SayDigits(${EXTEN})
same => n,Hangup() 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] [sp-featurecodes]
exten => *97,1,Answer() ; my voicemail exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default) same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox) exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default) same => n,VoiceMailMain(@default)
same => n,Hangup() 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) ; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer() exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny) 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,SayDigits(${EXTEN})
same => n,Hangup() 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] [sp-featurecodes]
exten => *97,1,Answer() ; my voicemail exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default) same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox) exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default) same => n,VoiceMailMain(@default)
same => n,Hangup() 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) ; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer() exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny) 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,SayDigits(${EXTEN})
same => n,Hangup() 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] [sp-featurecodes]
exten => *97,1,Answer() ; my voicemail exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default) same => n,VoiceMailMain(${SP_EXT}@default)
@@ -93,6 +93,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox) exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default) same => n,VoiceMailMain(@default)
same => n,Hangup() 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) ; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer() exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny) 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,SayDigits(\${EXTEN})
same => n,Hangup() 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] [sp-featurecodes]
exten => *97,1,Answer() ; my voicemail exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(\${SP_EXT}@default) same => n,VoiceMailMain(\${SP_EXT}@default)
@@ -96,6 +96,41 @@ exten => *97,1,Answer() ; my voicemail
exten => *98,1,Answer() ; any voicemail (asks mailbox) exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default) same => n,VoiceMailMain(@default)
same => n,Hangup() 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) ; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer() exten => _*3[123]X,1,Answer()
same => n,GotoIf($["\${GLOBAL(SP_TCADMIN_\${SP_EXT})}" != "1"]?deny) 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); const sendMail = smtpMailer(db);
app.decorate('sendMail', sendMail); app.decorate('sendMail', sendMail);
watchEmergencies({ db, ami, live, send: sendMail, log: app.log }, ami); 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( const voicemail = watchVoicemail(
{ {
db, db,
+3 -1
View File
@@ -36,10 +36,12 @@ export async function liveRoutes(app: FastifyInstance) {
? `emergency:${m.data.at}:${m.data.ext}` // never merge two 911 calls ? `emergency:${m.data.at}:${m.data.ext}` // never merge two 911 calls
: m.type === 'voicemail.new' : m.type === 'voicemail.new'
? `voicemail:${m.data.id}` ? `voicemail:${m.data.id}`
: m.type === 'toast'
? `toast:${m.data.message}`
: m.type; : m.type;
const maySee = (c: Client, m: LiveMessage) => 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 ? m.data.userId === c.userId // private, even from admins
: c.admin || : c.admin ||
(m.type === 'emergency.call' (m.type === 'emergency.call'
+76 -2
View File
@@ -8,14 +8,19 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { import {
GREETINGS, GREETINGS,
MeUpdateSchema,
type ListResponse, type ListResponse,
type MeDto,
type VoicemailDto, type VoicemailDto,
type WebRtcCreds, type WebRtcCreds,
} from '@selfphoneng/shared'; } 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 { getSetting } from '../services/settings.ts';
import { requireAuth } from '../auth/guards.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 { badRequest, conflict, HttpError, notFound } from '../errors.ts';
import { AudioError, MAX_UPLOAD_BYTES, toPcmWav } from '../media/audio.ts'; import { AudioError, MAX_UPLOAD_BYTES, toPcmWav } from '../media/audio.ts';
import { toVoicemailDto, type watchVoicemail } from '../services/voicemail.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). */ /** 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> => { app.get('/api/me/webrtc', async (req): Promise<WebRtcCreds> => {
const [ext] = await db.select().from(extensions).where(eq(extensions.userId, req.user!.id)); 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."); 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 // SPDX-License-Identifier: AGPL-3.0-or-later
// AstDB holds what the dialplan reads at call time (overrides, DND, forwards, blocks). PostgreSQL // 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). // 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 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> }; 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) => export const setTcOverride = (ami: AstDbAmi, id: string, state: 'open' | 'closed' | null) =>
state ? put(ami, `TCOVR/${id}`, state) : del(ami, `TCOVR/${id}`); 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> { export async function syncAstDb(db: Db, ami: AstDbAmi): Promise<void> {
await ami.action({ Action: 'DBDelTree', Family: 'SP' }).catch(() => undefined); // empty tree await ami.action({ Action: 'DBDelTree', Family: 'SP' }).catch(() => undefined); // empty tree
const overrides = await db const overrides = await db
@@ -24,4 +38,13 @@ export async function syncAstDb(db: Db, ami: AstDbAmi): Promise<void> {
.from(timeConditions) .from(timeConditions)
.where(isNotNull(timeConditions.overrideState)); .where(isNotNull(timeConditions.overrideState));
for (const o of overrides) await setTcOverride(ami, o.id, o.state as 'open' | 'closed'); 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 const PARKING = { from: 700, to: 720 }; // res_parking.conf: 700 + slots 701–720
/** Who holds `number` right now (ignoring row `excludeId`), or null. */ /** 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); const n = Number(number);
if (n >= PARKING.from && n <= PARKING.to) return 'parking'; if (n >= PARKING.from && n <= PARKING.to) return 'parking';
for (const { table, label } of OWNERS) { for (const { table, label } of OWNERS) {
+52 -4
View File
@@ -1,29 +1,77 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Feature codes change AstDB from a phone, then raise UserEvent(SpToggle, Key, ...) so PostgreSQL // 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 type { EventEmitter } from 'node:events';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import type { LiveMessage } from '@selfphoneng/shared';
import { field, type AmiMessage } from '../ami/events.ts'; import { field, type AmiMessage } from '../ami/events.ts';
import type { Db } from '../db/client.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 = { export type ToggleDeps = {
db: Db; 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 }; log: { info(o: object, msg: string): void; error(o: object, msg: string): void };
}; };
export async function handleToggle(d: ToggleDeps, m: AmiMessage): Promise<void> { export async function handleToggle(d: ToggleDeps, m: AmiMessage): Promise<void> {
const key = field(m, 'Key'); const key = field(m, 'Key');
const value = field(m, 'Value') ?? '';
if (key === 'TCOVR') { if (key === 'TCOVR') {
const id = field(m, 'Id') ?? ''; 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 await d.db
.update(timeConditions) .update(timeConditions)
.set({ overrideState: value === 'auto' ? null : value }) .set({ overrideState: value === 'auto' ? null : value })
.where(eq(timeConditions.id, id)); .where(eq(timeConditions.id, id));
d.log.info({ id, value }, 'office hours override from a phone'); 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'>) { 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 () => { it('*311 from a phone (SpToggle TCOVR) updates the database', async () => {
await handleToggle( 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' }, { Event: 'UserEvent', UserEvent: 'SpToggle', Key: 'TCOVR', Id: id, Value: 'open' },
); );
const [row] = await t.db.select().from(timeConditions).where(eq(timeConditions.id, id)); const [row] = await t.db.select().from(timeConditions).where(eq(timeConditions.id, id));
expect(row!.overrideState).toBe('open'); expect(row!.overrideState).toBe('open');
await handleToggle( 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' }, { Event: 'UserEvent', UserEvent: 'SpToggle', Key: 'TCOVR', Id: id, Value: 'auto' },
); );
expect((await req('GET', `/api/time-conditions/${id}`)).json().overrideState).toBeNull(); expect((await req('GET', `/api/time-conditions/${id}`)).json().overrideState).toBeNull();
+4
View File
@@ -18,6 +18,8 @@ import type {
IvrDto, IvrDto,
IvrUpdate, IvrUpdate,
ListResponse, ListResponse,
MeDto,
MeUpdate,
NumberKind, NumberKind,
OutboundRouteCreate, OutboundRouteCreate,
OutboundRouteDto, OutboundRouteDto,
@@ -181,6 +183,8 @@ export const systemApi = {
}; };
export const meApi = { 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'), webrtc: () => api<WebRtcCreds>('GET', '/api/me/webrtc'),
clickToCall: (number: string) => api<{ ok: true }>('POST', '/api/me/click-to-call', { number }), 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), voicemail: () => api<ListResponse<VoicemailDto>>('GET', '/api/me/voicemail').then((r) => r.items),
+5
View File
@@ -14,6 +14,8 @@ type LiveStore = {
emergencies: EmergencyCall[]; emergencies: EmergencyCall[];
/** Bumped on every voicemail.new, so the voicemail list knows to refetch. */ /** Bumped on every voicemail.new, so the voicemail list knows to refetch. */
voicemailSeq: number; 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>(() => ({ export const useLive = create<LiveStore>(() => ({
@@ -23,6 +25,7 @@ export const useLive = create<LiveStore>(() => ({
trunks: {}, trunks: {},
emergencies: [], emergencies: [],
voicemailSeq: 0, voicemailSeq: 0,
toasts: [],
})); }));
function apply(m: LiveMessage) { function apply(m: LiveMessage) {
@@ -49,6 +52,8 @@ function apply(m: LiveMessage) {
} }
case 'emergency.call': case 'emergency.call':
return { emergencies: [...s.emergencies, m.data] }; return { emergencies: [...s.emergencies, m.data] };
case 'toast':
return { toasts: [...s.toasts, { id: Date.now(), message: m.data.message }] };
case 'voicemail.new': case 'voicemail.new':
return { voicemailSeq: s.voicemailSeq + 1 }; return { voicemailSeq: s.voicemailSeq + 1 };
case 'ami.status': case 'ami.status':
+56
View File
@@ -12,6 +12,7 @@ import { subscribeLive, useLive } from '../../live/store.ts';
import { SoftphoneDock } from '../../softphone/SoftphoneDock.tsx'; import { SoftphoneDock } from '../../softphone/SoftphoneDock.tsx';
import { PresencePanel } from '../../softphone/PresencePanel.tsx'; import { PresencePanel } from '../../softphone/PresencePanel.tsx';
import { VoicemailPage, callerOf, isNew, mmss } from './VoicemailPage.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'; const link = 'text-sky-700 underline dark:text-sky-400';
@@ -24,6 +25,12 @@ export function MePage({ user }: { user: AuthUser }) {
useEffect(() => { useEffect(() => {
if (voicemailSeq) void qc.invalidateQueries({ queryKey: ['me', 'voicemail'] }); if (voicemailSeq) void qc.invalidateQueries({ queryKey: ['me', 'voicemail'] });
}, [voicemailSeq, qc]); }, [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 }) => 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'}`; `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}> <NavLink to="/me/voicemail" className={tab}>
Voicemail Voicemail
</NavLink> </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). */} {/* 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`}> <NavLink to="/me/people" className={(s) => `${tab(s)} lg:hidden`}>
People People
</NavLink> </NavLink>
</nav> </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="grid flex-1 gap-8 lg:grid-cols-[1fr_20rem]">
<div className="flex min-w-0 flex-col gap-6"> <div className="flex min-w-0 flex-col gap-6">
<Routes> <Routes>
<Route index element={<Home user={user} />} /> <Route index element={<Home user={user} />} />
<Route path="voicemail" element={<VoicemailPage />} /> <Route path="voicemail" element={<VoicemailPage />} />
<Route path="settings" element={<MeSettingsPage />} />
<Route path="people" element={<PresencePanel me={me} />} /> <Route path="people" element={<PresencePanel me={me} />} />
</Routes> </Routes>
<SoftphoneDock /> <SoftphoneDock />
@@ -88,6 +118,7 @@ function Home({ user }: { user: AuthUser }) {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<p>Hi {user.displayName}.</p> <p>Hi {user.displayName}.</p>
{user.extension && <StatusCard />}
<section aria-labelledby="new-vm" className="flex flex-col gap-2"> <section aria-labelledby="new-vm" className="flex flex-col gap-2">
<h2 id="new-vm" className="text-lg font-semibold"> <h2 id="new-vm" className="text-lg font-semibold">
New voicemails New voicemails
@@ -118,3 +149,28 @@ function Home({ user }: { user: AuthUser }) {
</div> </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 deletes the AstDB key, stores nothing, and logs an audit entry. Dialplan also
limits forwards via `SP_CLASS_<ext>` in `sp-sub-forward`. 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` 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 come from the Asterisk core/extra sound packs. If one is missing, swap in
`beep` and note it in the spec. `beep` and note it in the spec.
+1
View File
@@ -16,3 +16,4 @@ export * from './ringgroups.ts';
export * from './ivrs.ts'; export * from './ivrs.ts';
export * from './timeconditions.ts'; export * from './timeconditions.ts';
export * from './announcements.ts'; export * from './announcements.ts';
export * from './me.ts';
+2
View File
@@ -62,6 +62,8 @@ export type LiveMessage =
| { type: 'emergency.call'; data: EmergencyCall } | { type: 'emergency.call'; data: EmergencyCall }
/** Only to the mailbox owner. */ /** Only to the mailbox owner. */
| { type: 'voicemail.new'; data: VoicemailDto & { userId: string } } | { 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: 'ami.status'; data: { connected: boolean } }
| { | {
type: 'system.configApplied'; 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 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 non-admin phone must get "no service", and the 2026 US federal holiday import must reach the
dialplan. 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); await p.fill(label, value);
}; };
const clickNext = () => p.click('Next'); 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 { try {
// 1. Password (forced change on first sign-in) // 1. Password (forced change on first sign-in)
await p.goto(`${base}/login`); 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();
}