queues: queues, agents, *45 login, live tiles, stats

Queues per 03 §6.8: queues_selfphoneng.conf (members as Local agents
following their hint), sp-queue-{id} (answer, prefix, record, Queue with
max wait, timeout destination), the number in sp-local-numbers and
destApp queue. Golden case queue; the Asterisk load test now checks
`queue show` lists each queue's members.

*45<queue> toggles login and checks PQMSTATUS/UPQMSTATUS, so a code for
a queue the caller isn't an agent of plays ss-noservice and stores
nothing (spec snippet fixed). Pauses live in queue_member_state, AstDB
SP/QPAUSE (resynced on connect) and app_queue; QueuePause is re-sent
after every `queue reload all` and rollback, which unpause everyone.

/api/queues CRUD with members [{extensionId, penalty}], admin
pause/unpause, GET /:id/stats; /api/me/queues/:id/pause|unpause for
Home. Stats come from queue_calls (new table), filled from app_queue's
AMI events plus UserEvent SpQueueExit in the fail branch: a real call
showed that when no agent rings, the Queue part of a call vanishes into
the next leg's CDR, and Asterisk 24 sends QueueCallerAbandon on
timeouts too.

Live: QueueTracker (QueueStatus on connect and after applies, queue
events); admins see every queue, users the queues they're agents of.
Web: Queues page (list, form with every column, detail page with
waiting callers, agents and pause buttons, today's stats), queue tiles
on the dashboard, queue login switches on Home, Queue in the
DestinationPicker.

CDR fixtures for queue answered/abandoned/timeout calls. e2e m8-t1 with
real phones: 3 callers wait with 2 agents, one paused by *45550 and one
in the UI, unpause connects the longest waiting caller, one times out
to the overflow phone, stats count it all. The whole e2e chain passes.

Task: M8-T1
This commit is contained in:
iamdoubz committed 2026-10-18 20:15:51 -05:00
1 parent 6ddd8d3ea9
commit 2d9f8ed99e
75 files changed
+7623 -56

No files matched your search

+2
View File
@@ -23,6 +23,7 @@ import { liveRoutes } from './live/hub.ts';
import { meRoutes } from './routes/me.ts';
import { soundRoutes } from './routes/sounds.ts';
import { ringGroupRoutes } from './routes/ringgroups.ts';
import { queueRoutes } from './routes/queues.ts';
import { ivrRoutes } from './routes/ivrs.ts';
import { timeConditionRoutes } from './routes/timeconditions.ts';
import { announcementRoutes } from './routes/announcements.ts';
@@ -104,6 +105,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(meRoutes);
app.register(soundRoutes);
app.register(ringGroupRoutes);
app.register(queueRoutes);
app.register(ivrRoutes);
app.register(timeConditionRoutes);
app.register(announcementRoutes);
@@ -0,0 +1,16 @@
CREATE TYPE "public"."queue_outcome" AS ENUM('answered', 'abandoned', 'timed_out');--> statement-breakpoint
CREATE TABLE "queue_calls" (
"id" bigserial PRIMARY KEY NOT NULL,
"queue_id" uuid NOT NULL,
"uniqueid" text NOT NULL,
"linkedid" text NOT NULL,
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
"outcome" "queue_outcome",
"wait_seconds" integer,
"agent_extension_id" uuid
);
--> statement-breakpoint
ALTER TABLE "queue_calls" ADD CONSTRAINT "queue_calls_queue_id_queues_id_fk" FOREIGN KEY ("queue_id") REFERENCES "public"."queues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "queue_calls" ADD CONSTRAINT "queue_calls_agent_extension_id_extensions_id_fk" FOREIGN KEY ("agent_extension_id") REFERENCES "public"."extensions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "queue_calls_queue_id_joined_at_index" ON "queue_calls" USING btree ("queue_id","joined_at");--> statement-breakpoint
CREATE INDEX "queue_calls_uniqueid_index" ON "queue_calls" USING btree ("uniqueid");
File diff suppressed because it is too large. Load diff
@@ -15,6 +15,13 @@
"when": 1792267225335,
"tag": "0001_initial",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1792371506100,
"tag": "0002_queue_calls",
"breakpoints": true
}
]
}
+22
View File
@@ -305,6 +305,28 @@ export const queueMemberState = pgTable(
(t) => [primaryKey({ columns: [t.queueId, t.extensionId] })],
);
export const queueOutcome = pgEnum('queue_outcome', ['answered', 'abandoned', 'timed_out']);
/** One caller's stay in a queue, from app_queue's AMI events (stats, 04 §7.3). */
export const queueCalls = pgTable(
'queue_calls',
{
id: bigserial({ mode: 'number' }).primaryKey(),
queueId: uuid()
.notNull()
.references(() => queues.id, { onDelete: 'cascade' }),
/** The caller's channel. */
uniqueid: text().notNull(),
linkedid: text().notNull(),
joinedAt: tstz().notNull().defaultNow(),
/** null while waiting. */
outcome: queueOutcome(),
waitSeconds: integer(),
agentExtensionId: uuid().references(() => extensions.id, { onDelete: 'set null' }),
},
(t) => [index().on(t.queueId, t.joinedAt), index().on(t.uniqueid)],
);
export const mohClasses = pgTable('moh_classes', {
id: id(),
name: text().notNull().unique(),
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -129,6 +129,24 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${EXTEN:3})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
same => n,Set(DB(${K})=1)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 1)
same => n,Playback(agent-loggedoff)
same => n,Hangup()
same => n(login),Set(X=${DB_DELETE(${K})})
same => n,UnpauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
same => n,Playback(agent-loginok)
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -129,6 +129,24 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${EXTEN:3})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
same => n,Set(DB(${K})=1)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 1)
same => n,Playback(agent-loggedoff)
same => n,Hangup()
same => n(login),Set(X=${DB_DELETE(${K})})
same => n,UnpauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
same => n,Playback(agent-loginok)
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -129,6 +129,24 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${EXTEN:3})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
same => n,Set(DB(${K})=1)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 1)
same => n,Playback(agent-loggedoff)
same => n,Hangup()
same => n(login),Set(X=${DB_DELETE(${K})})
same => n,UnpauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
same => n,Playback(agent-loginok)
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -129,6 +129,24 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${EXTEN:3})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
same => n,Set(DB(${K})=1)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 1)
same => n,Playback(agent-loggedoff)
same => n,Hangup()
same => n(login),Set(X=${DB_DELETE(${K})})
same => n,UnpauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
same => n,Playback(agent-loginok)
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -129,6 +129,24 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${EXTEN:3})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
same => n,Set(DB(${K})=1)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 1)
same => n,Playback(agent-loggedoff)
same => n,Hangup()
same => n(login),Set(X=${DB_DELETE(${K})})
same => n,UnpauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
same => n,Playback(agent-loginok)
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -129,6 +129,24 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${EXTEN:3})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
same => n,Set(DB(${K})=1)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 1)
same => n,Playback(agent-loggedoff)
same => n,Hangup()
same => n(login),Set(X=${DB_DELETE(${K})})
same => n,UnpauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
same => n,Playback(agent-loginok)
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,7 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_CLASS_101=2
SP_CLASS_102=2
SP_CLASS_103=2
@@ -0,0 +1,270 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
; ─── Normalize a number. ARG1 = raw. Returns normalized in GOSUB_RETVAL. (matches 00 §8)
[sp-sub-normalize]
exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
same => n,ExecIf($["${N:0:2}" = "+1"]?Set(N=${N:1}))
same => n,ExecIf($["${N:0:1}" = "+"]?Set(N=011${N:1}))
same => n,ExecIf($[${LEN(${N})} = 10]?Set(N=1${N}))
same => n,Return(${N})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
exten => s,1,Set(__SP_TARGET=${ARG1})
same => n,ExecIf($["${SP_DIRECTION}" = "inbound" & ("${ARG4}" = "inbound" | "${ARG4}" = "all")]?Gosub(sp-sub-record,s,1))
same => n,GotoIf($["${DB(SP/DND/${ARG1})}" = "1"]?busy)
same => n,Set(FWD=${DB(SP/CFU/${ARG1})})
same => n,GotoIf($["${FWD}" != ""]?forward)
same => n,Gosub(sp-sub-dial-user,s,1(${ARG1},${ARG2}))
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
same => n(noanswer),Set(FWD=${DB(SP/CFNA/${ARG1})})
same => n,GotoIf($["${FWD}" != ""]?forward)
same => n,GotoIf($["${ARG3}" = "1"]?vmu)
same => n,Return()
same => n(vmu),VoiceMail(${ARG1}@default,u)
same => n,Return()
same => n(busy),Set(FWD=${DB(SP/CFB/${ARG1})})
same => n,GotoIf($["${FWD}" != ""]?forward)
same => n,GotoIf($["${ARG3}" = "1"]?vmb)
same => n,Busy(5)
same => n,Return()
same => n(vmb),VoiceMail(${ARG1}@default,b)
same => n,Return()
same => n(forward),Gosub(sp-sub-forward,s,1(${FWD},${ARG1}))
same => n(done),Return()
; ─── Forward a call. ARG1 = target number, ARG2 = ext whose forward rule this is.
; Stops after 3 hops so 101→102→101 can't loop forever.
[sp-sub-forward]
exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} + 1])
same => n,GotoIf($[${SP_FWDHOPS} > 3]?stop)
same => n,GotoIf($[${DIALPLAN_EXISTS(sp-local-numbers,${ARG1},1)}]?internal)
same => n,Set(SP_CLASS=${GLOBAL(SP_CLASS_${ARG2})})
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
[sp-sub-record]
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
same => n,MixMonitor(${SP_REC},b)
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
[sp-sub-autoanswer]
exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=<sip:${SP_EXT}>\;answer-after=0)
same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer)
same => n,Return()
; ─── 911 alert ring-out. Originated by the api (04 §5.3): Channel PJSIP/<notify ext>,
; Exten = the extension that dialed 911.
[sp-emergency-alert]
exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,Wait(1)
same => n,Playback(beep&beep&beep)
same => n,Playback(extension)
same => n,SayDigits(${EXTEN})
same => n,Playback(beep&beep&beep)
same => n,Playback(extension)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
same => n,Playback(do-not-disturb&activated)
same => n,Hangup()
exten => *79,1,Answer() ; DND off
same => n,Set(X=${DB_DELETE(SP/DND/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
same => n,Playback(call-fwd-unconditional&activated)
same => n,Hangup()
exten => *73,1,Answer() ; cancel forward always
same => n,Set(X=${DB_DELETE(SP/CFU/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
exten => *8,1,Pickup()
same => n,Hangup()
exten => *43,1,Answer() ; echo test
same => n,Playback(demo-echotest)
same => n,Echo()
same => n,Playback(demo-echodone)
same => n,Hangup()
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${EXTEN:3})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
same => n,Set(DB(${K})=1)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 1)
same => n,Playback(agent-loggedoff)
same => n,Hangup()
same => n(login),Set(X=${DB_DELETE(${K})})
same => n,UnpauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
same => n,Playback(agent-loginok)
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
same => n,Goto(sp-tc-override,${EXTEN},1)
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
[sp-vm-direct]
exten => _X.,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
[sp-ext-forward]
exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external destination
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
include => sp-emergency-dial
include => sp-featurecodes
include => sp-local-numbers
include => sp-outbound-routes
[sp-local-numbers]
include => sp-parkedcalls
; users (sorted by number)
exten => 101,1,Gosub(sp-sub-user,s,1(101,25,1,never))
same => n,Hangup()
exten => 101,hint,PJSIP/101&PJSIP/101-web
exten => 102,1,Gosub(sp-sub-user,s,1(102,25,1,never))
same => n,Hangup()
exten => 102,hint,PJSIP/102&PJSIP/102-web
exten => 103,1,Gosub(sp-sub-user,s,1(103,25,1,never))
same => n,Hangup()
exten => 103,hint,PJSIP/103&PJSIP/103-web
; queues
exten => 550,1,Goto(sp-queue-44444444-4444-4444-8444-444444444550,s,1)
exten => 551,1,Goto(sp-queue-44444444-4444-4444-8444-444444444551,s,1)
; ─── Queue 550 "Support"
[sp-queue-44444444-4444-4444-8444-444444444550]
exten => s,1,Set(__SP_TARGET=550)
same => n,Answer()
same => n,Set(CALLERID(name)=Support: ${CALLERID(name)})
same => n,Gosub(sp-sub-record,s,1)
same => n,Queue(550,t,,,300)
same => n,GotoIf($["${QUEUESTATUS}" = "TIMEOUT" | "${QUEUESTATUS}" = "FULL" | "${QUEUESTATUS}" = "JOINEMPTY" | "${QUEUESTATUS}" = "LEAVEEMPTY" | "${QUEUESTATUS}" = "JOINUNAVAIL" | "${QUEUESTATUS}" = "LEAVEUNAVAIL"]?fail)
same => n,Hangup()
same => n(fail),UserEvent(SpQueueExit,Queue: 550,Status: ${QUEUESTATUS})
same => n,Goto(sp-vm-direct,101,1)
; ─── Queue 551 "Overflow"
[sp-queue-44444444-4444-4444-8444-444444444551]
exten => s,1,Set(__SP_TARGET=551)
same => n,Answer()
same => n,Queue(551,t,,,120)
same => n,GotoIf($["${QUEUESTATUS}" = "TIMEOUT" | "${QUEUESTATUS}" = "FULL" | "${QUEUESTATUS}" = "JOINEMPTY" | "${QUEUESTATUS}" = "LEAVEEMPTY" | "${QUEUESTATUS}" = "JOINUNAVAIL" | "${QUEUESTATUS}" = "LEAVEUNAVAIL"]?fail)
same => n,Hangup()
same => n(fail),UserEvent(SpQueueExit,Queue: 551,Status: ${QUEUESTATUS})
same => n,Goto(sp-queue-44444444-4444-4444-8444-444444444550,s,1)
; ─── Office hours overrides (admins only, see sp-featurecodes)
[sp-tc-override]
exten => _*3[123]X,1,Playback(ss-noservice)
same => n,Hangup()
; ─── Calls from trunk "voip.ms"
[sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1)
exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
same => n,Set(CDR(SP_DIRECTION)=inbound)
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN}))
same => n,Set(SP_IN=${GOSUB_RETVAL})
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
; Request-URI didn't hold one of our DIDs: try the To header (06 §3)
same => n,Set(TOU=${CUT(CUT(PJSIP_HEADER(read,To),@,1),:,2)})
same => n,Gosub(sp-sub-normalize,s,1(${TOU}))
same => n,Set(SP_IN=${GOSUB_RETVAL})
same => n(caller),Gosub(sp-sub-normalize,s,1(${CALLERID(num)}))
same => n,Set(CALLERID(num)=${GOSUB_RETVAL})
same => n,GotoIf($["${DB(SP/BLOCK/${CALLERID(num)})}" = "1"]?blocked)
same => n,Goto(sp-inbound,${IF($["${SP_IN}" = ""]?unknown:${SP_IN})},1)
same => n(blocked),Hangup(21)
[sp-did-exists]
exten => 13125551234,1,NoOp()
[sp-inbound]
; DID 13125551234
exten => 13125551234,1,Set(__SP_DID=13125551234)
same => n,Set(CDR(SP_DID)=13125551234)
same => n,Goto(sp-queue-44444444-4444-4444-8444-444444444550,s,1)
; anything else
exten => _[0-9a-z].,1,Hangup()
[sp-outbound-routes]
exten => pending,1,Hangup() ; no routes yet
[sp-emergency-dial]
exten => 911,1,Goto(sp-emergency,s,1)
exten => 9911,1,Goto(sp-emergency,s,1) ; people who dial 9 first out of habit
[sp-emergency]
exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E911_CID})})
same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
same => n,Set(__SP_DIRECTION=outbound)
same => n,Set(CDR(SP_DIRECTION)=outbound)
same => n,Dial(PJSIP/911@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37)
same => n,Hangup()
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,296 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[global]
type = global
user_agent = SelfPhoneNG
endpoint_identifier_order = ip,username,anonymous
keep_alive_interval = 90
max_initial_qualify_time = 4
[system]
type = system
timer_t1 = 500
timer_b = 32000
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:5060
[transport-tcp]
type = transport
protocol = tcp
bind = 0.0.0.0:5060
[transport-ws]
type = transport
protocol = ws
bind = 0.0.0.0
; ── extension 101 (Dan Doe) ──
[101]
type = endpoint
context = sp-from-ext
disallow = all
allow = g722,ulaw,alaw
auth = 101-auth
aors = 101
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_pai = yes
callerid = "Dan Doe" <101>
mailboxes = 101@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=101
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[101-auth]
type = auth
auth_type = userpass
username = 101
password = Ab3dEf9hIj2kLm4nOp6qRs8t
[101]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30
mailboxes = 101@default
[101-web]
type = endpoint
context = sp-from-ext
webrtc = yes
disallow = all
allow = g722,ulaw
auth = 101-web-auth
aors = 101-web
from_domain = selfphoneng.invalid
callerid = "Dan Doe" <101>
mailboxes = 101@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=101
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[101-web-auth]
type = auth
auth_type = userpass
username = 101-web
password = Ab3dEf9hIj2kLm4nOp6qRs8t
[101-web]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 0
; ── extension 102 (Ann Lee) ──
[102]
type = endpoint
context = sp-from-ext
disallow = all
allow = g722,ulaw,alaw
auth = 102-auth
aors = 102
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_pai = yes
callerid = "Ann Lee" <102>
mailboxes = 102@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=102
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[102-auth]
type = auth
auth_type = userpass
username = 102
password = Bb3dEf9hIj2kLm4nOp6qRs8t
[102]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30
mailboxes = 102@default
[102-web]
type = endpoint
context = sp-from-ext
webrtc = yes
disallow = all
allow = g722,ulaw
auth = 102-web-auth
aors = 102-web
from_domain = selfphoneng.invalid
callerid = "Ann Lee" <102>
mailboxes = 102@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=102
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[102-web-auth]
type = auth
auth_type = userpass
username = 102-web
password = Bb3dEf9hIj2kLm4nOp6qRs8t
[102-web]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 0
; ── extension 103 (Sue Smith) ──
[103]
type = endpoint
context = sp-from-ext
disallow = all
allow = g722,ulaw,alaw
auth = 103-auth
aors = 103
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_pai = yes
callerid = "Sue Smith" <103>
mailboxes = 103@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=103
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[103-auth]
type = auth
auth_type = userpass
username = 103
password = Cb3dEf9hIj2kLm4nOp6qRs8t
[103]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30
mailboxes = 103@default
[103-web]
type = endpoint
context = sp-from-ext
webrtc = yes
disallow = all
allow = g722,ulaw
auth = 103-web-auth
aors = 103-web
from_domain = selfphoneng.invalid
callerid = "Sue Smith" <103>
mailboxes = 103@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=103
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[103-web-auth]
type = auth
auth_type = userpass
username = 103-web
password = Cb3dEf9hIj2kLm4nOp6qRs8t
[103-web]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 0
; ── trunk "voip.ms" ──
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth]
type = auth
auth_type = userpass
username = 123456_pbx
password = TrunkPassw0rd
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-reg]
type = registration
transport = transport-udp
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
server_uri = sip:chicago1.voip.ms:5060
client_uri = sip:123456_pbx@chicago1.voip.ms:5060
contact_user = 123456_pbx
retry_interval = 60
forbidden_retry_interval = 600
fatal_retry_interval = 600
max_retries = 10000
expiration = 300
line = yes
endpoint = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
type = aor
contact = sip:chicago1.voip.ms:5060
qualify_frequency = 60
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
type = endpoint
transport = transport-udp
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
disallow = all
allow = ulaw
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
aors = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
from_user = 123456_pbx
from_domain = chicago1.voip.ms
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_rpid = yes
trust_id_inbound = yes
dtmf_mode = rfc4733
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-identify]
type = identify
endpoint = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
match = chicago1.voip.ms
@@ -0,0 +1,30 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
; ─── Queue 550 "Support"
[550]
strategy = rrmemory
timeout = 15
retry = 5
wrapuptime = 10
maxlen = 0
musicclass = default
announce-frequency = 60
announce-position = yes
ringinuse = no
setinterfacevar = yes
joinempty = paused,unavailable,invalid,unknown
leavewhenempty = paused,unavailable,invalid,unknown
member => Local/101@sp-queue-member/n,0,Dan Doe,hint:101@sp-local-numbers
member => Local/102@sp-queue-member/n,1,Ann Lee,hint:102@sp-local-numbers
; ─── Queue 551 "Overflow"
[551]
strategy = ringall
timeout = 20
retry = 0
wrapuptime = 0
maxlen = 5
musicclass = default
announce-frequency = 0
announce-position = no
ringinuse = no
setinterfacevar = yes
@@ -0,0 +1,2 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
icesupport = yes
@@ -0,0 +1,25 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[general]
format = wav
attach = no ; the api sends emails itself
maxmsg = 100
maxsecs = 300
minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
envelope = yes
saycid = yes
review = no
nextaftercmd = yes
sendvoicemail = no
pollmailboxes = yes
pollfreq = 30
[zonemessages]
local = America/Chicago|'vm-received' Q 'digits/at' IMp
[default]
101 => 4826,Dan Doe,,,tz=local|attach=no
102 => 4826,Ann Lee,,,tz=local|attach=no
103 => 4826,Sue Smith,,,tz=local|attach=no
@@ -0,0 +1,58 @@
{
"extensions": [
{
"id": "00000000-0000-4000-8000-000000000101",
"number": "101",
"name": "Dan Doe",
"sipPassword": "Ab3dEf9hIj2kLm4nOp6qRs8t"
},
{
"id": "00000000-0000-4000-8000-000000000102",
"number": "102",
"name": "Ann, Lee",
"sipPassword": "Bb3dEf9hIj2kLm4nOp6qRs8t"
},
{
"id": "00000000-0000-4000-8000-000000000103",
"number": "103",
"name": "Sue Smith",
"sipPassword": "Cb3dEf9hIj2kLm4nOp6qRs8t"
}
],
"queues": [
{
"id": "44444444-4444-4444-8444-444444444550",
"number": "550",
"name": "Support",
"members": [
{ "number": "101", "name": "Dan Doe", "penalty": 0 },
{ "number": "102", "name": "Ann, Lee", "penalty": 1 }
],
"callerIdNamePrefix": "Support:",
"record": true,
"timeoutDestination": { "type": "voicemail", "id": "00000000-0000-4000-8000-000000000101" }
},
{
"id": "44444444-4444-4444-8444-444444444551",
"number": "551",
"name": "Overflow",
"strategy": "ringall",
"memberRingSeconds": 20,
"retrySeconds": 0,
"wrapupSeconds": 0,
"maxWaitSeconds": 120,
"maxCallers": 5,
"announcePosition": false,
"joinWhenNoAgents": true,
"timeoutDestination": { "type": "queue", "id": "44444444-4444-4444-8444-444444444550" }
}
],
"dids": [
{
"id": "11111111-1111-4111-8111-111111111111",
"number": "13125551234",
"destination": { "type": "queue", "id": "44444444-4444-4444-8444-444444444550" }
}
],
"trunks": [{ "id": "7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37" }]
}
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -129,6 +129,24 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${EXTEN:3})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
same => n,Set(DB(${K})=1)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 1)
same => n,Playback(agent-loggedoff)
same => n,Hangup()
same => n(login),Set(X=${DB_DELETE(${K})})
same => n,UnpauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
same => n,Playback(agent-loginok)
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -129,6 +129,24 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${EXTEN:3})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
same => n,Set(DB(${K})=1)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 1)
same => n,Playback(agent-loggedoff)
same => n,Hangup()
same => n(login),Set(X=${DB_DELETE(${K})})
same => n,UnpauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
same => n,Playback(agent-loginok)
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -129,6 +129,24 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${EXTEN:3})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
same => n,Set(DB(${K})=1)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 1)
same => n,Playback(agent-loggedoff)
same => n,Hangup()
same => n(login),Set(X=${DB_DELETE(${K})})
same => n,UnpauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
same => n,Playback(agent-loginok)
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
@@ -86,7 +86,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
@@ -129,6 +129,24 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${EXTEN:3})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
same => n,Set(DB(${K})=1)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 1)
same => n,Playback(agent-loggedoff)
same => n,Hangup()
same => n(login),Set(X=${DB_DELETE(${K})})
same => n,UnpauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
same => n,Playback(agent-loginok)
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
; 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)
+5 -1
View File
@@ -21,6 +21,10 @@ export function destApp(d: Destination, snap: Snapshot): string {
return snap.ringGroups.some((g) => g.id === d.id)
? `Goto(sp-rg-${s.ident(d.id)},s,1)`
: 'Hangup()';
case 'queue':
return snap.queues.some((q) => q.id === d.id)
? `Goto(sp-queue-${s.ident(d.id)},s,1)`
: 'Hangup()';
case 'ivr':
return snap.ivrs.some((r) => r.id === d.id)
? `Goto(sp-ivr-${s.ident(d.id)},s,1)`
@@ -34,7 +38,7 @@ export function destApp(d: Destination, snap: Snapshot): string {
? `Goto(sp-ann-${s.ident(d.id)},s,1)`
: 'Hangup()';
default:
// TODO(M8): queues, conferences.
// TODO(M8): conferences.
return 'Hangup()';
}
}
+4 -1
View File
@@ -24,10 +24,13 @@ export function renderLocalNumbers(snap: Snapshot): string {
if (snap.ringGroups.length) lines.push('', '; ring groups');
for (const g of [...snap.ringGroups].sort(byNumber))
lines.push(`exten => ${s.digits(g.number)},1,Goto(sp-rg-${s.ident(g.id)},s,1)`);
if (snap.queues.length) lines.push('', '; queues');
for (const q of [...snap.queues].sort(byNumber))
lines.push(`exten => ${s.digits(q.number)},1,Goto(sp-queue-${s.ident(q.id)},s,1)`);
if (snap.ivrs.length) lines.push('', '; digital receptionists');
for (const r of [...snap.ivrs].sort(byNumber))
lines.push(`exten => ${s.digits(r.number)},1,Goto(sp-ivr-${s.ident(r.id)},s,1)`);
// M8: queues, conferences and paging groups add one line each here.
// M8: conferences and paging groups add one line each here.
return lines.join('\n') + '\n';
}
+33
View File
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Queue entry contexts (03 §6.8): answer, wait in the queue up to max_wait_seconds, and send
// callers who time out (or find no agents, or a full queue) to the timeout destination.
import { byNumber, type Snapshot } from '../load.ts';
import * as s from '../sanitize.ts';
import { destApp } from './inbound.ts';
const FAILED = ['TIMEOUT', 'FULL', 'JOINEMPTY', 'LEAVEEMPTY', 'JOINUNAVAIL', 'LEAVEUNAVAIL'];
export function renderQueueDialplan(snap: Snapshot): string {
return [...snap.queues]
.sort(byNumber)
.map((q) => {
const num = s.digits(q.number);
const prefix = q.callerIdNamePrefix ? s.name(q.callerIdNamePrefix) : '';
return [
`; ─── Queue ${num} "${s.name(q.name)}"`,
`[sp-queue-${s.ident(q.id)}]`,
`exten => s,1,Set(__SP_TARGET=${num})`,
' same => n,Answer()',
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []),
...(q.record ? [' same => n,Gosub(sp-sub-record,s,1)'] : []),
` same => n,Queue(${num},t,,,${s.digits(String(q.maxWaitSeconds))})`,
` same => n,GotoIf($[${FAILED.map((f) => `"\${QUEUESTATUS}" = "${f}"`).join(' | ')}]?fail)`,
' same => n,Hangup()',
// Tells the stats why the caller left: the AMI events can't tell a timeout from a hang-up.
` same => n(fail),UserEvent(SpQueueExit,Queue: ${num},Status: \${QUEUESTATUS})`,
` same => n,${destApp(q.timeoutDestination, snap)}`,
'',
].join('\n');
})
.join('\n');
}
+19 -1
View File
@@ -89,7 +89,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(\${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Queue login *45 and intercom *80 arrive with M8.
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(\${SP_EXT}@default)
@@ -132,6 +132,24 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(\${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=\${EXTEN:3})
same => n,Set(K=SP/QPAUSE/\${Q}/\${SP_EXT})
same => n,GotoIf($["\${DB(\${K})}" = "1"]?login)
same => n,PauseQueueMember(\${Q},Local/\${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["\${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
same => n,Set(DB(\${K})=1)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: \${Q},Ext: \${SP_EXT},Value: 1)
same => n,Playback(agent-loggedoff)
same => n,Hangup()
same => n(login),Set(X=\${DB_DELETE(\${K})})
same => n,UnpauseQueueMember(\${Q},Local/\${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["\${UPQMSTATUS}" != "UNPAUSED"]?deny)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: \${Q},Ext: \${SP_EXT},Value: 0)
same => n,Playback(agent-loginok)
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
; 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)
+21
View File
@@ -18,6 +18,7 @@ export function loadCase(name: string): Snapshot {
trunks?: Partial<Snapshot['trunks'][number]>[];
routes?: Partial<Snapshot['routes'][number]>[];
ringGroups?: Partial<Snapshot['ringGroups'][number]>[];
queues?: Partial<Snapshot['queues'][number]>[];
ivrs?: Partial<Snapshot['ivrs'][number]>[];
timeConditions?: Partial<Snapshot['timeConditions'][number]>[];
holidays?: Snapshot['holidays'];
@@ -83,6 +84,26 @@ export function loadCase(name: string): Snapshot {
id: g.id!,
number: g.number!,
})),
queues: (raw.queues ?? []).map((q, i) => ({
name: `Queue ${i + 1}`,
strategy: 'rrmemory',
memberRingSeconds: 15,
retrySeconds: 5,
wrapupSeconds: 10,
maxWaitSeconds: 300,
maxCallers: 0,
mohClass: 'default',
announcePosition: true,
announceEverySeconds: 60,
joinWhenNoAgents: false,
record: false,
callerIdNamePrefix: null,
members: [],
timeoutDestination: { type: 'hangup' as const },
...q,
id: q.id!,
number: q.number!,
})),
routes: (raw.routes ?? []).map((r, i) => ({
name: `Route ${i + 1}`,
position: i,
+8 -1
View File
@@ -25,6 +25,7 @@ describe('golden', () => {
'ivr',
'holidays',
'ivr-business-hours',
'queue',
]),
);
});
@@ -42,7 +43,13 @@ describe('golden', () => {
});
it('is deterministic: shuffled input order gives identical output', () => {
const shuffled = { ...snap, extensions: [...snap.extensions].reverse() };
const shuffled = {
...snap,
extensions: [...snap.extensions].reverse(),
queues: [...snap.queues]
.reverse()
.map((q) => ({ ...q, members: [...q.members].reverse() })),
};
expect(render(shuffled)).toEqual(files);
});
});
+8 -2
View File
@@ -9,6 +9,7 @@ import type { Db } from '../db/client.ts';
import { eq } from 'drizzle-orm';
import { generatorRuns, settings } from '../db/schema.ts';
import { getSetting, setSetting } from '../services/settings.ts';
import { resendPauses } from '../services/queues.ts';
import { loadSnapshot, type Snapshot } from './load.ts';
import { renderPjsip, renderRtp, renderTransports } from './pjsip.ts';
import { STATIC_DIALPLAN } from './dialplan/static.ts';
@@ -17,6 +18,8 @@ import { renderDids, renderTrunkEntries } from './dialplan/inbound.ts';
import { renderOutbound } from './dialplan/outbound.ts';
import { renderEmergency } from './dialplan/emergency.ts';
import { renderRingGroups } from './dialplan/ringgroups.ts';
import { renderQueueDialplan } from './dialplan/queues.ts';
import { renderQueues } from './queues.ts';
import { renderIvrs } from './dialplan/ivrs.ts';
import { renderTimeConditions } from './dialplan/timeconditions.ts';
import { renderAnnouncements } from './dialplan/announcements.ts';
@@ -48,11 +51,13 @@ const BODIES: Partial<Record<GeneratedFileName, (snap: Snapshot) => string>> = {
'rtp_selfphoneng.conf': renderRtp,
'extensions_globals_selfphoneng.conf': renderGlobals,
'voicemail_selfphoneng.conf': renderVoicemail,
'queues_selfphoneng.conf': renderQueues,
'extensions_selfphoneng.conf': (snap) =>
[
STATIC_DIALPLAN,
renderLocalNumbers(snap),
renderRingGroups(snap),
renderQueueDialplan(snap),
renderIvrs(snap),
renderTimeConditions(snap),
renderAnnouncements(snap),
@@ -82,7 +87,7 @@ export type ApplyResult = {
export type GeneratorDeps = {
db: Db;
ami: Pick<AmiClient, 'isConnected' | 'command'>;
ami: Pick<AmiClient, 'isConnected' | 'command' | 'action'>;
/** /etc/asterisk/selfphoneng */
dir: string;
log: Pick<FastifyBaseLogger, 'info' | 'error'>;
@@ -185,9 +190,9 @@ export function createGenerator(deps: GeneratorDeps) {
const commands = [...new Set(reloadFor.map((f) => RELOADS[f]))].filter(
(c) => !(transportsChanged && c === 'module reload res_pjsip.so'),
);
// TODO(M8): after `queue reload all`, re-send QueuePause for paused members (03 §1 step 9).
const { sent, error } = await sendReloads(commands);
r.commands = sent;
if (sent.includes(RELOADS['queues_selfphoneng.conf'])) await resendPauses(db, ami);
if (error) Object.assign(r, { ok: false, error });
} catch (e) {
Object.assign(r, { ok: false, error: (e as Error).message });
@@ -236,6 +241,7 @@ export function createGenerator(deps: GeneratorDeps) {
else {
const { sent, error } = await sendReloads([...new Set(Object.values(RELOADS))]);
r.commands = sent;
if (sent.includes(RELOADS['queues_selfphoneng.conf'])) await resendPauses(db, ami);
if (error) Object.assign(r, { ok: false, error });
}
r.restartRequired = await getSetting(db, 'system.restartRequired');
+54
View File
@@ -11,6 +11,8 @@ import {
ivrOptions,
ivrs,
outboundRoutes,
queueMembers,
queues,
ringGroupMembers,
ringGroups,
soundFiles,
@@ -93,6 +95,26 @@ export type SnapRingGroup = {
noAnswerDestination: Destination;
};
export type SnapQueue = {
id: string;
number: string;
name: string;
strategy: 'ringall' | 'leastrecent' | 'fewestcalls' | 'random' | 'rrmemory' | 'linear';
memberRingSeconds: number;
retrySeconds: number;
wrapupSeconds: number;
maxWaitSeconds: number;
maxCallers: number;
mohClass: string;
announcePosition: boolean;
announceEverySeconds: number;
joinWhenNoAgents: boolean;
record: boolean;
callerIdNamePrefix: string | null;
members: { number: string; name: string; penalty: number }[];
timeoutDestination: Destination;
};
export type SnapIvr = {
id: string;
number: string;
@@ -144,6 +166,7 @@ export type Snapshot = {
trunks: SnapTrunk[];
routes: SnapRoute[];
ringGroups: SnapRingGroup[];
queues: SnapQueue[];
ivrs: SnapIvr[];
timeConditions: SnapTimeCondition[];
holidays: SnapHoliday[];
@@ -187,6 +210,16 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
const trunkRows = await db.select().from(trunks);
const routeRows = await db.select().from(outboundRoutes);
const groupRows = await db.select().from(ringGroups);
const queueRows = await db.select().from(queues);
const agentRows = await db
.select({
queueId: queueMembers.queueId,
number: extensions.number,
name: extensions.name,
penalty: queueMembers.penalty,
})
.from(queueMembers)
.innerJoin(extensions, eq(extensions.id, queueMembers.extensionId));
const ivrRows = await db.select().from(ivrs);
const tcRows = await db.select().from(timeConditions);
const holidayRows = await db.select().from(holidays);
@@ -259,6 +292,27 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
members: memberRows.filter((m) => m.groupId === g.id).map((m) => m.number),
noAnswerDestination: g.noAnswerDestination as Destination,
})),
queues: queueRows.map((q) => ({
id: q.id,
number: q.number,
name: q.name,
strategy: q.strategy,
memberRingSeconds: q.memberRingSeconds,
retrySeconds: q.retrySeconds,
wrapupSeconds: q.wrapupSeconds,
maxWaitSeconds: q.maxWaitSeconds,
maxCallers: q.maxCallers,
mohClass: q.mohClass,
announcePosition: q.announcePosition,
announceEverySeconds: q.announceEverySeconds,
joinWhenNoAgents: q.joinWhenNoAgents,
record: q.record,
callerIdNamePrefix: q.callerIdNamePrefix,
members: agentRows
.filter((m) => m.queueId === q.id)
.map(({ number, name, penalty }) => ({ number, name, penalty })),
timeoutDestination: q.timeoutDestination as Destination,
})),
routes: routeRows.map((r) => ({
id: r.id,
name: r.name,
+40
View File
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// queues_selfphoneng.conf (03 §6.8): one section per queue, named by its number. Agents are Local
// channels through sp-queue-member; their state follows the user's hint.
import { byNumber, type Snapshot } from './load.ts';
import * as s from './sanitize.ts';
export function renderQueues(snap: Snapshot): string {
return [...snap.queues]
.sort(byNumber)
.map((q) => {
const num = s.digits(q.number);
const n = (v: number) => s.digits(String(v));
return [
`; ─── Queue ${num} "${s.name(q.name)}"`,
`[${num}]`,
`strategy = ${q.strategy}`,
`timeout = ${n(q.memberRingSeconds)}`,
`retry = ${n(q.retrySeconds)}`,
`wrapuptime = ${n(q.wrapupSeconds)}`,
`maxlen = ${n(q.maxCallers)}`,
`musicclass = ${s.ident(q.mohClass)}`,
`announce-frequency = ${q.announcePosition ? n(q.announceEverySeconds) : 0}`,
`announce-position = ${q.announcePosition ? 'yes' : 'no'}`,
'ringinuse = no',
'setinterfacevar = yes',
...(q.joinWhenNoAgents
? []
: [
'joinempty = paused,unavailable,invalid,unknown',
'leavewhenempty = paused,unavailable,invalid,unknown',
]),
...[...q.members].sort(byNumber).map((m) => {
const ext = s.digits(m.number);
return `member => Local/${ext}@sp-queue-member/n,${n(m.penalty)},${s.name(m.name) || ext},hint:${ext}@sp-local-numbers`;
}),
'',
].join('\n');
})
.join('\n');
}
+4
View File
@@ -15,6 +15,7 @@ import { watchVoicemail } from './services/voicemail.ts';
import { syncAstDb } from './services/astdb.ts';
import { watchToggles } from './services/toggles.ts';
import { watchCdr } from './services/cdr.ts';
import { watchQueueCalls } from './services/queuestats.ts';
import { cleanupRecordings } from './routes/recordings.ts';
import { getSetting } from './services/settings.ts';
import { todayIn } from './generator/load.ts';
@@ -50,6 +51,8 @@ const generator = createGenerator({
data: { ok: r.ok, error: r.error, restartRequired: r.restartRequired },
});
void live.loadFromDb().catch((err) => app.log.error({ err }, 'live reload failed'));
if (ami.isConnected())
void live.refreshQueues().catch((err) => app.log.error({ err }, 'queue reload failed'));
},
});
app.decorate('generator', generator);
@@ -58,6 +61,7 @@ const sendMail = smtpMailer(db);
app.decorate('sendMail', sendMail);
watchEmergencies({ db, ami, live, send: sendMail, log: app.log }, ami);
watchToggles({ db, ami, live, log: app.log }, ami);
watchQueueCalls({ db, log: app.log }, ami);
const history = watchCdr({ db, log: app.log, recordingsDir: resolve(config.RECORDINGS_DIR) }, ami);
const voicemail = watchVoicemail(
{
+22 -3
View File
@@ -2,8 +2,10 @@
// GET /api/live WebSocket (04 §5.2): a snapshot on connect, then batched deltas.
import type { FastifyInstance } from 'fastify';
import type { WebSocket } from '@fastify/websocket';
import { eq } from 'drizzle-orm';
import type { ExtensionLive, LiveMessage } from '@selfphoneng/shared';
import { COOKIE, getSessionUser } from '../auth/sessions.ts';
import { extensions } from '../db/schema.ts';
import type { LiveState } from './state.ts';
const BATCH_MS = 250;
@@ -13,6 +15,8 @@ type Client = {
ws: WebSocket;
admin: boolean;
userId: string;
/** The user's extension numbers: they see the queues they're an agent of. */
exts: string[];
/** `${type}:${key}` → latest message, so many updates of one thing collapse into one. */
pending: Map<string, LiveMessage>;
alive: boolean;
@@ -40,7 +44,9 @@ export async function liveRoutes(app: FastifyInstance) {
? `toast:${m.data.message}`
: m.type === 'call.updated' || m.type === 'call.removed'
? `call:${m.data.linkedid}` // a removal replaces a pending update
: m.type;
: m.type === 'queue.updated' || m.type === 'queue.removed'
? `queue:${m.data.number}`
: m.type;
const maySee = (c: Client, m: LiveMessage) =>
m.type === 'voicemail.new' || m.type === 'toast'
@@ -60,7 +66,12 @@ export async function liveRoutes(app: FastifyInstance) {
type: m.type,
data: app.live.view(m.data as ExtensionLive & { contacts: [] }, c.admin),
}
: m;
: // Not (or no longer) an agent of that queue: it goes away from their view.
m.type === 'queue.updated' &&
!c.admin &&
!m.data.members.some((x) => c.exts.includes(x.ext))
? { type: 'queue.removed', data: { number: m.data.number } }
: m;
const flush = setInterval(() => {
for (const c of clients) {
@@ -92,10 +103,15 @@ export async function liveRoutes(app: FastifyInstance) {
const found = token ? await getSessionUser(app.db, token) : null;
// Browsers can't read an HTTP 401 on an upgrade, so open and close with 4401 (04 §5.2).
if (!found || found.user.mustChangePassword) return ws.close(4401, 'unauthorized');
const mine = await app.db
.select({ n: extensions.number })
.from(extensions)
.where(eq(extensions.userId, found.user.id));
const c: Client = {
ws,
admin: found.user.role === 'admin',
userId: found.user.id,
exts: mine.map((e) => e.n),
pending: new Map(),
alive: true,
};
@@ -103,7 +119,10 @@ export async function liveRoutes(app: FastifyInstance) {
ws.on('pong', () => (c.alive = true));
ws.on('close', () => clients.delete(c));
ws.send(
JSON.stringify({ type: 'snapshot', data: app.live.snapshot(c.admin) } satisfies LiveMessage),
JSON.stringify({
type: 'snapshot',
data: app.live.snapshot(c.admin, c.exts),
} satisfies LiveMessage),
);
});
}
+117
View File
@@ -0,0 +1,117 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Events below are trimmed from a real Asterisk 24 call into queue 550 (cdr-record.ts).
import { describe, expect, it } from 'vitest';
import type { QueueLive } from '@selfphoneng/shared';
import { QueueTracker } from './queues.ts';
const DEFS = [{ id: 'q-550', number: '550', name: 'Support' }];
const IFACE = 'Local/101@sp-queue-member/n';
const CALLER = { Queue: '550', Uniqueid: '1792369984.35', Linkedid: '1792369984.35' };
function tracker() {
const sent: (QueueLive | null)[] = [];
const t = new QueueTracker((_n, q) => sent.push(q));
return { t, sent };
}
describe('QueueTracker', () => {
it('loads QueueStatus and follows a caller from joining to an agent', () => {
const { t, sent } = tracker();
t.load(DEFS, [
{ Event: 'QueueParams', Queue: '550' },
{
Event: 'QueueMember',
Queue: '550',
Name: 'Alice',
Location: IFACE,
StateInterface: 'hint:101@sp-local-numbers',
Status: '1',
Paused: '0',
CallsTaken: '0',
LastCall: '0',
},
{
Event: 'QueueEntry',
Queue: '550',
Uniqueid: '9.1',
CallerIDNum: '13125550001',
Wait: '30',
},
{ Event: 'QueueStatusComplete' },
]);
expect(t.list()[0]).toMatchObject({
id: 'q-550',
name: 'Support',
members: [{ ext: '101', paused: false, state: 'idle', callsTaken: 0 }],
waiting: [{ callerNumber: '13125550001', linkedid: '9.1' }],
});
const since = Date.parse(t.list()[0]!.waiting[0]!.since);
expect(Date.now() - since).toBeGreaterThanOrEqual(29_000);
t.handle({
Event: 'QueueCallerJoin',
...CALLER,
CallerIDNum: '13125559999',
CallerIDName: 'Mobile',
});
expect(t.list()[0]!.waiting.map((w) => w.callerNumber)).toEqual(['13125550001', '13125559999']);
t.handle({
Event: 'QueueMemberStatus',
Queue: '550',
MemberName: 'Alice',
Interface: IFACE,
Status: '6',
Paused: '0',
CallsTaken: '0',
LastCall: '0',
});
expect(t.list()[0]!.members[0]!.state).toBe('ringing');
t.handle({ Event: 'QueueCallerLeave', ...CALLER, Position: '1', Count: '0' });
t.handle({ Event: 'AgentConnect', ...CALLER, HoldTime: '0', Interface: IFACE });
t.handle({
Event: 'QueueMemberStatus',
Queue: '550',
MemberName: 'Alice',
Interface: IFACE,
Status: '1',
Paused: '0',
CallsTaken: '1',
LastCall: '1792369988',
InCall: '0',
});
expect(t.list()[0]).toMatchObject({
waiting: [{ callerNumber: '13125550001' }],
members: [
{
ext: '101',
state: 'idle',
callsTaken: 1,
lastCall: new Date(1792369988000).toISOString(),
},
],
});
expect(sent.length).toBe(5); // load, join, ringing, leave, idle: AgentConnect changes nothing
});
it('pause, unknown queues, and queues that went away', () => {
const { t, sent } = tracker();
t.load(DEFS, [
{ Event: 'QueueMember', Queue: '550', Location: IFACE, Status: '1', Paused: '0' },
]);
t.handle({
Event: 'QueueMemberPause',
Queue: '550',
Interface: IFACE,
Paused: '1',
PausedReason: '',
});
expect(t.list()[0]!.members[0]).toMatchObject({ paused: true, state: 'idle' });
sent.length = 0;
t.handle({ Event: 'QueueCallerJoin', Queue: '999', Uniqueid: '1.1' });
t.handle({ Event: 'QueueCallerAbandon', Queue: '550', Uniqueid: 'nobody' });
expect(sent).toEqual([]);
t.load([], []);
expect(sent).toEqual([null]);
expect(t.list()).toEqual([]);
});
});
+143
View File
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Queues right now (04 §5.1): waiting callers and agents. Loaded from QueueStatus on connect and
// after config changes, then kept current from app_queue's events.
import type { ExtensionState, QueueLive } from '@selfphoneng/shared';
import { field, type AmiMessage } from '../ami/events.ts';
/** QueueMember / QueueMemberStatus Status is a device state (AST_DEVICE_*). */
export function memberState(status: number): ExtensionState {
switch (status) {
case 1:
return 'idle';
case 2:
return 'inuse';
case 3:
return 'busy';
case 6:
return 'ringing';
case 7:
return 'ringinuse';
case 8:
return 'onhold';
default:
return 'unavailable'; // 0 unknown, 4 invalid, 5 unavailable
}
}
const extOf = (iface: string | undefined) =>
/^Local\/(\d+)@sp-queue-member/.exec(iface ?? '')?.[1] ?? null;
type Waiting = QueueLive['waiting'][number] & { uniqueid: string };
type Q = {
id: string;
name: string;
waiting: Waiting[];
members: Map<string, QueueLive['members'][number]>;
};
export class QueueTracker {
private readonly queues = new Map<string, Q>();
private readonly emit: (number: string, q: QueueLive | null) => void;
constructor(emit: (number: string, q: QueueLive | null) => void) {
this.emit = emit;
}
list(): QueueLive[] {
return [...this.queues.keys()].sort().map((n) => this.view(n)!);
}
private view(number: string): QueueLive | null {
const q = this.queues.get(number);
if (!q) return null;
return {
id: q.id,
number,
name: q.name,
waiting: q.waiting.map(({ uniqueid: _u, ...w }) => w),
members: [...q.members.values()].sort((a, b) => a.ext.localeCompare(b.ext)),
};
}
/** Queues from the DB, and QueueStatus items (QueueParams, QueueMember, QueueEntry). */
load(defs: { id: string; number: string; name: string }[], items: AmiMessage[]): void {
const before = new Set(this.queues.keys());
this.queues.clear();
for (const d of defs)
this.queues.set(d.number, { id: d.id, name: d.name, waiting: [], members: new Map() });
const now = Date.now();
for (const m of items) {
const q = this.queues.get(field(m, 'Queue') ?? '');
if (!q) continue;
if (field(m, 'Event') === 'QueueMember') this.member(q, m);
if (field(m, 'Event') === 'QueueEntry')
q.waiting.push({
uniqueid: field(m, 'Uniqueid') ?? '',
linkedid: field(m, 'Linkedid') ?? field(m, 'Uniqueid') ?? '',
callerNumber: field(m, 'CallerIDNum') ?? '',
callerName: field(m, 'CallerIDName') || undefined,
since: new Date(now - Number(field(m, 'Wait') ?? 0) * 1000).toISOString(),
});
}
for (const n of before) if (!this.queues.has(n)) this.emit(n, null);
for (const n of this.queues.keys()) this.emit(n, this.view(n));
}
private member(q: Q, m: AmiMessage): boolean {
const ext = extOf(field(m, 'Interface') ?? field(m, 'Location') ?? field(m, 'StateInterface'));
if (!ext) return false;
const prev = q.members.get(ext);
const last = Number(field(m, 'LastCall') ?? 0);
q.members.set(ext, {
ext,
paused:
field(m, 'Paused') !== undefined ? field(m, 'Paused') === '1' : (prev?.paused ?? false),
state:
field(m, 'Status') !== undefined
? memberState(Number(field(m, 'Status')))
: (prev?.state ?? 'unavailable'),
callsTaken: Number(field(m, 'CallsTaken') ?? prev?.callsTaken ?? 0),
lastCall: last > 0 ? new Date(last * 1000).toISOString() : prev?.lastCall,
});
return true;
}
handle(m: AmiMessage): void {
const number = field(m, 'Queue') ?? '';
const q = this.queues.get(number);
if (!q) return;
const uniqueid = field(m, 'Uniqueid') ?? '';
switch (field(m, 'Event')) {
case 'QueueCallerJoin':
q.waiting = [
...q.waiting.filter((w) => w.uniqueid !== uniqueid),
{
uniqueid,
linkedid: field(m, 'Linkedid') ?? uniqueid,
callerNumber: field(m, 'CallerIDNum') ?? '',
callerName: field(m, 'CallerIDName') || undefined,
since: new Date().toISOString(),
},
];
break;
case 'QueueCallerLeave':
case 'QueueCallerAbandon':
if (!q.waiting.some((w) => w.uniqueid === uniqueid)) return;
q.waiting = q.waiting.filter((w) => w.uniqueid !== uniqueid);
break;
case 'QueueMemberStatus':
case 'QueueMemberPause':
case 'QueueMemberAdded':
if (!this.member(q, m)) return;
break;
case 'QueueMemberRemoved': {
const ext = extOf(field(m, 'Interface'));
if (!ext || !q.members.delete(ext)) return;
break;
}
default:
return;
}
this.emit(number, this.view(number));
}
}
+29 -4
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// In-memory live state (04 §5.1). Rebuilt from Asterisk on every AMI connect, then kept current
// from AMI events. M2: extension states and registered devices; calls, queues and parking later.
// from AMI events: extension states and registered devices, calls, queues. Parking with M8-T2.
import { EventEmitter } from 'node:events';
import { eq } from 'drizzle-orm';
import type {
@@ -14,8 +14,9 @@ import type {
import type { AmiClient } from '../ami/client.ts';
import { field, type AmiMessage } from '../ami/events.ts';
import type { Db } from '../db/client.ts';
import { extensions, trunks, userRuntimeState } from '../db/schema.ts';
import { extensions, queues, trunks, userRuntimeState } from '../db/schema.ts';
import { CallTracker } from './calls.ts';
import { QueueTracker } from './queues.ts';
/** ExtensionStatus/ExtensionStateList Status is a bit mask (04 §5.1). */
export function stateFromStatus(status: number): ExtensionState {
@@ -69,6 +70,13 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
call ? { type: 'call.updated', data: call } : { type: 'call.removed', data: { linkedid } },
),
);
/** Waiting callers and agents (admins, and each queue's agents). */
readonly queues = new QueueTracker((number, q) =>
this.emit(
'message',
q ? { type: 'queue.updated', data: q } : { type: 'queue.removed', data: { number } },
),
);
private readonly db: Db;
private readonly ami: Ami;
@@ -80,14 +88,17 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
ami.on('disconnected', () => this.setAmi(false));
}
snapshot(admin: boolean): LiveSnapshot {
/** `myExts`: a user's own extension numbers (they see the queues they're an agent of). */
snapshot(admin: boolean, myExts: string[] = []): LiveSnapshot {
return {
extensions: [...this.exts.values()].map((e) => this.view(e, admin)),
queues: this.queues
.list()
.filter((q) => admin || q.members.some((m) => myExts.includes(m.ext))),
...(admin
? {
trunks: [...this.trunks.values()].map((t) => ({ ...t })),
calls: this.calls.list(),
queues: [],
parked: [],
}
: {}),
@@ -230,6 +241,18 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
}
}
/** Queues from the DB and app_queue (on connect, and after each config apply). */
async refreshQueues(): Promise<void> {
const defs = await this.db
.select({ id: queues.id, number: queues.number, name: queues.name })
.from(queues);
// QueueStatus errors when app_queue has no queues yet (the apply is still on its way).
const items = defs.length
? await this.ami.actionList({ Action: 'QueueStatus' }).catch(() => [])
: [];
this.queues.load(defs, items);
}
/** Initial load on every AMI connect (04 §5.1). */
async loadFromAsterisk(): Promise<void> {
await this.loadFromDb();
@@ -242,6 +265,7 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
this.ami.actionList({ Action: 'CoreShowChannels' }),
]);
this.calls.load(channels);
await this.refreshQueues();
for (const m of states) this.onExtensionStatus(m, false);
for (const m of contacts) {
const endpoint = field(m, 'Endpoint') ?? field(m, 'EndpointName') ?? '';
@@ -263,6 +287,7 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
handleEvent(m: AmiMessage): void {
this.calls.handle(m);
this.queues.handle(m);
switch (field(m, 'Event')) {
case 'ExtensionStatus':
return this.onExtensionStatus(m, true);
+23 -1
View File
@@ -20,7 +20,14 @@ 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, userRuntimeState, voicemailMessages } from '../db/schema.ts';
import {
extensions,
queueMembers,
queues,
userRuntimeState,
voicemailMessages,
} from '../db/schema.ts';
import { setPaused } from '../services/queues.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';
@@ -107,6 +114,21 @@ export async function meRoutes(app: FastifyInstance) {
app.get('/api/me', async (req) => myState(req.user!.id));
// Queue login from Home (05 §4, 04 §7.3), the same as *45<queue> on a phone.
for (const action of ['pause', 'unpause'] as const)
app.post(`/api/me/queues/:id/${action}`, async (req) => {
const { id } = IdParams.parse(req.params);
const ext = await myExtension(req.user!.id);
const [q] = await db
.select({ id: queues.id, number: queues.number })
.from(queueMembers)
.innerJoin(queues, eq(queues.id, queueMembers.queueId))
.where(and(eq(queueMembers.queueId, id), eq(queueMembers.extensionId, ext.id)));
if (!q) throw notFound("You aren't an agent of that queue");
await setPaused(db, app.ami, q, ext, action === 'pause');
return { ok: true };
});
/** DND, forwards and voicemail options: PostgreSQL first, then AstDB (04 §5.5). */
app.patch('/api/me', async (req) => {
const body = MeUpdateSchema.parse(req.body);
+226
View File
@@ -0,0 +1,226 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/queues (04 §7.3): agents are sent as `members: [{extensionId, penalty}]`. Admin only.
import { and, asc, eq, inArray, notInArray } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
QueueCreateSchema,
QueueUpdateSchema,
type Destination,
type ListResponse,
type QueueDto,
} from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts';
import type { Db } from '../db/client.ts';
import { extensions, queueMembers, queueMemberState, queues } from '../db/schema.ts';
import { badRequest, conflict, notFound } from '../errors.ts';
import { audit } from '../services/audit.ts';
import {
assertDestinationExists,
describeDestination,
findReferences,
} from '../services/destinations.ts';
import { assertNumberAvailable } from '../services/numbering.ts';
import { setPaused } from '../services/queues.ts';
import { queueStats, StatsQuery } from '../services/queuestats.ts';
type Row = typeof queues.$inferSelect;
type Member = { extensionId: string; penalty: number };
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
async function membersOf(db: Db, id: string) {
return db
.select({
extensionId: queueMembers.extensionId,
penalty: queueMembers.penalty,
paused: queueMemberState.paused,
})
.from(queueMembers)
.innerJoin(extensions, eq(extensions.id, queueMembers.extensionId))
.leftJoin(
queueMemberState,
and(
eq(queueMemberState.queueId, queueMembers.queueId),
eq(queueMemberState.extensionId, queueMembers.extensionId),
),
)
.where(eq(queueMembers.queueId, id))
.orderBy(asc(extensions.number));
}
async function toDto(db: Db, q: Row): Promise<QueueDto> {
const timeoutDestination = q.timeoutDestination as Destination;
const { createdAt, updatedAt: _u, ...rest } = q;
return {
...rest,
members: (await membersOf(db, q.id)).map((m) => ({ ...m, paused: m.paused ?? false })),
timeoutDestination,
timeoutSummary: await describeDestination(db, timeoutDestination),
createdAt: createdAt.toISOString(),
};
}
const IdParams = z.object({ id: z.uuid() });
const MemberParams = z.object({ id: z.uuid(), extId: z.uuid() });
async function assertMembers(db: Db, ms: Member[]) {
if (!ms.length) return;
const ids = ms.map((m) => m.extensionId);
const found = await db
.select({ id: extensions.id })
.from(extensions)
.where(inArray(extensions.id, ids));
if (found.length !== ids.length) throw badRequest('One of the agents no longer exists');
}
/** Replaces the agent list; removed agents lose their pause state. */
async function setMembers(db: Db | Tx, id: string, ms: Member[]) {
await db.delete(queueMembers).where(eq(queueMembers.queueId, id));
if (ms.length) await db.insert(queueMembers).values(ms.map((m) => ({ queueId: id, ...m })));
const kept = ms.map((m) => m.extensionId);
await db
.delete(queueMemberState)
.where(
and(
eq(queueMemberState.queueId, id),
kept.length ? notInArray(queueMemberState.extensionId, kept) : undefined,
),
);
}
const isUniqueViolation = (e: unknown) =>
((e as { cause?: { code?: string } }).cause?.code ?? (e as { code?: string }).code) === '23505';
export async function queueRoutes(app: FastifyInstance) {
const { db } = app;
app.addHook('preHandler', requireAdmin);
async function getQueue(id: string) {
const [q] = await db.select().from(queues).where(eq(queues.id, id));
if (!q) throw notFound('Queue not found');
return q;
}
app.get('/api/queues', async (): Promise<ListResponse<QueueDto>> => {
const rows = await db.select().from(queues).orderBy(asc(queues.number));
return { items: await Promise.all(rows.map((q) => toDto(db, q))), nextCursor: null };
});
app.get('/api/queues/:id', async (req) =>
toDto(db, await getQueue(IdParams.parse(req.params).id)),
);
app.post('/api/queues', async (req, reply) => {
const { members = [], ...body } = QueueCreateSchema.parse(req.body);
await assertNumberAvailable(db, body.number, 'queue');
await assertDestinationExists(db, body.timeoutDestination);
await assertMembers(db, members);
let created: Row;
try {
created = await db.transaction(async (tx) => {
const [q] = (await tx.insert(queues).values(body).returning()) as [Row];
await setMembers(tx, q.id, members);
return q;
});
} catch (e) {
if (isUniqueViolation(e)) throw conflict(`${body.number} is already taken`);
throw e;
}
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'create',
entityType: 'queue',
entityId: created.id,
summary: `Created queue ${created.number} (${created.name})`,
diff: { after: { ...body, members } },
});
app.generator.requestApply('api-change');
return reply.code(201).send(await toDto(db, created));
});
app.patch('/api/queues/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const { members, ...body } = QueueUpdateSchema.parse(req.body);
const before = await getQueue(id);
if (body.number && body.number !== before.number)
await assertNumberAvailable(db, body.number, 'queue', id);
if (body.timeoutDestination) {
if (body.timeoutDestination.type === 'queue' && body.timeoutDestination.id === id)
throw badRequest("A queue can't send callers who waited too long back to itself");
await assertDestinationExists(db, body.timeoutDestination);
}
if (members) await assertMembers(db, members);
let after: Row;
try {
after = await db.transaction(async (tx) => {
const [q] = Object.keys(body).length
? ((await tx.update(queues).set(body).where(eq(queues.id, id)).returning()) as [Row])
: [before];
if (members) await setMembers(tx, id, members);
return q;
});
} catch (e) {
if (isUniqueViolation(e)) throw conflict(`${body.number} is already taken`);
throw e;
}
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'update',
entityType: 'queue',
entityId: id,
summary: `Updated queue ${after.number} (${after.name})`,
diff: { after: { ...body, ...(members ? { members } : {}) } },
});
app.generator.requestApply('api-change');
return toDto(db, after);
});
app.delete('/api/queues/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const q = await getQueue(id);
const usedBy = await findReferences(db, 'queue', id);
if (usedBy.length)
throw conflict(`Queue ${q.name} is still used by ${usedBy.join(', ')}`, { usedBy });
await db.delete(queues).where(eq(queues.id, id));
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'delete',
entityType: 'queue',
entityId: id,
summary: `Deleted queue ${q.number} (${q.name})`,
diff: { before: q },
});
app.generator.requestApply('api-change');
return { ok: true };
});
for (const action of ['pause', 'unpause'] as const)
app.post(`/api/queues/:id/members/:extId/${action}`, async (req) => {
const { id, extId } = MemberParams.parse(req.params);
const q = await getQueue(id);
const [m] = await db
.select({ id: extensions.id, number: extensions.number })
.from(queueMembers)
.innerJoin(extensions, eq(extensions.id, queueMembers.extensionId))
.where(and(eq(queueMembers.queueId, id), eq(queueMembers.extensionId, extId)));
if (!m) throw notFound(`That extension isn't an agent of queue ${q.number}`);
await setPaused(db, app.ami, q, m, action === 'pause');
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: `queue_${action}`,
entityType: 'queue',
entityId: id,
summary: `${action === 'pause' ? 'Paused' : 'Unpaused'} ${m.number} in queue ${q.number}`,
});
return { ok: true };
});
app.get('/api/queues/:id/stats', async (req) => {
const q = await getQueue(IdParams.parse(req.params).id);
return queueStats(db, q, StatsQuery.parse(req.query));
});
}
+23 -2
View File
@@ -3,7 +3,14 @@
// is the truth: routes write it first, then call one of these; every AMI connect resyncs (04 §5.5).
import { eq, isNotNull } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { blockedNumbers, extensions, timeConditions, userRuntimeState } from '../db/schema.ts';
import {
blockedNumbers,
extensions,
queueMemberState,
queues,
timeConditions,
userRuntimeState,
} from '../db/schema.ts';
export type AstDbAmi = { action(fields: Record<string, string>): Promise<unknown> };
@@ -34,7 +41,11 @@ export const setForward = (
export const setBlock = (ami: AstDbAmi, number: string, on: boolean) =>
on ? put(ami, `BLOCK/${number}`, '1') : del(ami, `BLOCK/${number}`);
/** Wipe our keys and write them again from PostgreSQL. M8 adds queue pauses. */
/** The agent is logged out of (paused in) that queue; *45<queue> reads it (03 §6.13). */
export const setQueuePause = (ami: AstDbAmi, queue: string, ext: string, on: boolean) =>
on ? put(ami, `QPAUSE/${queue}/${ext}`, '1') : del(ami, `QPAUSE/${queue}/${ext}`);
/** Wipe our keys and write them again from PostgreSQL. */
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
@@ -53,4 +64,14 @@ export async function syncAstDb(db: Db, ami: AstDbAmi): Promise<void> {
}
for (const b of await db.select({ n: blockedNumbers.number }).from(blockedNumbers))
await setBlock(ami, b.n, true);
for (const p of await pausedMembers(db)) await setQueuePause(ami, p.queue, p.ext, true);
}
/** Paused agents by queue and extension number. */
export const pausedMembers = (db: Db) =>
db
.select({ queue: queues.number, ext: extensions.number })
.from(queueMemberState)
.innerJoin(queues, eq(queues.id, queueMemberState.queueId))
.innerJoin(extensions, eq(extensions.id, queueMemberState.extensionId))
.where(eq(queueMemberState.paused, true));
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Agent pause (logged out) state: PostgreSQL queue_member_state is the truth, AstDB SP/QPAUSE lets
// *45 toggle it, and app_queue's own pause flag is what stops calls (03 §6.8, 04 §5.5).
import type { Db } from '../db/client.ts';
import { queueMemberState } from '../db/schema.ts';
import { pausedMembers, setQueuePause, type AstDbAmi } from './astdb.ts';
export const memberInterface = (ext: string) => `Local/${ext}@sp-queue-member/n`;
const queuePause = (ami: AstDbAmi, queue: string, ext: string, paused: boolean) =>
ami.action({
Action: 'QueuePause',
Queue: queue,
Interface: memberInterface(ext),
Paused: paused ? 'true' : 'false',
});
/** Stores the state; with AMI up, also tells Asterisk (reconnects resync everything). */
export async function setPaused(
db: Db,
ami: AstDbAmi & { isConnected(): boolean },
q: { id: string; number: string },
ext: { id: string; number: string },
paused: boolean,
): Promise<void> {
await db
.insert(queueMemberState)
.values({ queueId: q.id, extensionId: ext.id, paused })
.onConflictDoUpdate({
target: [queueMemberState.queueId, queueMemberState.extensionId],
set: { paused },
});
if (!ami.isConnected()) return;
await setQueuePause(ami, q.number, ext.number, paused);
// A queue saved a moment ago may not be loaded yet; its reload re-sends the pauses.
await queuePause(ami, q.number, ext.number, paused).catch(() => undefined);
}
/** `queue reload all` unpauses everyone (03 §1 step 9): pause the paused ones again. */
export async function resendPauses(db: Db, ami: AstDbAmi): Promise<void> {
for (const p of await pausedMembers(db))
await queuePause(ami, p.queue, p.ext, true).catch(() => undefined); // not loaded yet: next apply
}
+142
View File
@@ -0,0 +1,142 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Queue stats (04 §7.3 GET /api/queues/:id/stats) from app_queue's AMI events, kept in
// queue_calls. CDRs can't do it: when no agent rings, the Queue part of a call disappears into
// the next leg's CDR (seen on a real call, M8-T1). Asterisk 24 raises QueueCallerAbandon for
// timeouts too, so the dialplan's UserEvent(SpQueueExit, Status: TIMEOUT|FULL|JOINEMPTY…) says
// which callers went on to the timeout destination (03 §6.8).
import type { EventEmitter } from 'node:events';
import { and, desc, eq, sql } from 'drizzle-orm';
import { z } from 'zod';
import type { QueueStats } from '@selfphoneng/shared';
import { field, type AmiMessage } from '../ami/events.ts';
import type { Db } from '../db/client.ts';
import { extensions, queueCalls, queues } from '../db/schema.ts';
import { todayIn } from '../generator/load.ts';
import { getSetting } from './settings.ts';
export const StatsQuery = z.object({
/** Company-time-zone days, inclusive; both default to today. */
from: z.iso.date().optional(),
to: z.iso.date().optional(),
});
const QUEUE_EVENTS = new Set(['QueueCallerJoin', 'AgentConnect', 'QueueCallerAbandon']);
/** One app_queue event (or SpQueueExit) → queue_calls. */
export async function recordQueueEvent(db: Db, m: AmiMessage): Promise<void> {
const ev = field(m, 'Event') === 'UserEvent' ? field(m, 'UserEvent') : field(m, 'Event');
if (!QUEUE_EVENTS.has(ev ?? '') && ev !== 'SpQueueExit') return;
const [q] = await db
.select({ id: queues.id })
.from(queues)
.where(eq(queues.number, field(m, 'Queue') ?? ''));
const uniqueid = field(m, 'Uniqueid') ?? '';
if (!q || !uniqueid) return;
if (ev === 'QueueCallerJoin') {
await db.insert(queueCalls).values({
queueId: q.id,
uniqueid,
linkedid: field(m, 'Linkedid') ?? uniqueid,
});
return;
}
const [row] = await db
.select()
.from(queueCalls)
.where(and(eq(queueCalls.queueId, q.id), eq(queueCalls.uniqueid, uniqueid)))
.orderBy(desc(queueCalls.id))
.limit(1);
const hold = Number(field(m, 'HoldTime'));
const waitSeconds = Number.isFinite(hold) ? hold : null;
if (ev === 'AgentConnect') {
if (!row) return;
const ext = /^Local\/(\d+)@/.exec(field(m, 'Interface') ?? '')?.[1] ?? '';
const [agent] = await db
.select({ id: extensions.id })
.from(extensions)
.where(eq(extensions.number, ext));
await db
.update(queueCalls)
.set({ outcome: 'answered', waitSeconds, agentExtensionId: agent?.id ?? null })
.where(eq(queueCalls.id, row.id));
} else if (ev === 'QueueCallerAbandon') {
if (row && !row.outcome)
await db
.update(queueCalls)
.set({ outcome: 'abandoned', waitSeconds })
.where(eq(queueCalls.id, row.id));
} else if (row && row.outcome !== 'answered') {
// SpQueueExit after waiting (it follows the Abandon event of a timeout)
await db
.update(queueCalls)
.set({
outcome: 'timed_out',
waitSeconds: row.waitSeconds ?? Math.round((Date.now() - row.joinedAt.getTime()) / 1000),
})
.where(eq(queueCalls.id, row.id));
} else if (!row) {
// Turned away at the door (full, or no agents): never joined, but offered all the same.
await db.insert(queueCalls).values({
queueId: q.id,
uniqueid,
linkedid: field(m, 'Linkedid') ?? uniqueid,
outcome: 'timed_out',
waitSeconds: 0,
});
}
}
/**
* ponytail: events that arrive while the api is down are lost (stats undercount for that
* time); the queue_log file has them if that ever matters.
*/
export function watchQueueCalls(
d: { db: Db; log: { error(o: object, msg: string): void } },
ami: Pick<EventEmitter, 'on'>,
) {
let chain = Promise.resolve(); // in order: an Abandon must land before its SpQueueExit
ami.on('event', (m: AmiMessage) => {
chain = chain
.then(() => recordQueueEvent(d.db, m))
.catch((err: unknown) => d.log.error({ err }, 'queue event failed'));
});
}
export async function queueStats(
db: Db,
q: { id: string; slaSeconds: number },
range: z.output<typeof StatsQuery>,
): Promise<QueueStats> {
const tz = await getSetting(db, 'general.timezone');
const today = todayIn(tz);
const from = range.from ?? range.to ?? today;
const to = range.to ?? range.from ?? today;
const answered = sql`${queueCalls.outcome} = 'answered'`;
const [r] = await db
.select({
offered: sql<number>`count(*)::int`,
answered: sql<number>`count(*) filter (where ${answered})::int`,
abandoned: sql<number>`count(*) filter (where ${queueCalls.outcome} = 'abandoned')::int`,
timedOut: sql<number>`count(*) filter (where ${queueCalls.outcome} = 'timed_out')::int`,
avgWait: sql<
number | null
>`round(avg(${queueCalls.waitSeconds}) filter (where ${answered}))::int`,
withinSla: sql<number>`count(*) filter (where ${answered} and ${queueCalls.waitSeconds} <= ${q.slaSeconds})::int`,
})
.from(queueCalls)
.where(
and(
eq(queueCalls.queueId, q.id),
sql`${queueCalls.joinedAt} >= (${from}::date)::timestamp at time zone ${tz}`,
sql`${queueCalls.joinedAt} < ((${to}::date + 1)::timestamp at time zone ${tz})`,
),
);
return {
offered: r!.offered,
answered: r!.answered,
abandoned: r!.abandoned,
timedOut: r!.timedOut,
avgWaitSeconds: r!.avgWait,
withinSlaPercent: r!.answered ? Math.round((100 * r!.withinSla) / r!.answered) : null,
};
}
+33 -3
View File
@@ -1,12 +1,19 @@
// 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): TCOVR (*31N/*32N/*33N), DND (*78/*79), CFU (*72<number>/*73).
// follows (04 §5.3): TCOVR (*31N/*32N/*33N), DND (*78/*79), CFU (*72<number>/*73), QPAUSE (*45N).
import type { EventEmitter } from 'node:events';
import { eq } from 'drizzle-orm';
import { and, 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 { extensions, timeConditions, userRuntimeState } from '../db/schema.ts';
import {
extensions,
queueMembers,
queueMemberState,
queues,
timeConditions,
userRuntimeState,
} from '../db/schema.ts';
import { HttpError } from '../errors.ts';
import { setForward, type AstDbAmi } from './astdb.ts';
import { audit } from './audit.ts';
@@ -32,6 +39,29 @@ export async function handleToggle(d: ToggleDeps, m: AmiMessage): Promise<void>
d.log.info({ id, value }, 'office hours override from a phone');
return;
}
if (key === 'QPAUSE') {
// The dialplan only raises it after app_queue accepted the change, so the agent exists.
const [agent] = await d.db
.select({ queueId: queues.id, extensionId: extensions.id })
.from(queueMembers)
.innerJoin(queues, eq(queues.id, queueMembers.queueId))
.innerJoin(extensions, eq(extensions.id, queueMembers.extensionId))
.where(
and(
eq(queues.number, field(m, 'Queue') ?? ''),
eq(extensions.number, field(m, 'Ext') ?? ''),
),
);
if (!agent) return;
await d.db
.insert(queueMemberState)
.values({ ...agent, paused: value === '1' })
.onConflictDoUpdate({
target: [queueMemberState.queueId, queueMemberState.extensionId],
set: { paused: value === '1' },
});
return; // app_queue's QueueMemberPause event updates the live view
}
if (key !== 'DND' && key !== 'CFU') return;
const [ext] = await d.db
.select({ id: extensions.id, number: extensions.number, userId: extensions.userId })
+1
View File
@@ -65,6 +65,7 @@ function snapshot(): Snapshot {
trunks: [],
routes: [],
ringGroups: [],
queues: [],
ivrs: [],
timeConditions: [],
holidays: [],
+51 -1
View File
@@ -4,7 +4,8 @@
// 101 Alice auto-answer 102 Bob never answers, voicemail on, rings 5 s
// 103 Carol auto-answer 104 Dave no phone, no voicemail
// Each scenario's Cdr events go to apps/api/test/fixtures/cdr/<name>.json.
// Queue abandon and park arrive with M8 (queues, parking).
// Queue 550 has Alice (answers); queue 551 has Bob (never answers) and sends callers who wait
// 8 s to his voicemail. Parking arrives with M8-T2.
// Usage: node --conditions=source apps/api/test/asterisk/cdr-record.ts
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
@@ -34,6 +35,7 @@ export const CDR_IDS = {
did: (n: string) => `11111111-1111-4111-8111-11111111${n}`, // 1234 / 1235 / 1236
ringGroup: '22222222-2222-4222-8222-222222222500',
ivr: '33333333-3333-4333-8333-333333333600',
queue: (n: string) => `44444444-4444-4444-8444-444444444${n}`,
};
const PASS = (n: string) => `CdrRecord${n}xxxxxxxxxxxxx`;
@@ -93,6 +95,8 @@ function snapshot(): Snapshot {
did('1234', { type: 'user', id: CDR_IDS.ext('101') }),
did('1235', { type: 'ring_group', id: CDR_IDS.ringGroup }),
did('1236', { type: 'ivr', id: CDR_IDS.ivr }),
did('1237', { type: 'queue', id: CDR_IDS.queue('550') }),
did('1238', { type: 'queue', id: CDR_IDS.queue('551') }),
],
trunks: [
{
@@ -125,6 +129,32 @@ function snapshot(): Snapshot {
noAnswerDestination: { type: 'hangup' },
},
],
queues: (['550', '551'] as const).map((n) => ({
id: CDR_IDS.queue(n),
number: n,
name: n === '550' ? 'Support' : 'Billing',
strategy: 'rrmemory' as const,
memberRingSeconds: 15,
retrySeconds: 1,
wrapupSeconds: 0,
maxWaitSeconds: n === '550' ? 60 : 8,
maxCallers: 0,
mohClass: 'default',
announcePosition: false,
announceEverySeconds: 60,
joinWhenNoAgents: false,
record: false,
callerIdNamePrefix: null,
members: [
n === '550'
? { number: '101', name: 'Alice', penalty: 0 }
: { number: '102', name: 'Bob', penalty: 0 },
],
timeoutDestination:
n === '550'
? ({ type: 'hangup' } as const)
: ({ type: 'voicemail', id: CDR_IDS.ext('102') } as const),
})),
ivrs: [
{
id: CDR_IDS.ivr,
@@ -394,6 +424,26 @@ async function main() {
await sleep(4000);
});
await scenario('queue-answered', async () => {
fromMobile('3125551237');
await waitFor('101 answers', () => !!pbxChannel('PJSIP/101-'), 20);
await sleep(4000);
});
await scenario('queue-abandoned', async () => {
fromMobile('3125551238');
await waitFor('102 rings', () => !!pbxChannel('PJSIP/102-'), 20);
await sleep(3000); // the caller gives up before the 8 s limit
});
await scenario('queue-timeout-voicemail', async () => {
fromMobile('3125551238');
await waitFor(
'voicemail',
() => /VoiceMail/.test(cli(PBX, 'core show channels concise')),
20,
);
await sleep(6000);
});
mkdirSync(OUT, { recursive: true });
for (const [name, evs] of Object.entries(recorded)) {
if (!evs.length) throw new Error(`${name}: no Cdr events`);
+13 -2
View File
@@ -28,7 +28,12 @@ function expectations(dir) {
const contexts = [...ext.matchAll(/^\[([^\]]+)\]$/gm)].map((m) => m[1]);
const vm = readFileSync(join(dir, 'voicemail_selfphoneng.conf'), 'utf8');
const mailboxes = [...vm.matchAll(/^(\d+) =>/gm)].map((m) => m[1]);
return { endpoints, contexts, mailboxes };
// queue number → how many agents it lists
const queues = readFileSync(join(dir, 'queues_selfphoneng.conf'), 'utf8')
.split(/^\[/m)
.slice(1)
.map((sec) => [sec.slice(0, sec.indexOf(']')), (sec.match(/^member =>/gm) ?? []).length]);
return { endpoints, contexts, mailboxes, queues };
}
async function check(name) {
@@ -57,7 +62,7 @@ async function check(name) {
if (!booted) problems.push('Asterisk did not finish booting');
await sleep(500);
const { endpoints, contexts, mailboxes } = expectations(tmp);
const { endpoints, contexts, mailboxes, queues } = expectations(tmp);
const shown = cli('pjsip show endpoints').out;
for (const e of endpoints)
if (!new RegExp(`Endpoint:\\s+${e}[\\s/]`).test(shown))
@@ -70,6 +75,12 @@ async function check(name) {
for (const m of mailboxes)
if (!new RegExp(`^default\\s+${m}\\s`, 'm').test(users))
problems.push(`mailbox ${m} not loaded`);
for (const [q, members] of queues) {
const shownQ = cli(`queue show ${q}`).out;
const listed = (shownQ.match(/Local\/\d+@sp-queue-member/g) ?? []).length;
if (!shownQ.startsWith(`${q} has`) || listed !== members)
problems.push(`queue ${q}: expected ${members} members, got: ${shownQ.trim()}`);
}
// Asterisk colors its log: strip the escape codes before looking for ERROR[.
// eslint-disable-next-line no-control-regex -- matching ANSI escapes is the point
const log = docker('logs', NAME).out.replace(/\x1b\[[0-9;]*m/g, '');
+1
View File
@@ -104,6 +104,7 @@ function snapshot(): Snapshot {
],
trunks: [trunk(GOOD, 'voip.ms', SUB.pass), trunk(BAD, 'wrong password', 'NotThePassword1')],
ringGroups: [],
queues: [],
ivrs: [],
timeConditions: [],
holidays: [],
+1
View File
@@ -60,6 +60,7 @@ function snapshot(): Snapshot {
trunks: [],
routes: [],
ringGroups: [],
queues: [],
ivrs: [],
timeConditions: [],
holidays: [],
+2 -1
View File
@@ -56,7 +56,8 @@ describe.skipIf(!hasTestDb)('call history API', () => {
destination: { type: 'hangup' },
label: n === '1234' ? 'Main line' : null,
});
for (const f of readdirSync(FIXTURES)) {
// The M7 scenarios; queue calls (M8) are checked in cdr.test.ts and queues.test.ts.
for (const f of readdirSync(FIXTURES).filter((f) => !f.startsWith('queue-'))) {
const events: AmiMessage[] = JSON.parse(readFileSync(new URL(f, FIXTURES), 'utf8'));
for (const e of events) await t.db.insert(cdr).values(toCdrRow(e));
await aggregateCall(t.db, String(events[0]!.LinkedID));
+34 -1
View File
@@ -110,6 +110,39 @@ const SCENARIOS: Record<string, Expected> = {
rang: ['101', '103'],
talkSeconds: 4,
},
'queue-answered': {
direction: 'inbound',
status: 'answered',
fromNumber: '13125559999',
fromName: 'Mobile',
toNumber: '13125551237',
did: '13125551237',
answeredBy: '101',
rang: ['101'],
talkSeconds: 4,
},
'queue-abandoned': {
direction: 'inbound',
status: 'abandoned',
fromNumber: '13125559999',
fromName: 'Mobile',
toNumber: '13125551238',
did: '13125551238',
answeredBy: null,
rang: ['102'],
talkSeconds: 0,
},
'queue-timeout-voicemail': {
direction: 'inbound',
status: 'voicemail',
fromNumber: '13125559999',
fromName: 'Mobile',
toNumber: '13125551238',
did: '13125551238',
answeredBy: null,
rang: ['102'], // rang as an agent, then took the call in his mailbox
talkSeconds: 0,
},
};
describe.skipIf(!hasTestDb)('call aggregation from recorded CDRs', () => {
@@ -139,7 +172,7 @@ describe.skipIf(!hasTestDb)('call aggregation from recorded CDRs', () => {
passwordEnc: encrypt('p'),
})
.returning();
for (const n of ['1234', '1235', '1236'])
for (const n of ['1234', '1235', '1236', '1237', '1238'])
await t.db.insert(dids).values({
number: `1312555${n}`,
trunkId: trunk!.id,
+54
View File
@@ -0,0 +1,54 @@
[
{
"Event": "Cdr",
"Privilege": "cdr,all",
"AccountCode": "",
"Source": "13125559999",
"Destination": "102",
"DestinationContext": "sp-queue-member",
"CallerID": "\"Mobile\" <13125559999>",
"Channel": "Local/102@sp-queue-member-00000003;2",
"DestinationChannel": "PJSIP/102-00000013",
"LastApplication": "Dial",
"LastData": "PJSIP/102/sip:102-0x5d74549820b0@172.21.0.4:5072,600,tx",
"StartTime": "2026-10-19 00:33:12",
"AnswerTime": "",
"EndTime": "2026-10-19 00:33:16",
"Duration": "3",
"BillableSeconds": "0",
"Disposition": "NO ANSWER",
"AMAFlags": "DOCUMENTATION",
"UniqueID": "1792369992.43",
"UserField": "",
"LinkedID": "1792369992.41",
"sp_did": "",
"sp_direction": "",
"sp_recording": ""
},
{
"Event": "Cdr",
"Privilege": "cdr,all",
"AccountCode": "",
"Source": "13125559999",
"Destination": "s",
"DestinationContext": "sp-queue-44444444-4444-4444-8444-444444444551",
"CallerID": "\"Mobile\" <13125559999>",
"Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000012",
"DestinationChannel": "Local/102@sp-queue-member-00000003;1",
"LastApplication": "Queue",
"LastData": "551,t,,,8",
"StartTime": "2026-10-19 00:33:12",
"AnswerTime": "2026-10-19 00:33:12",
"EndTime": "2026-10-19 00:33:16",
"Duration": "3",
"BillableSeconds": "3",
"Disposition": "NO ANSWER",
"AMAFlags": "DOCUMENTATION",
"UniqueID": "1792369992.41",
"UserField": "",
"LinkedID": "1792369992.41",
"sp_did": "13125551238",
"sp_direction": "inbound",
"sp_recording": ""
}
]
+54
View File
@@ -0,0 +1,54 @@
[
{
"Event": "Cdr",
"Privilege": "cdr,all",
"AccountCode": "",
"Source": "13125559999",
"Destination": "101",
"DestinationContext": "sp-queue-member",
"CallerID": "\"Mobile\" <13125559999>",
"Channel": "Local/101@sp-queue-member-00000002;2",
"DestinationChannel": "PJSIP/101-00000011",
"LastApplication": "Dial",
"LastData": "PJSIP/101/sip:101-0x5bca24ea80b0@172.21.0.4:5070,600,tx",
"StartTime": "2026-10-19 00:33:04",
"AnswerTime": "2026-10-19 00:33:04",
"EndTime": "2026-10-19 00:33:08",
"Duration": "4",
"BillableSeconds": "4",
"Disposition": "ANSWERED",
"AMAFlags": "DOCUMENTATION",
"UniqueID": "1792369984.37",
"UserField": "",
"LinkedID": "1792369984.35",
"sp_did": "",
"sp_direction": "",
"sp_recording": ""
},
{
"Event": "Cdr",
"Privilege": "cdr,all",
"AccountCode": "",
"Source": "13125559999",
"Destination": "s",
"DestinationContext": "sp-queue-44444444-4444-4444-8444-444444444550",
"CallerID": "\"Mobile\" <13125559999>",
"Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000010",
"DestinationChannel": "Local/101@sp-queue-member-00000002;1",
"LastApplication": "Queue",
"LastData": "550,t,,,60",
"StartTime": "2026-10-19 00:33:04",
"AnswerTime": "2026-10-19 00:33:04",
"EndTime": "2026-10-19 00:33:08",
"Duration": "4",
"BillableSeconds": "4",
"Disposition": "ANSWERED",
"AMAFlags": "DOCUMENTATION",
"UniqueID": "1792369984.35",
"UserField": "",
"LinkedID": "1792369984.35",
"sp_did": "13125551237",
"sp_direction": "inbound",
"sp_recording": ""
}
]
+80
View File
@@ -0,0 +1,80 @@
[
{
"Event": "Cdr",
"Privilege": "cdr,all",
"AccountCode": "",
"Source": "13125559999",
"Destination": "102",
"DestinationContext": "sp-queue-member",
"CallerID": "\"Mobile\" <13125559999>",
"Channel": "Local/102@sp-queue-member-00000004;2",
"DestinationChannel": "PJSIP/102-00000015",
"LastApplication": "Dial",
"LastData": "PJSIP/102/sip:102-0x5d74549820b0@172.21.0.4:5072,600,tx",
"StartTime": "2026-10-19 00:33:20",
"AnswerTime": "",
"EndTime": "2026-10-19 00:33:28",
"Duration": "8",
"BillableSeconds": "0",
"Disposition": "NO ANSWER",
"AMAFlags": "DOCUMENTATION",
"UniqueID": "1792370000.49",
"UserField": "",
"LinkedID": "1792369999.47",
"sp_did": "",
"sp_direction": "",
"sp_recording": ""
},
{
"Event": "Cdr",
"Privilege": "cdr,all",
"AccountCode": "",
"Source": "13125559999",
"Destination": "s",
"DestinationContext": "sp-queue-44444444-4444-4444-8444-444444444551",
"CallerID": "\"Mobile\" <13125559999>",
"Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000014",
"DestinationChannel": "Local/102@sp-queue-member-00000004;1",
"LastApplication": "Queue",
"LastData": "551,t,,,8",
"StartTime": "2026-10-19 00:33:19",
"AnswerTime": "2026-10-19 00:33:19",
"EndTime": "2026-10-19 00:33:28",
"Duration": "8",
"BillableSeconds": "8",
"Disposition": "NO ANSWER",
"AMAFlags": "DOCUMENTATION",
"UniqueID": "1792369999.47",
"UserField": "",
"LinkedID": "1792369999.47",
"sp_did": "13125551238",
"sp_direction": "inbound",
"sp_recording": ""
},
{
"Event": "Cdr",
"Privilege": "cdr,all",
"AccountCode": "",
"Source": "13125559999",
"Destination": "102",
"DestinationContext": "sp-vm-direct",
"CallerID": "\"Mobile\" <13125559999>",
"Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000014",
"DestinationChannel": "",
"LastApplication": "VoiceMail",
"LastData": "102@default,u",
"StartTime": "2026-10-19 00:33:28",
"AnswerTime": "2026-10-19 00:33:28",
"EndTime": "2026-10-19 00:33:34",
"Duration": "6",
"BillableSeconds": "6",
"Disposition": "ANSWERED",
"AMAFlags": "DOCUMENTATION",
"UniqueID": "1792369999.47",
"UserField": "",
"LinkedID": "1792369999.47",
"sp_did": "13125551238",
"sp_direction": "inbound",
"sp_recording": ""
}
]
+1
View File
@@ -136,6 +136,7 @@ describe.skipIf(!hasTestDb)('generator apply', () => {
const order: string[] = [];
const slowAmi = {
isConnected: () => true,
action: async () => ({}),
command: async (c: string) => {
order.push(`start ${c}`);
await new Promise((r) => setTimeout(r, 5));
+32
View File
@@ -219,4 +219,36 @@ describe.skipIf(!hasTestDb)('/api/live WebSocket', () => {
expect(other.msgs).toHaveLength(1); // just the snapshot
for (const c of [admin, desk, other]) c.ws.terminate();
});
it("queues reach admins and that queue's agents", async () => {
const agent = await t.addUser('agent@example.com', 'agent-password-123');
await t.db.insert(extensions).values({
number: '160',
name: 'Agent',
userId: agent.id,
sipPasswordEnc: encrypt('x'),
voicemailPinEnc: encrypt('1234'),
});
const admin = await open(await t.login(ADMIN.email, ADMIN.password));
const me = await open(await t.login('agent@example.com', 'agent-password-123'));
await until(() => admin.msgs.length === 1 && me.msgs.length === 1);
const q = (number: string, ext: string) => ({
type: 'queue.updated' as const,
data: {
id: `q-${number}`,
number,
name: number,
waiting: [],
members: [{ ext, paused: false, state: 'idle' as const, callsTaken: 0 }],
},
});
t.live.emit('message', q('550', '160'));
t.live.emit('message', q('551', '170'));
await until(() => admin.msgs.length === 3 && me.msgs.length === 3);
expect(me.msgs[1]).toMatchObject({ type: 'queue.updated', data: { number: '550' } });
// not an agent there: it only learns the queue left its view
expect(me.msgs[2]).toEqual({ type: 'queue.removed', data: { number: '551' } });
expect(admin.msgs[2]).toMatchObject({ type: 'queue.updated', data: { number: '551' } });
for (const c of [admin, me]) c.ws.terminate();
});
});
+277
View File
@@ -0,0 +1,277 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M8-T1: /api/queues (03 §6.8, 04 §7.3), agent pause from the UI and *45, stats from events.
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { eq } from 'drizzle-orm';
import { queueCalls, queueMemberState } from '../src/db/schema.ts';
import { syncAstDb } from '../src/services/astdb.ts';
import { recordQueueEvent } from '../src/services/queuestats.ts';
import { handleToggle } from '../src/services/toggles.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('/api/queues', () => {
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>;
const ext: Record<string, string> = {};
let queueId: string;
const conf = async (f: string) => {
await t.app.generator.apply({ trigger: 'test' });
return readFile(join(t.genDir, f), 'utf8');
};
const state = async () =>
(await t.db.select().from(queueMemberState)).map((s) => ({
ext: Object.keys(ext).find((n) => ext[n] === s.extensionId),
paused: s.paused,
}));
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));
const danUser = await t.addUser('dan@example.com', 'dan-password-123');
dan = as(await t.login('dan@example.com', 'dan-password-123'));
for (const [n, name] of [
['101', 'Dan Doe'],
['102', 'Sue Smith'],
['103', 'Ann Lee'],
] as const)
ext[n] = (
await admin('POST', '/api/extensions', {
number: n,
name,
...(n === '101' ? { userId: danUser.id } : {}),
})
).json().id;
});
afterAll(() => t?.close());
it('creates a queue with agents; config and dialplan follow', async () => {
const r = await admin('POST', '/api/queues', {
number: '550',
name: 'Support',
members: [
{ extensionId: ext['102'], penalty: 1 },
{ extensionId: ext['101'], penalty: 0 },
],
timeoutDestination: { type: 'voicemail', id: ext['101'] },
});
expect(r.statusCode, r.body).toBe(201);
queueId = r.json().id;
expect(r.json()).toMatchObject({
strategy: 'rrmemory',
maxWaitSeconds: 300,
timeoutSummary: 'Voicemail of Dan Doe (101)',
members: [
{ extensionId: ext['101'], penalty: 0, paused: false },
{ extensionId: ext['102'], penalty: 1, paused: false },
],
});
const q = await conf('queues_selfphoneng.conf');
expect(q).toContain('[550]');
expect(q).toContain(
'member => Local/101@sp-queue-member/n,0,Dan Doe,hint:101@sp-local-numbers',
);
expect(q).toContain(
'member => Local/102@sp-queue-member/n,1,Sue Smith,hint:102@sp-local-numbers',
);
const d = await conf('extensions_selfphoneng.conf');
expect(d).toContain(`exten => 550,1,Goto(sp-queue-${queueId},s,1)`);
expect(d).toContain(' same => n,Queue(550,t,,,300)');
expect(t.ami.commands).toContain('queue reload all');
});
it('checks the number, agents and destination', async () => {
const bad = (body: object) =>
admin('POST', '/api/queues', {
number: '551',
name: 'X',
timeoutDestination: { type: 'hangup' },
...body,
});
expect((await bad({ number: '500' })).statusCode).toBe(400); // outside 550–599
expect((await bad({ number: '550' })).statusCode).toBe(409);
const twice = { extensionId: ext['101'], penalty: 0 };
expect((await bad({ members: [twice, twice] })).statusCode).toBe(400);
expect(
(
await bad({
members: [{ extensionId: '00000000-0000-4000-8000-000000000999', penalty: 0 }],
})
).statusCode,
).toBe(400);
expect(
(
await bad({
timeoutDestination: { type: 'queue', id: '00000000-0000-4000-8000-000000000999' },
})
).statusCode,
).toBe(400);
expect(
(
await admin('PATCH', `/api/queues/${queueId}`, {
timeoutDestination: { type: 'queue', id: queueId },
})
).statusCode,
).toBe(400);
});
it('admin pause: PostgreSQL, AstDB and app_queue; unpause undoes it', async () => {
t.ami.actions.length = 0;
const r = await admin('POST', `/api/queues/${queueId}/members/${ext['102']}/pause`);
expect(r.statusCode, r.body).toBe(200);
expect(await state()).toEqual([{ ext: '102', paused: true }]);
expect(t.ami.actions).toEqual([
{ Action: 'DBPut', Family: 'SP', Key: 'QPAUSE/550/102', Val: '1' },
{
Action: 'QueuePause',
Queue: '550',
Interface: 'Local/102@sp-queue-member/n',
Paused: 'true',
},
]);
expect((await admin('GET', `/api/queues/${queueId}`)).json().members[1].paused).toBe(true);
// not an agent of the queue
expect(
(await admin('POST', `/api/queues/${queueId}/members/${ext['103']}/pause`)).statusCode,
).toBe(404);
t.ami.actions.length = 0;
await admin('POST', `/api/queues/${queueId}/members/${ext['102']}/unpause`);
expect(await state()).toEqual([{ ext: '102', paused: false }]);
expect(t.ami.actions.map((a) => a.Action)).toEqual(['DBDel', 'QueuePause']);
});
it('agents log themselves in and out from Home, only for their own queues', async () => {
expect((await dan('POST', `/api/me/queues/${queueId}/pause`)).statusCode).toBe(200);
expect(await state()).toContainEqual({ ext: '101', paused: true });
expect(
(await dan('POST', `/api/queues/${queueId}/members/${ext['101']}/unpause`)).statusCode,
).toBe(403);
const other = await admin('POST', '/api/queues', {
number: '551',
name: 'Billing',
members: [{ extensionId: ext['103'], penalty: 0 }],
timeoutDestination: { type: 'hangup' },
});
expect((await dan('POST', `/api/me/queues/${other.json().id}/pause`)).statusCode).toBe(404);
expect((await dan('POST', `/api/me/queues/${queueId}/unpause`)).statusCode).toBe(200);
await admin('DELETE', `/api/queues/${other.json().id}`);
});
it('*45 from a phone (SpToggle QPAUSE) updates PostgreSQL', async () => {
const toggle = (Value: string) =>
handleToggle(
{ db: t.db, ami: t.ami, live: t.live, log: t.app.log },
{
Event: 'UserEvent',
UserEvent: 'SpToggle',
Key: 'QPAUSE',
Queue: '550',
Ext: '102',
Value,
},
);
await toggle('1');
expect(await state()).toContainEqual({ ext: '102', paused: true });
await toggle('0');
expect(await state()).toContainEqual({ ext: '102', paused: false });
});
it('a queue reload and an AstDB resync pause the paused agents again', async () => {
await admin('POST', `/api/queues/${queueId}/members/${ext['102']}/pause`);
t.ami.actions.length = 0;
await t.app.generator.apply({ trigger: 'test', force: true });
expect(t.ami.actions).toContainEqual({
Action: 'QueuePause',
Queue: '550',
Interface: 'Local/102@sp-queue-member/n',
Paused: 'true',
});
t.ami.actions.length = 0;
await syncAstDb(t.db, t.ami);
expect(t.ami.actions).toContainEqual({
Action: 'DBPut',
Family: 'SP',
Key: 'QPAUSE/550/102',
Val: '1',
});
});
it('removing an agent forgets their pause', async () => {
const r = await admin('PATCH', `/api/queues/${queueId}`, {
members: [{ extensionId: ext['101'], penalty: 0 }],
});
expect(r.statusCode, r.body).toBe(200);
expect((await state()).map((s) => s.ext)).not.toContain('102');
expect(await conf('queues_selfphoneng.conf')).not.toContain('Local/102@');
});
it('stats from app_queue events (shapes as on a real Asterisk 24)', async () => {
const ev = (Event: string, Uniqueid: string, extra: Record<string, string> = {}) =>
recordQueueEvent(t.db, { Event, Queue: '550', Uniqueid, Linkedid: Uniqueid, ...extra });
const exit = (Uniqueid: string, Status: string) =>
ev('UserEvent', Uniqueid, { UserEvent: 'SpQueueExit', Status });
await ev('QueueCallerJoin', '1.1');
await ev('AgentConnect', '1.1', { HoldTime: '3', Interface: 'Local/101@sp-queue-member/n' });
await ev('QueueCallerJoin', '1.2');
await ev('AgentConnect', '1.2', { HoldTime: '45', Interface: 'Local/101@sp-queue-member/n' });
await ev('QueueCallerJoin', '1.3');
await ev('QueueCallerAbandon', '1.3', { HoldTime: '4' }); // hung up
await ev('QueueCallerJoin', '1.4');
await ev('QueueCallerAbandon', '1.4', { HoldTime: '300' }); // a timeout looks the same…
await exit('1.4', 'TIMEOUT'); // …until the dialplan says so
await exit('1.5', 'JOINEMPTY'); // turned away without joining
await ev('QueueCallerJoin', '1.6'); // still waiting
await ev('QueueCallerJoin', '1.7', { Queue: '999' }); // not ours
expect((await admin('GET', `/api/queues/${queueId}/stats`)).json()).toEqual({
offered: 6,
answered: 2,
abandoned: 1,
timedOut: 2,
avgWaitSeconds: 24,
withinSlaPercent: 50, // 3 s yes, 45 s no (sla 20 s)
});
const [taken] = await t.db.select().from(queueCalls).where(eq(queueCalls.uniqueid, '1.1'));
expect(taken).toMatchObject({
outcome: 'answered',
waitSeconds: 3,
agentExtensionId: ext['101'],
});
expect(
(await admin('GET', `/api/queues/${queueId}/stats?from=2026-01-01&to=2026-01-02`)).json(),
).toMatchObject({ offered: 0, avgWaitSeconds: null, withinSlaPercent: null });
});
it('a phone number can point at it; delete is refused while it does', async () => {
const trunkId = (
await admin('POST', '/api/trunks', {
name: 'voip.ms',
serverHost: '127.0.0.1',
username: '123456_pbx',
password: 'pw',
})
).json().id;
const did = await admin('POST', '/api/dids', {
number: '3125551234',
trunkId,
destination: { type: 'queue', id: queueId },
});
expect(did.json().destinationSummary).toBe('Queue Support (550)');
expect(await conf('extensions_selfphoneng.conf')).toContain(`Goto(sp-queue-${queueId},s,1)`);
expect((await admin('DELETE', `/api/queues/${queueId}`)).statusCode).toBe(409);
await admin('PATCH', `/api/dids/${did.json().id}`, { destination: { type: 'hangup' } });
expect((await admin('DELETE', `/api/queues/${queueId}`)).statusCode).toBe(200);
expect(await conf('queues_selfphoneng.conf')).not.toContain('[550]');
});
});
+2 -2
View File
@@ -104,11 +104,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.());
it('creates all 39 tables', async () => {
it('creates all 40 tables', async () => {
const r = await db.execute(
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
);
expect(r.rows[0]).toEqual({ n: 39 });
expect(r.rows[0]).toEqual({ n: 40 });
});
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
+19
View File
@@ -31,6 +31,10 @@ import type {
OutboundRouteCreate,
OutboundRouteDto,
OutboundRouteUpdate,
QueueCreate,
QueueDto,
QueueStats,
QueueUpdate,
RingGroupCreate,
RingGroupDto,
RingGroupUpdate,
@@ -195,6 +199,9 @@ 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'),
/** Log in to (unpause) or out of (pause) a queue I'm an agent of. */
setQueuePaused: (queueId: string, paused: boolean) =>
api<{ ok: true }>('POST', `/api/me/queues/${queueId}/${paused ? 'pause' : 'unpause'}`),
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),
audioUrl: (id: string, download = false) =>
@@ -231,6 +238,18 @@ export const ringGroupsApi = {
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/ring-groups/${id}`),
};
export const queuesApi = {
list: () => api<ListResponse<QueueDto>>('GET', '/api/queues').then((r) => r.items),
get: (id: string) => api<QueueDto>('GET', `/api/queues/${id}`),
create: (q: QueueCreate) => api<QueueDto>('POST', '/api/queues', q),
update: (id: string, q: QueueUpdate) => api<QueueDto>('PATCH', `/api/queues/${id}`, q),
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/queues/${id}`),
/** Today (company time zone) unless from/to (YYYY-MM-DD) are given. */
stats: (id: string) => api<QueueStats>('GET', `/api/queues/${id}/stats`),
setPaused: (id: string, extId: string, paused: boolean) =>
api<{ ok: true }>('POST', `/api/queues/${id}/members/${extId}/${paused ? 'pause' : 'unpause'}`),
};
export const ivrsApi = {
list: () => api<ListResponse<IvrDto>>('GET', '/api/ivrs').then((r) => r.items),
create: (r: IvrCreate) => api<IvrDto>('POST', '/api/ivrs', r),
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// "Where should the call go?" (05 §3): a type, then the target (or a number).
// Every type that exists (M6-T6); queues and conferences join with M8.
// Every type that exists (M6-T6, queues M8-T1); conferences join with M8-T3.
import { useQuery } from '@tanstack/react-query';
import { useId, useState } from 'react';
import { normalizeNumber, type Destination } from '@selfphoneng/shared';
@@ -8,18 +8,21 @@ import {
announcementsApi,
extensionsApi,
ivrsApi,
queuesApi,
ringGroupsApi,
timeConditionsApi,
} from '../api/client.ts';
import { inputClass } from './ui.tsx';
type IdKind = 'user' | 'voicemail' | 'ring_group' | 'ivr' | 'time_condition' | 'announcement';
type IdKind =
'user' | 'voicemail' | 'ring_group' | 'queue' | 'ivr' | 'time_condition' | 'announcement';
type Kind = IdKind | 'external' | 'hangup';
const KINDS: { kind: Kind; label: string }[] = [
{ kind: 'user', label: 'Ring a user' },
{ kind: 'voicemail', label: 'Voicemail of a user' },
{ kind: 'ring_group', label: 'Ring group' },
{ kind: 'queue', label: 'Queue' },
{ kind: 'ivr', label: 'Digital receptionist' },
{ kind: 'time_condition', label: 'Office hours' },
{ kind: 'announcement', label: 'Announcement' },
@@ -31,6 +34,7 @@ const TARGET_NOUN: Record<IdKind, string> = {
user: 'user',
voicemail: 'user',
ring_group: 'ring group',
queue: 'queue',
ivr: 'digital receptionist',
time_condition: 'office hours',
announcement: 'announcement',
@@ -139,6 +143,7 @@ export function DestinationPicker({
function useTargets() {
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
const groups = useQuery({ queryKey: ['ring-groups'], queryFn: ringGroupsApi.list });
const queues = useQuery({ queryKey: ['queues'], queryFn: queuesApi.list });
const ivrs = useQuery({ queryKey: ['ivrs'], queryFn: ivrsApi.list });
const tcs = useQuery({ queryKey: ['time-conditions'], queryFn: timeConditionsApi.list });
const anns = useQuery({ queryKey: ['announcements'], queryFn: announcementsApi.list });
@@ -148,6 +153,7 @@ function useTargets() {
user: rows(exts),
voicemail: rows(exts),
ring_group: rows(groups),
queue: rows(queues),
ivr: rows(ivrs),
time_condition: rows(tcs),
announcement: rows(anns),
+11
View File
@@ -6,6 +6,7 @@ import type {
EmergencyCall,
ExtensionLive,
LiveMessage,
QueueLive,
TrunkLive,
} from '@selfphoneng/shared';
@@ -18,6 +19,8 @@ type LiveStore = {
trunks: Record<string, TrunkLive>;
/** Admins only: calls in progress by linkedid. */
calls: Record<string, ActiveCall>;
/** By number. Admins: all; users: the queues they're an agent of. */
queues: Record<string, QueueLive>;
/** 911 alerts received while this tab was open, newest last; dismissed ones are removed. */
emergencies: EmergencyCall[];
/** Bumped on every voicemail.new, so the voicemail list knows to refetch. */
@@ -32,6 +35,7 @@ export const useLive = create<LiveStore>(() => ({
extensions: {},
trunks: {},
calls: {},
queues: {},
emergencies: [],
voicemailSeq: 0,
toasts: [],
@@ -47,6 +51,7 @@ function apply(m: LiveMessage) {
extensions: Object.fromEntries(m.data.extensions.map((e) => [e.number, e])),
trunks: Object.fromEntries((m.data.trunks ?? []).map((t) => [t.trunkId, t])),
calls: Object.fromEntries((m.data.calls ?? []).map((c) => [c.linkedid, c])),
queues: Object.fromEntries(m.data.queues.map((q) => [q.number, q])),
};
case 'extension.updated':
return { extensions: { ...s.extensions, [m.data.number]: m.data } };
@@ -66,6 +71,12 @@ function apply(m: LiveMessage) {
const { [m.data.linkedid]: _gone, ...rest } = s.calls;
return { calls: rest };
}
case 'queue.updated':
return { queues: { ...s.queues, [m.data.number]: m.data } };
case 'queue.removed': {
const { [m.data.number]: _gone, ...rest } = s.queues;
return { queues: rest };
}
case 'emergency.call':
return { emergencies: [...s.emergencies, m.data] };
case 'toast':
+4
View File
@@ -16,6 +16,7 @@ import { OutboundPage } from './pages/admin/OutboundPage.tsx';
import { SettingsPage } from './pages/admin/SettingsPage.tsx';
import { SoundsPage } from './pages/admin/SoundsPage.tsx';
import { RingGroupsPage } from './pages/admin/RingGroupsPage.tsx';
import { QueuesPage } from './pages/admin/QueuesPage.tsx';
import { IvrsPage } from './pages/admin/IvrsPage.tsx';
import { OfficeHoursPage } from './pages/admin/OfficeHoursPage.tsx';
import { BlockedPage } from './pages/admin/BlockedPage.tsx';
@@ -52,6 +53,8 @@ const router = createBrowserRouter([
<SoundsPage />
) : p.path === 'ring-groups' ? (
<RingGroupsPage />
) : p.path === 'queues' ? (
<QueuesPage />
) : p.path === 'ivrs' ? (
<IvrsPage />
) : p.path === 'hours' ? (
@@ -67,6 +70,7 @@ const router = createBrowserRouter([
),
})),
{ path: 'settings/:tab', element: <SettingsPage /> },
{ path: 'queues/:id', element: <QueuesPage /> },
],
},
{ path: '/me/*', element: <RequireAuth>{(user) => <MePage user={user} />}</RequireAuth> },
+1 -1
View File
@@ -16,7 +16,7 @@ export const ADMIN_PAGES = [
{ path: 'numbers', label: 'Phone numbers', milestone: null },
{ path: 'outbound', label: 'Outbound rules', milestone: null },
{ path: 'ring-groups', label: 'Ring groups', milestone: null },
{ path: 'queues', label: 'Queues', milestone: 'M8' },
{ path: 'queues', label: 'Queues', milestone: null },
{ path: 'ivrs', label: 'Digital receptionist', milestone: null },
{ path: 'hours', label: 'Office hours', milestone: null },
{ path: 'conferences', label: 'Conferences', milestone: 'M8' },
+47 -4
View File
@@ -1,9 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Dashboard (05 §2). Warnings, telephony link, trunks, calls in progress (hang up / transfer)
// and extensions online. Queues and parked calls arrive with M8.
// queue tiles and extensions online. Parked calls arrive with M8-T2.
import { useMutation, useQuery } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react';
import type { ActiveCall } from '@selfphoneng/shared';
import type { ActiveCall, QueueLive } from '@selfphoneng/shared';
import { Link } from 'react-router';
import { liveApi, settingsApi } from '../../api/client.ts';
import { ConfirmDialog } from '../../components/Dialog.tsx';
@@ -62,6 +62,8 @@ export function DashboardPage() {
</Card>
</div>
<QueueTiles />
<LiveCalls />
<div>
@@ -92,6 +94,47 @@ export function DashboardPage() {
);
}
/** Agents who would get the next call right now. */
export const available = (q: QueueLive) =>
q.members.filter((m) => !m.paused && m.state === 'idle').length;
/** Per queue: waiting, longest wait, agents available (05 §2). */
function QueueTiles() {
const queues = Object.values(useLive((s) => s.queues)).sort((a, b) =>
a.number.localeCompare(b.number),
);
const now = useNow();
if (!queues.length) return null;
return (
<div>
<h2 className="mb-2 font-medium">Queues</h2>
<ul aria-label="Queues" className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{queues.map((q) => {
const oldest = q.waiting.reduce<string | null>(
(m, w) => (m === null || w.since < m ? w.since : m),
null,
);
return (
<li
key={q.number}
className="rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"
>
<Link className="text-sm text-zinc-500 hover:underline" to={`queues/${q.id}`}>
{q.number} {q.name}
</Link>
<div className="mt-1 text-2xl font-semibold">{q.waiting.length} waiting</div>
<div className="text-sm text-zinc-500">
Longest {oldest ? since(oldest, now) : '—'} · {available(q)} of {q.members.length}{' '}
agents free
</div>
</li>
);
})}
</ul>
</div>
);
}
function Warnings({ trunkDown }: { trunkDown: boolean }) {
const warnings = useQuery({
queryKey: ['warnings'],
@@ -138,7 +181,7 @@ const STATE_TEXT: Record<ActiveCall['state'], string> = {
parked: 'Parked',
};
function useNow() {
export function useNow() {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000);
@@ -147,7 +190,7 @@ function useNow() {
return now;
}
const since = (iso: string, now: number) => {
export const since = (iso: string, now: number) => {
const s = Math.max(0, Math.floor((now - Date.parse(iso)) / 1000));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
};
+559
View File
@@ -0,0 +1,559 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Queues (05 §2, 03 §6.8): callers wait with music until an agent is free. The list shows live
// counts; a queue's page shows who is waiting, the agents (pause/unpause) and today's numbers.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Link, useParams } from 'react-router';
import {
QUEUE_STRATEGIES,
QueueCreateSchema,
type Destination,
type QueueCreate,
type QueueDto,
} from '@selfphoneng/shared';
import { extensionsApi, queuesApi } from '../../api/client.ts';
import { DestinationPicker } from '../../components/DestinationPicker.tsx';
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { useLive } from '../../live/store.ts';
import { fmtNumber } from './CallsPage.tsx';
import { available, since, useNow } from './DashboardPage.tsx';
const STRATEGY_LABEL: Record<(typeof QUEUE_STRATEGIES)[number], string> = {
rrmemory: 'Take turns (round robin)',
leastrecent: 'Whoever finished a call longest ago',
fewestcalls: 'Whoever took the fewest calls',
ringall: 'Everyone at once',
linear: 'In order, first agent first',
random: 'Random agent',
};
export function QueuesPage() {
const { id } = useParams();
return id ? <QueueDetail id={id} /> : <QueueList />;
}
function QueueList() {
const qc = useQueryClient();
const queues = useQuery({ queryKey: ['queues'], queryFn: queuesApi.list });
const live = useLive((s) => s.queues);
const [editing, setEditing] = useState<QueueDto | 'new' | null>(null);
const [deleting, setDeleting] = useState<QueueDto | null>(null);
const refresh = () => qc.invalidateQueries({ queryKey: ['queues'] });
const remove = useMutation({
mutationFn: (id: string) => queuesApi.remove(id),
onSuccess: async () => {
setDeleting(null);
await refresh();
},
});
return (
<section className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Queues</h1>
<Button variant="primary" onClick={() => setEditing('new')}>
Add queue
</Button>
</div>
{queues.isError && <Alert>{queues.error.message}</Alert>}
{queues.data?.length === 0 && (
<p className="text-sm text-zinc-500">
No queues yet. In a queue (like 550 for Support) callers wait with music until one of the
agents is free.
</p>
)}
{queues.data && queues.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{queues.data.map((q) => {
const l = live[q.number];
return (
<li
key={q.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="font-mono">{q.number}</span>
<Link className="font-medium underline-offset-2 hover:underline" to={q.id}>
{q.name}
</Link>
<span className="min-w-0 flex-1 truncate text-zinc-500">
{q.members.length} agent{q.members.length === 1 ? '' : 's'}
{l ? ` · ${available(l)} available · ${l.waiting.length} waiting` : ''}
</span>
<Button onClick={() => setEditing(q)}>Edit</Button>
<Button variant="ghost" onClick={() => setDeleting(q)}>
Delete
</Button>
</li>
);
})}
</ul>
)}
{editing && (
<QueueForm
queue={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
onSaved={refresh}
/>
)}
<ConfirmDialog
open={deleting !== null}
title="Delete queue"
message={
<p>
Delete queue {deleting?.number} ({deleting?.name})?
</p>
}
confirmLabel="Delete"
busy={remove.isPending}
error={remove.error?.message}
onCancel={() => {
setDeleting(null);
remove.reset();
}}
onConfirm={() => deleting && remove.mutate(deleting.id)}
/>
</section>
);
}
const STATE_LABEL: Record<string, string> = {
idle: 'Free',
inuse: 'On a call',
busy: 'Busy',
ringing: 'Ringing',
ringinuse: 'Ringing',
onhold: 'On hold',
unavailable: 'Offline',
};
function QueueDetail({ id }: { id: string }) {
const qc = useQueryClient();
const queue = useQuery({ queryKey: ['queues', id], queryFn: () => queuesApi.get(id) });
const stats = useQuery({
queryKey: ['queues', id, 'stats'],
queryFn: () => queuesApi.stats(id),
refetchInterval: 30_000,
});
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
const live = useLive((s) => (queue.data ? s.queues[queue.data.number] : undefined));
const now = useNow();
const toggle = useMutation({
mutationFn: ({ extId, paused }: { extId: string; paused: boolean }) =>
queuesApi.setPaused(id, extId, paused),
onSuccess: () => qc.invalidateQueries({ queryKey: ['queues', id] }),
});
if (queue.isError) return <Alert>{queue.error.message}</Alert>;
if (!queue.data) return null;
const q = queue.data;
const s = stats.data;
return (
<section className="flex flex-col gap-6">
<div>
<Link className="text-sm text-zinc-500 hover:underline" to="..">
← Queues
</Link>
<h1 className="text-xl font-semibold">
<span className="font-mono">{q.number}</span> {q.name}
</h1>
<p className="text-sm text-zinc-500">
{STRATEGY_LABEL[q.strategy]} · waits up to {q.maxWaitSeconds} s, then {q.timeoutSummary}
</p>
</div>
<div>
<h2 className="mb-2 font-medium">Waiting</h2>
{!live?.waiting.length ? (
<p className="text-sm text-zinc-500">Nobody is waiting.</p>
) : (
<ol
aria-label="Waiting callers"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
>
{live.waiting.map((w, i) => (
<li key={w.linkedid} className="flex items-center gap-3 px-3 py-2 text-sm">
<span className="w-5 text-zinc-500">{i + 1}.</span>
<span className="flex-1">
{w.callerName ? `${w.callerName} ` : ''}
<span className="text-zinc-500">{fmtNumber(w.callerNumber)}</span>
</span>
<span className="font-mono tabular-nums">{since(w.since, now)}</span>
</li>
))}
</ol>
)}
</div>
<div>
<h2 className="mb-2 font-medium">Agents</h2>
{toggle.error && <Alert>{toggle.error.message}</Alert>}
{q.members.length === 0 ? (
<p className="text-sm text-zinc-500">No agents yet. Add some with Edit.</p>
) : (
<ul
aria-label="Agents"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
>
{q.members.map((m) => {
const e = exts.data?.find((x) => x.id === m.extensionId);
const l = live?.members.find((x) => x.ext === e?.number);
const paused = l?.paused ?? m.paused;
return (
<li
key={m.extensionId}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="w-12 font-mono">{e?.number}</span>
<span className="min-w-0 flex-1 truncate">{e?.name}</span>
<span className="text-zinc-500">
{paused ? 'Paused' : STATE_LABEL[l?.state ?? 'unavailable']}
{l ? ` · ${l.callsTaken} call${l.callsTaken === 1 ? '' : 's'}` : ''}
</span>
<Button
disabled={toggle.isPending}
onClick={() => toggle.mutate({ extId: m.extensionId, paused: !paused })}
>
{paused ? `Unpause ${e?.number ?? ''}` : `Pause ${e?.number ?? ''}`}
</Button>
</li>
);
})}
</ul>
)}
<p className="mt-1 text-xs text-zinc-500">
Agents can also log in and out from a phone with *45{q.number}.
</p>
</div>
<div>
<h2 className="mb-2 font-medium">Today</h2>
<dl
aria-label="Today's stats"
className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-3 lg:grid-cols-6"
>
{(
[
['Calls', s?.offered],
['Answered', s?.answered],
['Hung up waiting', s?.abandoned],
['Timed out', s?.timedOut],
['Average wait', s && (s.avgWaitSeconds === null ? '—' : `${s.avgWaitSeconds} s`)],
[
`Answered within ${q.slaSeconds} s`,
s && (s.withinSlaPercent === null ? '—' : `${s.withinSlaPercent}%`),
],
] as const
).map(([k, v]) => (
<div key={k} className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
<dt className="text-zinc-500">{k}</dt>
<dd className="text-lg font-semibold">{v ?? '…'}</dd>
</div>
))}
</dl>
</div>
</section>
);
}
function QueueForm({
queue,
onClose,
onSaved,
}: {
queue: QueueDto | null;
onClose: () => void;
onSaved: () => Promise<unknown>;
}) {
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
const next = useQuery({
queryKey: ['next-number', 'queue'],
queryFn: () => extensionsApi.nextNumber('queue'),
enabled: !queue,
});
const [form, setForm] = useState({
number: queue?.number ?? '',
name: queue?.name ?? '',
strategy: queue?.strategy ?? 'rrmemory',
memberRingSeconds: String(queue?.memberRingSeconds ?? 15),
retrySeconds: String(queue?.retrySeconds ?? 5),
wrapupSeconds: String(queue?.wrapupSeconds ?? 10),
maxWaitSeconds: String(queue?.maxWaitSeconds ?? 300),
maxCallers: String(queue?.maxCallers ?? 0),
mohClass: queue?.mohClass ?? 'default',
announcePosition: queue?.announcePosition ?? true,
announceEverySeconds: String(queue?.announceEverySeconds ?? 60),
joinWhenNoAgents: queue?.joinWhenNoAgents ?? false,
record: queue?.record ?? false,
slaSeconds: String(queue?.slaSeconds ?? 20),
callerIdNamePrefix: queue?.callerIdNamePrefix ?? '',
});
const [members, setMembers] = useState(
queue?.members.map(({ extensionId, penalty }) => ({ extensionId, penalty })) ?? [],
);
const [dest, setDest] = useState<Destination | null>(
queue?.timeoutDestination ?? { type: 'hangup' },
);
const [errors, setErrors] = useState<Record<string, string>>({});
const number = form.number || (queue ? '' : (next.data ?? ''));
const save = useMutation({
mutationFn: (body: QueueCreate) =>
queue ? queuesApi.update(queue.id, body) : queuesApi.create(body),
onSuccess: async () => {
await onSaved();
onClose();
},
});
function submit() {
const n = (k: keyof typeof form) => Number(form[k]);
const parsed = QueueCreateSchema.safeParse({
number,
name: form.name,
strategy: form.strategy,
memberRingSeconds: n('memberRingSeconds'),
retrySeconds: n('retrySeconds'),
wrapupSeconds: n('wrapupSeconds'),
maxWaitSeconds: n('maxWaitSeconds'),
maxCallers: n('maxCallers'),
mohClass: form.mohClass,
announcePosition: form.announcePosition,
announceEverySeconds: n('announceEverySeconds'),
joinWhenNoAgents: form.joinWhenNoAgents,
record: form.record,
slaSeconds: n('slaSeconds'),
callerIdNamePrefix: form.callerIdNamePrefix.trim() || null,
members,
timeoutDestination: dest ?? undefined,
});
if (!parsed.success) {
setErrors(Object.fromEntries(parsed.error.issues.map((i) => [String(i.path[0]), i.message])));
return;
}
setErrors({});
save.mutate(parsed.data);
}
const set = (k: keyof typeof form) => (v: string | boolean) => setForm((f) => ({ ...f, [k]: v }));
const label = (id: string) => {
const x = exts.data?.find((e) => e.id === id);
return x ? `${x.number} ${x.name}` : '?';
};
const candidates = exts.data?.filter((x) => !members.some((m) => m.extensionId === x.id)) ?? [];
const num = (k: keyof typeof form, lbl: string, hint?: string) => (
<Field label={lbl} error={errors[k]} hint={hint}>
{(p) => (
<input
{...p}
className={inputClass}
inputMode="numeric"
value={String(form[k])}
onChange={(e) => set(k)(e.target.value)}
/>
)}
</Field>
);
return (
<Dialog
open
drawer
onClose={onClose}
title={queue ? `Queue ${queue.number}` : 'New queue'}
footer={
<>
<Button onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={submit} disabled={save.isPending}>
Save
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<Field label="Number" error={errors.number} hint="Callers and colleagues dial this">
{(p) => (
<input
{...p}
className={inputClass}
inputMode="numeric"
value={number}
onChange={(e) => set('number')(e.target.value)}
/>
)}
</Field>
<Field label="Name" error={errors.name}>
{(p) => (
<input
{...p}
className={inputClass}
value={form.name}
placeholder="Support"
onChange={(e) => set('name')(e.target.value)}
/>
)}
</Field>
<Field label="Who gets the next call">
{(p) => (
<select
{...p}
className={inputClass}
value={form.strategy}
onChange={(e) => set('strategy')(e.target.value)}
>
{QUEUE_STRATEGIES.map((s) => (
<option key={s} value={s}>
{STRATEGY_LABEL[s]}
</option>
))}
</select>
)}
</Field>
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Agents</legend>
{errors.members && <Alert>{errors.members}</Alert>}
<ul className="flex flex-col gap-1">
{members.map((m) => (
<li key={m.extensionId} className="flex items-center gap-2 text-sm">
<span className="flex-1">{label(m.extensionId)}</span>
<label className="flex items-center gap-1 text-zinc-500">
Priority
<select
aria-label={`Priority of ${label(m.extensionId)}`}
className={`${inputClass} w-16`}
value={m.penalty}
onChange={(e) =>
setMembers((ms) =>
ms.map((x) =>
x.extensionId === m.extensionId
? { ...x, penalty: Number(e.target.value) }
: x,
),
)
}
>
{Array.from({ length: 11 }, (_, i) => (
<option key={i} value={i}>
{i}
</option>
))}
</select>
</label>
<Button
variant="ghost"
className="px-2 py-1"
aria-label={`Remove ${label(m.extensionId)}`}
onClick={() =>
setMembers((ms) => ms.filter((x) => x.extensionId !== m.extensionId))
}
>
✕
</Button>
</li>
))}
</ul>
{candidates.length > 0 && (
<select
aria-label="Add an agent"
className={inputClass}
value=""
onChange={(e) =>
e.target.value &&
setMembers((ms) => [...ms, { extensionId: e.target.value, penalty: 0 }])
}
>
<option value="">Add an agent…</option>
{candidates.map((x) => (
<option key={x.id} value={x.id}>
{x.number} {x.name}
</option>
))}
</select>
)}
<p className="text-xs text-zinc-500">
Priority 0 gets calls first; higher numbers only when the lower ones are all busy.
</p>
</fieldset>
{num(
'maxWaitSeconds',
'Longest wait (seconds)',
'Then the caller goes on to the next step',
)}
<DestinationPicker
label="When the wait is too long, the queue is full, or no agent is logged in"
value={dest ?? { type: 'hangup' }}
onChange={setDest}
error={errors.timeoutDestination}
exclude={queue?.id}
/>
{num('memberRingSeconds', 'Ring each agent for (seconds)')}
{num('retrySeconds', 'Pause before trying the next agent (seconds)')}
{num(
'wrapupSeconds',
'Break after each call (seconds)',
'Time for notes before the next call',
)}
{num('maxCallers', 'Most callers waiting', '0 = no limit')}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.joinWhenNoAgents}
onChange={(e) => set('joinWhenNoAgents')(e.target.checked)}
/>
Let callers wait even when no agent is logged in
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.announcePosition}
onChange={(e) => set('announcePosition')(e.target.checked)}
/>
Tell callers their place in line
</label>
{form.announcePosition && num('announceEverySeconds', 'Every (seconds)')}
<Field
label="Music on hold"
error={errors.mohClass}
hint="More music arrives with Sounds & music"
>
{(p) => (
<select
{...p}
className={inputClass}
value={form.mohClass}
onChange={(e) => set('mohClass')(e.target.value)}
>
<option value={form.mohClass}>{form.mohClass}</option>
</select>
)}
</Field>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.record}
onChange={(e) => set('record')(e.target.checked)}
/>
Record calls
</label>
{num('slaSeconds', 'Target answer time (seconds)', 'For the "answered within" figure')}
<Field
label="Caller name prefix"
error={errors.callerIdNamePrefix}
hint='Optional, e.g. "Support:" so agents know which queue rings'
>
{(p) => (
<input
{...p}
className={inputClass}
value={form.callerIdNamePrefix}
onChange={(e) => set('callerIdNamePrefix')(e.target.value)}
/>
)}
</Field>
{save.error && <Alert>{save.error.message}</Alert>}
</div>
</Dialog>
);
}
+37 -3
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// My Phone (05 §4). M4: Home and Voicemail. Calls, contacts, settings and the softphone follow
// with their milestones (M5–M7).
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { Link, NavLink, Route, Routes, useNavigate } from 'react-router';
import { APP_NAME, type AuthUser } from '@selfphoneng/shared';
@@ -124,7 +124,7 @@ function Home({ user }: { user: AuthUser }) {
return (
<div className="flex flex-col gap-6">
<p>Hi {user.displayName}.</p>
{user.extension && <StatusCard />}
{user.extension && <StatusCard ext={user.extension.number} />}
{user.extension && <MissedCalls />}
<section aria-labelledby="new-vm" className="flex flex-col gap-2">
<h2 id="new-vm" className="text-lg font-semibold">
@@ -158,7 +158,7 @@ function Home({ user }: { user: AuthUser }) {
}
/** Do not disturb and forwarding at a glance (05 §4 Home). */
function StatusCard() {
function StatusCard({ ext }: { ext: string }) {
const me = useMe();
if (!me.data) return null;
const fwd = [
@@ -172,6 +172,7 @@ function StatusCard() {
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"
>
<DndSwitch me={me.data} />
<QueueLogins ext={ext} />
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{fwd.length ? `Forwarding: ${fwd.join(', ')}.` : 'Not forwarding.'}{' '}
<Link className={link} to="/me/settings">
@@ -182,6 +183,39 @@ function StatusCard() {
);
}
/** One switch per queue I'm an agent of: on = logged in (not paused), like *45<queue>. */
function QueueLogins({ ext }: { ext: string }) {
const queues = Object.values(useLive((s) => s.queues)).sort((a, b) =>
a.number.localeCompare(b.number),
);
const toggle = useMutation({
mutationFn: ({ id, paused }: { id: string; paused: boolean }) =>
meApi.setQueuePaused(id, paused),
});
return (
<>
{queues.map((q) => {
const mine = q.members.find((m) => m.ext === ext);
if (!mine) return null;
return (
<label key={q.number} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
role="switch"
checked={!mine.paused}
disabled={toggle.isPending}
onChange={(e) => toggle.mutate({ id: q.id, paused: !e.target.checked })}
/>
Taking calls from queue {q.name} ({q.number})
<span className="text-xs text-zinc-500">(also *45{q.number} from your phone)</span>
</label>
);
})}
{toggle.error && <Alert>{toggle.error.message}</Alert>}
</>
);
}
/** Missed calls from the last page of my history (05 §4 Home). */
function MissedCalls() {
const calls = useQuery({ queryKey: ['me', 'calls', 'home'], queryFn: () => meApi.calls() });