parking: parked calls live, on the dashboard and Home, pick up from Home
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:
1 parent
2d9f8ed99e
commit
863a9d121c
21 files changed
+635
-23
No files matched your search
@@ -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>) => ({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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": ""
|
||||
}
|
||||
]
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}" |
|
||||
|
||||
@@ -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"). */
|
||||
|
||||
@@ -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
@@ -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' });
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user