parking: parked calls live, on the dashboard and Home, pick up from Home
ci / images (push) Canceled after 0s
ci / check (push) Canceled after 3s

res_parking was configured in M0 (700, slots 701-720, slot hints); this
makes parked calls visible. ParkingTracker loads ParkedCalls and follows
ParkedCall/UnParkedCall/ParkedCallTimeOut/ParkedCallGiveUp; everyone
gets parked.updated/parked.removed. Who parked comes from
ParkerDialString.

A blind transfer to 700 parks without running Park() in the dialplan,
so the live call's "parked" state now comes from those events by
ParkeeLinkedid; the picking-up phone runs ParkedCall() while talking,
which no longer counts as parked.

Web: "Parked calls" on the Dashboard and on Home, where "Pick up 701"
calls the slot like Call back (softphone, or the desk phone first).

CDR fixture park-pickup (recorded: 101 parks an inbound call, 103 picks
it up); the call counts as answered by 101 with 103 in rang. e2e m8-t2
with real phones: park by blind transfer, hint InUse, both lists, pick
up from Home, a second park on 702 (findslot = next) picked up by
dialing it from another phone. Specs 01/04/05 note what was verified.

Task: M8-T2
This commit is contained in:
iamdoubz committed 2026-10-19 04:23:21 -05:00
1 parent 2d9f8ed99e
commit 863a9d121c
21 files changed
+635 -23

No files matched your search

+27
View File
@@ -45,6 +45,33 @@ describe('CallTracker', () => {
expect(t.list()).toEqual([]);
});
it('parked: from the parking events, not the channel app (a picked-up call talks)', () => {
const t = new CallTracker(() => undefined);
const caller = {
Channel: 'PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000016',
Linkedid: '3.0',
Uniqueid: '3.0',
};
const picker = { Channel: 'PJSIP/103-00000018', Linkedid: '3.0', Uniqueid: '3.1' };
t.handle({
Event: 'Newchannel',
ChannelStateDesc: 'Up',
CallerIDNum: '13125559999',
...caller,
});
// a blind transfer to 700 parks it without a Park() Newexten
t.handle({ Event: 'ParkedCall', ParkeeLinkedid: '3.0', ParkingSpace: '701' });
expect(t.list()[0]!.state).toBe('parked');
t.handle({ Event: 'Newchannel', ChannelStateDesc: 'Up', ...picker });
t.handle({ Event: 'Newexten', Application: 'ParkedCall', ...picker });
t.handle({ Event: 'UnParkedCall', ParkeeLinkedid: '3.0', ParkingSpace: '701' });
t.handle({ Event: 'BridgeEnter', ...caller });
t.handle({ Event: 'BridgeEnter', ...picker });
expect(t.list()[0]!.state).toBe('talking');
t.handle({ Event: 'ParkedCall', ParkeeLinkedid: '9.9' }); // a call we don't know: ignored
expect(t.list()).toHaveLength(1);
});
it('loads calls already up from CoreShowChannels', () => {
const t = new CallTracker(() => undefined);
const ch = (Channel: string, extra: Record<string, string>) => ({
+17 -2
View File
@@ -35,7 +35,7 @@ export class CallTracker {
/** Per linkedid: dialplan variables (SP_DID…) and when it was first answered. */
private readonly extra = new Map<
string,
{ did?: string; direction?: string; rec?: boolean; answeredAt?: number }
{ did?: string; direction?: string; rec?: boolean; answeredAt?: number; parked?: boolean }
>();
private readonly emit: (linkedid: string, call: ActiveCall | null) => void;
@@ -127,6 +127,14 @@ export class CallTracker {
else x.rec = Boolean(value);
break;
}
// A blind transfer to 700 parks without running Park() in the dialplan, so the parking
// events (not the channel's application) say whether a call is parked.
case 'ParkedCall':
case 'UnParkedCall':
case 'ParkedCallTimeOut':
case 'ParkedCallGiveUp':
this.setParked(field(m, 'ParkeeLinkedid') ?? '', ev === 'ParkedCall');
return;
case 'Hangup': {
const c = this.channels.get(name);
if (!c) return;
@@ -146,6 +154,13 @@ export class CallTracker {
if (l) this.emit(l, this.call(l));
}
/** Also used after the initial load, with the ParkedCalls list. */
setParked(linkedid: string, parked: boolean): void {
if (!this.byLinkedid(linkedid).length) return;
this.extraOf(linkedid).parked = parked;
this.emit(linkedid, this.call(linkedid));
}
private extraOf(linkedid: string) {
let x = this.extra.get(linkedid);
if (!x) this.extra.set(linkedid, (x = {}));
@@ -204,7 +219,7 @@ export class CallTracker {
? 'ivr'
: apps.has('Queue')
? 'queued'
: apps.has('Park') || apps.has('ParkedCall')
: x.parked || apps.has('Park')
? 'parked'
: chans.filter((c) => c.bridged && c.state === 'Up').length >= 2
? 'talking'
+3 -1
View File
@@ -46,7 +46,9 @@ export async function liveRoutes(app: FastifyInstance) {
? `call:${m.data.linkedid}` // a removal replaces a pending update
: m.type === 'queue.updated' || m.type === 'queue.removed'
? `queue:${m.data.number}`
: m.type;
: m.type === 'parked.updated' || m.type === 'parked.removed'
? `parked:${m.data.slot}`
: m.type;
const maySee = (c: Client, m: LiveMessage) =>
m.type === 'voicemail.new' || m.type === 'toast'
+55
View File
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Events trimmed from a real Asterisk 24 park and pickup (cdr-record.ts, park-pickup).
import { describe, expect, it } from 'vitest';
import type { ParkedLive } from '@selfphoneng/shared';
import { ParkingTracker } from './parking.ts';
const PARKEE = {
ParkeeChannel: 'PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000016',
ParkeeCallerIDNum: '13125559999',
ParkeeCallerIDName: 'Mobile',
ParkeeUniqueid: '1792400815.54',
ParkeeLinkedid: '1792400815.54',
ParkerDialString: 'PJSIP/101',
Parkinglot: 'default',
ParkingSpace: '701',
};
describe('ParkingTracker', () => {
it('follows a call from parked to picked up', () => {
const sent: [string, ParkedLive | null][] = [];
const t = new ParkingTracker((slot, p) => sent.push([slot, p]));
t.handle({ Event: 'ParkedCall', ...PARKEE, ParkingTimeout: '120', ParkingDuration: '0' });
expect(t.list()).toEqual([
{
slot: '701',
linkedid: '1792400815.54',
callerNumber: '13125559999',
callerName: 'Mobile',
since: expect.any(String),
parkedBy: '101',
},
]);
t.handle({ Event: 'UnParkedCall', ...PARKEE, RetrieverChannel: 'PJSIP/103-00000018' });
expect(t.list()).toEqual([]);
expect(sent.map(([s, p]) => [s, p === null])).toEqual([
['701', false],
['701', true],
]);
t.handle({ Event: 'ParkedCallGiveUp', ParkingSpace: '701' }); // already gone: nothing
expect(sent).toHaveLength(2);
});
it('loads ParkedCalls, with how long each has waited', () => {
const sent: (ParkedLive | null)[] = [];
const t = new ParkingTracker((_s, p) => sent.push(p));
t.handle({ Event: 'ParkedCall', ...PARKEE, ParkingSpace: '705' });
t.load([
{ Event: 'ParkedCall', ...PARKEE, ParkingDuration: '30' },
{ Event: 'ParkedCallsComplete', Total: '1' },
]);
expect(t.list().map((p) => p.slot)).toEqual(['701']);
expect(Date.now() - Date.parse(t.list()[0]!.since)).toBeGreaterThanOrEqual(29_000);
expect(sent.at(-2)).toBeNull(); // 705 went away while we weren't looking
});
});
+56
View File
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Parked calls (04 §5.1): loaded from ParkedCalls on connect, then ParkedCall adds a slot and
// UnParkedCall (picked up), ParkedCallTimeOut (rang back) and ParkedCallGiveUp (hung up) free it.
import type { ParkedLive } from '@selfphoneng/shared';
import { field, type AmiMessage } from '../ami/events.ts';
const FREED = new Set(['UnParkedCall', 'ParkedCallTimeOut', 'ParkedCallGiveUp']);
export class ParkingTracker {
private readonly slots = new Map<string, ParkedLive>();
private readonly emit: (slot: string, p: ParkedLive | null) => void;
constructor(emit: (slot: string, p: ParkedLive | null) => void) {
this.emit = emit;
}
list(): ParkedLive[] {
return [...this.slots.values()].sort((a, b) => a.slot.localeCompare(b.slot));
}
/** ParkedCalls items (initial load). */
load(items: AmiMessage[]): void {
const before = new Set(this.slots.keys());
this.slots.clear();
for (const m of items) if (field(m, 'Event') === 'ParkedCall') this.park(m);
for (const s of before) if (!this.slots.has(s)) this.emit(s, null);
for (const p of this.slots.values()) this.emit(p.slot, p);
}
private park(m: AmiMessage): ParkedLive | null {
const slot = field(m, 'ParkingSpace') ?? '';
if (!slot) return null;
const parker = /^(?:PJSIP|SIP)\/(\d+)/.exec(field(m, 'ParkerDialString') ?? '')?.[1];
const p: ParkedLive = {
slot,
linkedid: field(m, 'ParkeeLinkedid') ?? field(m, 'ParkeeUniqueid') ?? '',
callerNumber: field(m, 'ParkeeCallerIDNum') ?? '',
callerName: field(m, 'ParkeeCallerIDName') || undefined,
since: new Date(Date.now() - Number(field(m, 'ParkingDuration') ?? 0) * 1000).toISOString(),
parkedBy: parker,
};
this.slots.set(slot, p);
return p;
}
handle(m: AmiMessage): void {
const ev = field(m, 'Event') ?? '';
if (ev === 'ParkedCall') {
const p = this.park(m);
if (p) this.emit(p.slot, p);
} else if (FREED.has(ev)) {
const slot = field(m, 'ParkingSpace') ?? '';
if (this.slots.delete(slot)) this.emit(slot, null);
}
}
}
+15 -3
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: extension states and registered devices, calls, queues. Parking with M8-T2.
// from AMI events: extension states and registered devices, calls, queues, parked calls.
import { EventEmitter } from 'node:events';
import { eq } from 'drizzle-orm';
import type {
@@ -16,6 +16,7 @@ import { field, type AmiMessage } from '../ami/events.ts';
import type { Db } from '../db/client.ts';
import { extensions, queues, trunks, userRuntimeState } from '../db/schema.ts';
import { CallTracker } from './calls.ts';
import { ParkingTracker } from './parking.ts';
import { QueueTracker } from './queues.ts';
/** ExtensionStatus/ExtensionStateList Status is a bit mask (04 §5.1). */
@@ -77,6 +78,13 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
q ? { type: 'queue.updated', data: q } : { type: 'queue.removed', data: { number } },
),
);
/** Parked calls (everyone). */
readonly parking = new ParkingTracker((slot, p) =>
this.emit(
'message',
p ? { type: 'parked.updated', data: p } : { type: 'parked.removed', data: { slot } },
),
);
private readonly db: Db;
private readonly ami: Ami;
@@ -95,11 +103,11 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
queues: this.queues
.list()
.filter((q) => admin || q.members.some((m) => myExts.includes(m.ext))),
parked: this.parking.list(),
...(admin
? {
trunks: [...this.trunks.values()].map((t) => ({ ...t })),
calls: this.calls.list(),
parked: [],
}
: {}),
ami: { connected: this.amiConnected },
@@ -259,12 +267,15 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
await this.refreshRegistrations();
await Promise.all([...this.trunks.keys()].map((id) => this.refreshReachable(id)));
for (const e of this.exts.values()) e.contacts = [];
const [states, contacts, channels] = await Promise.all([
const [states, contacts, channels, parked] = await Promise.all([
this.ami.actionList({ Action: 'ExtensionStateList' }),
this.ami.actionList({ Action: 'PJSIPShowContacts' }),
this.ami.actionList({ Action: 'CoreShowChannels' }),
this.ami.actionList({ Action: 'ParkedCalls' }).catch(() => []), // errors when none
]);
this.calls.load(channels);
this.parking.load(parked);
for (const p of this.parking.list()) this.calls.setParked(p.linkedid, true);
await this.refreshQueues();
for (const m of states) this.onExtensionStatus(m, false);
for (const m of contacts) {
@@ -288,6 +299,7 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
handleEvent(m: AmiMessage): void {
this.calls.handle(m);
this.queues.handle(m);
this.parking.handle(m);
switch (field(m, 'Event')) {
case 'ExtensionStatus':
return this.onExtensionStatus(m, true);
+18 -1
View File
@@ -5,7 +5,7 @@
// 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 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.
// 8 s to his voicemail. Park: Alice parks a caller on 700 (→ 701), Carol picks it up.
// 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';
@@ -444,6 +444,23 @@ async function main() {
await sleep(6000);
});
await scenario('park-pickup', async () => {
fromMobile('3125551234');
await waitFor('101 answers', () => !!pbxChannel('PJSIP/101-'));
await sleep(2000);
await ami.action({
Action: 'BlindTransfer',
Channel: pbxChannel('PJSIP/101-')!,
Context: 'sp-from-ext',
Exten: '700',
});
await waitFor('parked on 701', () => /701/.test(cli(PBX, 'parking show default')), 20);
await sleep(2000);
await fromPhone('103', '701', 'Carol');
await waitFor('103 picks up', () => !!pbxChannel('PJSIP/103-'), 20);
await sleep(4000);
});
mkdirSync(OUT, { recursive: true });
for (const [name, evs] of Object.entries(recorded)) {
if (!evs.length) throw new Error(`${name}: no Cdr events`);
+2 -2
View File
@@ -56,8 +56,8 @@ describe.skipIf(!hasTestDb)('call history API', () => {
destination: { type: 'hangup' },
label: n === '1234' ? 'Main line' : null,
});
// 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-'))) {
// The M7 scenarios; queue and park calls (M8) are checked in cdr.test.ts.
for (const f of readdirSync(FIXTURES).filter((f) => !/^(queue|park)-/.test(f))) {
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));
+11
View File
@@ -143,6 +143,17 @@ const SCENARIOS: Record<string, Expected> = {
rang: ['102'], // rang as an agent, then took the call in his mailbox
talkSeconds: 0,
},
'park-pickup': {
direction: 'inbound',
status: 'answered',
fromNumber: '13125559999',
fromName: 'Mobile',
toNumber: '13125551234',
did: '13125551234',
answeredBy: '101', // parked it on 701; Carol (103) picked it up there
rang: ['101', '103'],
talkSeconds: 4,
},
};
describe.skipIf(!hasTestDb)('call aggregation from recorded CDRs', () => {
+106
View File
@@ -0,0 +1,106 @@
[
{
"Event": "Cdr",
"Privilege": "cdr,all",
"AccountCode": "",
"Source": "103",
"Destination": "701",
"DestinationContext": "sp-from-ext",
"CallerID": "\"Carol\" <103>",
"Channel": "PJSIP/103-00000018",
"DestinationChannel": "",
"LastApplication": "ParkedCall",
"LastData": "default,701",
"StartTime": "2026-10-19 09:06:59",
"AnswerTime": "2026-10-19 09:06:59",
"EndTime": "2026-10-19 09:06:59",
"Duration": "0",
"BillableSeconds": "0",
"Disposition": "ANSWERED",
"AMAFlags": "DOCUMENTATION",
"UniqueID": "1792400819.56",
"UserField": "",
"LinkedID": "1792400815.54",
"sp_did": "",
"sp_direction": "",
"sp_recording": ""
},
{
"Event": "Cdr",
"Privilege": "cdr,all",
"AccountCode": "",
"Source": "13125559999",
"Destination": "101",
"DestinationContext": "sp-local-numbers",
"CallerID": "\"Mobile\" <13125559999>",
"Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000016",
"DestinationChannel": "PJSIP/101-00000017",
"LastApplication": "Dial",
"LastData": "PJSIP/101/sip:101-0x5987fe8320b0@172.21.0.4:5070,25,tx",
"StartTime": "2026-10-19 09:06:55",
"AnswerTime": "2026-10-19 09:06:55",
"EndTime": "2026-10-19 09:06:57",
"Duration": "2",
"BillableSeconds": "2",
"Disposition": "ANSWERED",
"AMAFlags": "DOCUMENTATION",
"UniqueID": "1792400815.54",
"UserField": "",
"LinkedID": "1792400815.54",
"sp_did": "13125551234",
"sp_direction": "inbound",
"sp_recording": ""
},
{
"Event": "Cdr",
"Privilege": "cdr,all",
"AccountCode": "",
"Source": "13125559999",
"Destination": "101",
"DestinationContext": "sp-local-numbers",
"CallerID": "\"Mobile\" <13125559999>",
"Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000016",
"DestinationChannel": "",
"LastApplication": "Park",
"LastData": "default:701",
"StartTime": "2026-10-19 09:06:57",
"AnswerTime": "2026-10-19 09:06:57",
"EndTime": "2026-10-19 09:06:59",
"Duration": "2",
"BillableSeconds": "2",
"Disposition": "ANSWERED",
"AMAFlags": "DOCUMENTATION",
"UniqueID": "1792400815.54",
"UserField": "",
"LinkedID": "1792400815.54",
"sp_did": "13125551234",
"sp_direction": "inbound",
"sp_recording": ""
},
{
"Event": "Cdr",
"Privilege": "cdr,all",
"AccountCode": "",
"Source": "13125559999",
"Destination": "101",
"DestinationContext": "sp-local-numbers",
"CallerID": "\"Mobile\" <13125559999>",
"Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000016",
"DestinationChannel": "PJSIP/103-00000018",
"LastApplication": "Dial",
"LastData": "PJSIP/101/sip:101-0x5987fe8320b0@172.21.0.4:5070,25,tx",
"StartTime": "2026-10-19 09:06:59",
"AnswerTime": "2026-10-19 09:06:59",
"EndTime": "2026-10-19 09:07:03",
"Duration": "4",
"BillableSeconds": "4",
"Disposition": "ANSWERED",
"AMAFlags": "DOCUMENTATION",
"UniqueID": "1792400815.54",
"UserField": "",
"LinkedID": "1792400815.54",
"sp_did": "13125551234",
"sp_direction": "inbound",
"sp_recording": ""
}
]
+53
View File
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Calls waiting in parking slots (701–720), live. Anyone picks one up by dialing its slot.
import { useLive } from '../live/store.ts';
import { fmtNumber } from '../pages/admin/CallsPage.tsx';
import { since, useNow } from '../pages/admin/DashboardPage.tsx';
export function ParkedCalls({ onPickUp }: { onPickUp?: (slot: string) => void }) {
const parked = Object.values(useLive((s) => s.parked)).sort((a, b) =>
a.slot.localeCompare(b.slot),
);
const now = useNow();
return (
<section aria-labelledby="parked-h" className="flex flex-col gap-2">
<h2 id="parked-h" className="font-medium">
Parked calls
</h2>
{parked.length === 0 ? (
<p className="text-sm text-zinc-500">
None. Transfer a call to 700 to park it; dial the slot to pick it up.
</p>
) : (
<ul
aria-label="Parked calls"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
>
{parked.map((p) => (
<li
key={p.slot}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="w-10 font-mono font-semibold">{p.slot}</span>
<span className="min-w-0 flex-1 truncate">
{p.callerName ? `${p.callerName} ` : ''}
<span className="text-zinc-500">{fmtNumber(p.callerNumber)}</span>
{p.parkedBy && <span className="text-zinc-500"> · parked by {p.parkedBy}</span>}
</span>
<span className="font-mono tabular-nums">{since(p.since, now)}</span>
{onPickUp && (
<button
type="button"
className="rounded-md border border-zinc-300 px-2 py-1 dark:border-zinc-700"
onClick={() => onPickUp(p.slot)}
>
Pick up {p.slot}
</button>
)}
</li>
))}
</ul>
)}
</section>
);
}
+11
View File
@@ -6,6 +6,7 @@ import type {
EmergencyCall,
ExtensionLive,
LiveMessage,
ParkedLive,
QueueLive,
TrunkLive,
} from '@selfphoneng/shared';
@@ -21,6 +22,8 @@ type LiveStore = {
calls: Record<string, ActiveCall>;
/** By number. Admins: all; users: the queues they're an agent of. */
queues: Record<string, QueueLive>;
/** By slot (everyone sees them). */
parked: Record<string, ParkedLive>;
/** 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. */
@@ -36,6 +39,7 @@ export const useLive = create<LiveStore>(() => ({
trunks: {},
calls: {},
queues: {},
parked: {},
emergencies: [],
voicemailSeq: 0,
toasts: [],
@@ -52,6 +56,7 @@ function apply(m: LiveMessage) {
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])),
parked: Object.fromEntries(m.data.parked.map((p) => [p.slot, p])),
};
case 'extension.updated':
return { extensions: { ...s.extensions, [m.data.number]: m.data } };
@@ -77,6 +82,12 @@ function apply(m: LiveMessage) {
const { [m.data.number]: _gone, ...rest } = s.queues;
return { queues: rest };
}
case 'parked.updated':
return { parked: { ...s.parked, [m.data.slot]: m.data } };
case 'parked.removed': {
const { [m.data.slot]: _gone, ...rest } = s.parked;
return { parked: rest };
}
case 'emergency.call':
return { emergencies: [...s.emergencies, m.data] };
case 'toast':
+4 -1
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Dashboard (05 §2). Warnings, telephony link, trunks, calls in progress (hang up / transfer)
// queue tiles and extensions online. Parked calls arrive with M8-T2.
// queue tiles, parked calls and extensions online.
import { useMutation, useQuery } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react';
import type { ActiveCall, QueueLive } from '@selfphoneng/shared';
@@ -11,6 +11,7 @@ import { Alert, Button, inputClass } from '../../components/ui.tsx';
import { fmtNumber } from './CallsPage.tsx';
import { StatusDot, statusLabel } from '../../components/StatusDot.tsx';
import { useLive } from '../../live/store.ts';
import { ParkedCalls } from '../../components/ParkedCalls.tsx';
function Card({ title, children }: { title: string; children: ReactNode }) {
return (
@@ -66,6 +67,8 @@ export function DashboardPage() {
<LiveCalls />
<ParkedCalls />
<div>
<h2 className="mb-2 font-medium">Extensions</h2>
{list.length === 0 ? (
+17
View File
@@ -14,6 +14,7 @@ import { PresencePanel } from '../../softphone/PresencePanel.tsx';
import { VoicemailPage, callerOf, isNew, mmss } from './VoicemailPage.tsx';
import { MyCallsPage, otherParty, useCallBack } from './MyCallsPage.tsx';
import { fmtNumber } from '../admin/CallsPage.tsx';
import { ParkedCalls } from '../../components/ParkedCalls.tsx';
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
const link = 'text-sky-700 underline dark:text-sky-400';
@@ -126,6 +127,7 @@ function Home({ user }: { user: AuthUser }) {
<p>Hi {user.displayName}.</p>
{user.extension && <StatusCard ext={user.extension.number} />}
{user.extension && <MissedCalls />}
{user.extension && <ParkedOnHome />}
<section aria-labelledby="new-vm" className="flex flex-col gap-2">
<h2 id="new-vm" className="text-lg font-semibold">
New voicemails
@@ -216,6 +218,21 @@ function QueueLogins({ ext }: { ext: string }) {
);
}
/** Parked calls with a Pick up button: my phone (or softphone) dials the slot. */
function ParkedOnHome() {
const { call, note } = useCallBack();
return (
<div className="flex flex-col gap-1">
<ParkedCalls onPickUp={call} />
{note && (
<p role="status" className="text-sm text-zinc-500">
{note}
</p>
)}
</div>
);
}
/** Missed calls from the last page of my history (05 §4 Home). */
function MissedCalls() {
const calls = useQuery({ queryKey: ['me', 'calls', 'home'], queryFn: () => meApi.calls() });
+4
View File
@@ -464,6 +464,10 @@ findslot = next
parkext_exclusive = no
parkinghints = yes
```
`parkinghints` gives each slot a hint (`701@sp-parkedcalls` → `park:701@sp-parkedcalls`) for BLF
keys; phones reach it through `sp-local-numbers`, which includes `sp-parkedcalls`. An empty slot's
state is Unavailable (BLF off), a parked call makes it InUse. `findslot = next` means each park
takes the slot after the last one used, not always 701 (verified in M8-T2).
### 3.14 `confbridge.conf`
```ini
+11 -2
View File
@@ -119,7 +119,7 @@ type ActiveCall = {
recording: boolean;
};
type QueueLive = { id: string; number: string; name: string; waiting: { linkedid; callerNumber; callerName?; since }[]; members: { ext; paused; state; callsTaken; lastCall? }[] };
type ParkedLive = { slot: string; linkedid: string; callerNumber: string; since: string; parkedBy?: string };
type ParkedLive = { slot: string; linkedid: string; callerNumber: string; callerName?: string; since: string; parkedBy?: string };
type MwiLive = Record<string /*ext*/, { new: number; old: number }>;
```
@@ -162,7 +162,7 @@ the channel events carry everything. A call's channels are ordered by creation (
so `channels[0]` is the caller. State, first match wins: hold, voicemail (app `VoiceMail`), ivr
(`BackGround`/`WaitExten`/`Read`), queued, parked, talking (2+ channels bridged and Up), else
ringing. `answeredAt` is the first time 2 channels are bridged; on a reload, `startedAt` is
`now - Duration`. Snapshots carry `parked: []` until M8-T2. Calls go to admins
`now - Duration`. Calls go to admins
only for now; a user's own calls are added when a user page shows them.
As built (M8-T1): queues live in `live/queues.ts` (`QueueTracker`), loaded from `QueueStatus` on
@@ -171,6 +171,13 @@ is the `Status` device state (1 idle, 2 in use, 3 busy, 6 ringing, 7 ringinuse,
unavailable). Every snapshot has `queues`; a user's holds only the queues they are an agent of, and a
`queue.updated` for a queue they aren't in reaches them as `queue.removed`.
As built (M8-T2): parked calls live in `live/parking.ts` (`ParkingTracker`), loaded from `ParkedCalls`
and changed by `ParkedCall` / `UnParkedCall` / `ParkedCallTimeOut` / `ParkedCallGiveUp`; deltas are
`parked.updated` and `parked.removed` (`{slot}`), for everyone. `parkedBy` comes from
`ParkerDialString` (`PJSIP/101`). A blind transfer to 700 parks without running `Park()` in the
dialplan, so a call's `parked` state comes from those events (by `ParkeeLinkedid`), not from the
channel's application; the phone that picks it up runs `ParkedCall()` while talking (verified).
### 5.2 Live WebSocket (`GET /api/live`)
- Auth: session cookie (the browser sends it automatically). Close code 4401 when unauthenticated.
@@ -236,6 +243,8 @@ unavailable). Every snapshot has `queues`; a user's holds only the queues they a
- This is a heuristic. In M7, record real AMI Cdr events from test calls
(inbound to user, inbound to ring group, IVR → voicemail, queue abandon,
outbound, transfer, park) into `test/fixtures/cdr/*.json` and write a test per scenario.
M8 added `queue-answered`, `queue-abandoned`, `queue-timeout-voicemail` and `park-pickup` (the
call counts as answered by whoever parked it; the one who picked it up is in `rang`).
As built (M7-T1):
- `apps/api/test/asterisk/cdr-record.ts` records 8 scenarios against our config, the fake
+1
View File
@@ -33,6 +33,7 @@ Left sidebar navigation, in this order:
| Phone numbers (DIDs) | `/admin/numbers` | Table: number, label, trunk, destination summary ("→ IVR Main menu"), SMS on/off. Form: number, label, trunk, **DestinationPicker**, caller-name prefix, record inbound, SMS enabled + users allowed |
| Outbound rules | `/admin/outbound` | Sortable list (drag to reorder). Form: name, patterns (rows of match/strip/prepend with a pattern help popover), required class, trunk order (multi-select with order), caller ID override. **Test box**: pick an extension + type a number → shows the matching route, final dialed number, trunk, caller ID, or the reason it's denied |
| Ring groups | `/admin/ring-groups` | Form: number, name, strategy, ring seconds, members (ordered picker), music instead of ring, caller-name prefix, no-answer DestinationPicker |
| (Dashboard, Home) | | As built (M8-T2): a "Parked calls" list (slot, caller, parked by, waiting time) on the Dashboard and on Home; Home's has **Pick up 701**, which calls the slot like Call back (softphone, or the desk phone rings first) |
| Queues | `/admin/queues` | Form: every column from 02. Detail page shows live waiting callers + agent list with pause/unpause buttons + today's stats. As built (M8-T1): detail at `/admin/queues/:id`; music on hold offers only the current class until M8-T5 |
| IVRs (Digital receptionist) | `/admin/ivrs` | Form: number, name, greeting (SoundPicker), keypad grid 0–9 * # where each key gets a DestinationPicker or "unused", timeout, retries, invalid sound, direct dial toggle, timeout and invalid destinations |
| Office hours | `/admin/hours` | Time conditions: weekly grid editor (click-drag hours per day) that saves to `open_ranges`; timezone; holidays toggle; open/closed/holiday destinations; override digit; big status chip "Open now / Closed now" with Force open / Force closed / Automatic buttons. Holidays sub-tab: list + "Add US federal holidays for {year}" |
+15 -2
View File
@@ -79,6 +79,17 @@ export type QueueLive = {
}[];
};
/** A call waiting in a parking slot (dial the slot to pick it up). */
export type ParkedLive = {
slot: string;
linkedid: string;
callerNumber: string;
callerName?: string;
since: string;
/** The extension that parked it. */
parkedBy?: string;
};
export type LiveSnapshot = {
extensions: ExtensionLive[];
/** Admins only. */
@@ -87,8 +98,8 @@ export type LiveSnapshot = {
calls?: ActiveCall[];
/** Admins: every queue; users: the queues they are a member of. */
queues: QueueLive[];
/** Parked calls arrive with M8-T2. */
parked?: never[];
/** Everyone sees every parked call. */
parked: ParkedLive[];
ami: { connected: boolean };
};
@@ -106,6 +117,8 @@ export type LiveMessage =
/** Admins and the queue's members. */
| { type: 'queue.updated'; data: QueueLive }
| { type: 'queue.removed'; data: { number: string } }
| { type: 'parked.updated'; data: ParkedLive }
| { type: 'parked.removed'; data: { slot: string } }
/** Only to the mailbox owner. */
| { type: 'voicemail.new'; data: VoicemailDto & { userId: string } }
/** A short message for one user (e.g. "forwarding refused"). */
+6
View File
@@ -136,3 +136,9 @@ of `Local/550@sp-from-ext`). The queue page and the dashboard tile show them liv
connects the longest waiting caller, a second caller goes to 282 after 20 s, a third hangs up.
`*45550` logs 280 back in, `*45551` (not an agent) plays `ss-noservice`, and the stats count
3 calls: 1 answered, 1 hung up, 1 timed out.
`m8-t2.mjs`: three auto-answer phones (290 parks, 291 belongs to a user, 292 picks up). A caller
reaches 290, which blind-transfers it to 700 (AMI BlindTransfer, what a phone's transfer key does):
it lands on 701 and the slot's hint goes InUse. The dashboard and the user's Home list it, the live
call shows "Parked", and **Pick up 701** on Home rings 291, which dials 701 and talks to the caller.
A second park takes 702 (`findslot = next`) and 292 picks it up by dialing 702.
+7 -9
View File
@@ -2,8 +2,8 @@
// A raw AMI session for the e2e scripts (the m2-live Asterisk publishes AMI on 127.0.0.1:15038).
import { Socket } from 'node:net';
/** One AMI Originate with a caller ID (the CLI can't set one). */
export function originate(fields) {
/** One AMI action on its own session; rejects on Response: Error. */
export function action(fields) {
return new Promise((resolve, reject) => {
const s = new Socket();
let buf = '';
@@ -11,19 +11,14 @@ export function originate(fields) {
s.on('data', (d) => {
buf += d;
if (/Response: Error/.test(buf)) reject(new Error(buf));
if (/ActionID: o1[\s\S]*\r\n\r\n/.test(buf)) {
if (/ActionID: a1[\s\S]*\r\n\r\n/.test(buf)) {
s.end();
resolve();
}
});
s.on('error', reject);
s.connect(15038, '127.0.0.1', () => {
const lines = Object.entries({
...fields,
Action: 'Originate',
Async: 'true',
ActionID: 'o1',
})
const lines = Object.entries({ ...fields, ActionID: 'a1' })
.map(([k, v]) => `${k}: ${v}`)
.join('\r\n');
s.write(
@@ -32,3 +27,6 @@ export function originate(fields) {
});
});
}
/** One AMI Originate with a caller ID (the CLI can't set one). */
export const originate = (fields) => action({ ...fields, Action: 'Originate', Async: 'true' });
+196
View File
@@ -0,0 +1,196 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M8-T2 with real phones: 290 answers a call and parks it (blind transfer to 700, as a phone's
// transfer key does), it lands on 701, the slot's BLF hint goes busy, the dashboard and a user's
// Home list it, and the user picks it up with "Pick up 701": their desk phone 291 rings, then
// dials 701 and talks to the caller. A second park takes 702 (findslot = next) and another
// phone (292) picks it up by dialing 702.
// Runs on m2-live, after m1-t4.mjs.
// Usage: node scripts/e2e/m8-t2.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { action, originate } from './ami.mjs';
import { launch } from './cdp.mjs';
import { startPhones } from './phones.mjs';
const NET = 'sp-live';
const AST = 'sp-live-ast';
const PIA = { email: 'pia@example.com', password: 'pia-new-password-123' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const cli = (cmd) =>
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
const [base, email, pw, shotsDir] = process.argv.slice(2);
const login = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
body: JSON.stringify({ email, password: pw }),
});
const cookie = login.headers
.getSetCookie()
.map((c) => c.split(';')[0])
.join('; ');
const api = async (method, url, body) => {
const r = await fetch(`${base}${url}`, {
method,
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng', cookie },
body: body && JSON.stringify(body),
});
const json = await r.json();
assert.ok(r.ok, `${method} ${url}: ${r.status} ${JSON.stringify(json)}`);
return json;
};
const waitUntil = async (fn, what, ms = 30_000) => {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn();
if (v) return v;
await sleep(300);
}
throw new Error(`timeout waiting for: ${what}`);
};
const channels = () =>
cli('core show channels concise')
.split('\n')
.filter(Boolean)
.map((l) => {
const f = l.split('!');
return { name: f[0], app: f[5], callerId: f[7], bridge: f[12] };
});
const chan = (n) => channels().find((c) => c.name.startsWith(`PJSIP/${n}-`));
/** The caller's bridged half (Local;2) shares a bridge with phone n. */
const talkingTo = (n, caller) => {
const phone = chan(n);
return (
!!phone?.bridge &&
channels().some((c) => c.callerId === caller && c !== phone && c.bridge === phone.bridge)
);
};
const hint701 = () => /701@sp-parkedcalls.*State:(\w+)/.exec(cli('core show hint 701'))?.[1];
/** Occupied slot (findslot = next: each park takes the slot after the last one used). */
const parkedSlot = () =>
/Space\s*:\s*(\d+)/.exec(cli('parking show default').split('Parked Calls')[1] ?? '')?.[1];
const parkedOn701 = () => parkedSlot() === '701';
const user = await api('POST', '/api/users', { email: PIA.email, displayName: 'Pia Picker' });
const people = [
['290', 'Paul Parker', null],
['291', 'Pia Picker', user.user.id],
['292', 'Gil Grab', null],
];
const phoneList = [];
for (const [ext, name, userId] of people) {
const e = await api('POST', '/api/extensions', {
number: ext,
name,
...(userId ? { userId } : {}),
});
const pass = (await api('GET', `/api/extensions/${e.id}/sip-credentials`)).password;
phoneList.push({ ext, password: pass, answer: 'auto' });
}
const phones = startPhones({ name: 'sp-live-phones-m8t2', net: NET, ast: AST, phones: phoneList });
/** A caller rings 290, who answers and blind-transfers it to 700. */
async function callAndPark(caller, slot = '701') {
await originate({
Channel: 'Local/290@sp-from-ext',
Application: 'Wait',
Data: '90',
CallerID: `"Carla Caller" <${caller}>`,
});
await waitUntil(() => talkingTo('290', caller), '290 answers');
await action({
Action: 'BlindTransfer',
Channel: chan('290').name,
Context: 'sp-from-ext',
Exten: '700',
});
await waitUntil(() => parkedSlot() === slot, `parked on ${slot}`);
await waitUntil(() => !chan('290'), '290 hangs up after the transfer');
}
const admin = await launch({ shotsDir });
const pia = await launch({ shotsDir });
const ROW = `(document.querySelector('ul[aria-label="Parked calls"]')?.innerText ?? '')`;
const EMPTY = `document.body.innerText.includes('Transfer a call to 700 to park it')`;
try {
await waitUntil(
() =>
['290', '291', '292'].every((e) =>
new RegExp(`${e}/sip:.*Avail`).test(cli('pjsip show contacts')),
),
'phones',
180_000,
);
await admin.goto(`${base}/login`);
await admin.waitFor(`!!document.querySelector('input[type=email]')`);
await admin.fill('Email', email);
await admin.fill('Password', pw);
await admin.click('Sign in');
await admin.waitFor(`location.pathname === '/admin'`);
await admin.waitFor(EMPTY, 'dashboard: no parked calls');
await pia.goto(`${base}/login`);
await pia.waitFor(`!!document.querySelector('input[type=email]')`);
await pia.fill('Email', PIA.email);
await pia.fill('Password', user.temporaryPassword);
await pia.click('Sign in');
await pia.waitFor(`location.pathname === '/change-password'`, 'forced password change');
await pia.fill('Current password', user.temporaryPassword);
await pia.fill('New password', PIA.password);
await pia.fill('Repeat new password', PIA.password);
await pia.click('Change password');
await pia.waitFor(`location.pathname === '/me'`, 'my phone');
// An empty slot's hint is Unavailable (BLF off), a parked call makes it InUse.
assert.notEqual(hint701(), 'InUse', 'slot 701 free before');
await callAndPark('3125550101');
await waitUntil(() => hint701() === 'InUse', 'BLF hint 701 busy');
console.log('290 parked the call on 701; hint 701@sp-parkedcalls is InUse');
await admin.waitFor(
`${ROW}.includes('701') && ${ROW}.includes('parked by 290')`,
'dashboard 701',
);
assert.match(await admin.evaluate(ROW), /Carla Caller/);
await admin.waitFor(
`[...document.querySelectorAll('ul[aria-label="Calls now"] > li')].some(li => li.innerText.includes('Parked'))`,
'the live call shows Parked',
);
await pia.waitFor(`${ROW}.includes('701')`, 'Home lists 701');
await admin.shot('m8-t2-01-dashboard');
await pia.shot('m8-t2-02-home');
console.log('dashboard and Home list the parked call on 701 (Carla Caller, parked by 290)');
// Pick up from Home: 291 rings (auto-answers), then dials 701.
await pia.click('Pick up 701');
await waitUntil(() => talkingTo('291', '3125550101'), '291 talks to the caller', 20_000);
await waitUntil(() => !parkedOn701() && hint701() !== 'InUse', 'slot 701 free again');
await pia.waitFor(EMPTY, 'Home list empties');
await admin.waitFor(EMPTY, 'dashboard list empties');
console.log('"Pick up 701" on Home: 291 rang, dialed 701 and talks to the caller; slot free');
// Dialing the slot from another phone (the next park takes 702).
cli('channel request hangup all');
await waitUntil(() => channels().length === 0, 'calls ended');
await callAndPark('3125550102', '702');
cli('channel originate PJSIP/292 extension 702@sp-from-ext');
await waitUntil(() => talkingTo('292', '3125550102'), '292 talks to the caller', 20_000);
await waitUntil(() => !parkedSlot(), 'slot 702 free again');
console.log('the next park took 702; dialing 702 from 292 picked it up');
cli('channel request hangup all');
console.log('M8-T2 (parking): PASS');
} catch (e) {
await admin.shot('m8-t2-fail-admin').catch(() => {});
await pia.shot('m8-t2-fail-user').catch(() => {});
console.log(`FAIL: ${e.message}`);
console.log(`parking: ${cli('parking show default')}`);
console.log(`hint: ${cli('core show hint 701')}`);
console.log(`browser errors: ${JSON.stringify([...admin.consoleErrors, ...pia.consoleErrors])}`);
process.exitCode = 1;
} finally {
cli('channel request hangup all');
await admin.close();
await pia.close();
phones.stop();
}