live: listen, whisper and barge on calls in progress
POST /api/live/calls/:linkedid/spy {mode} per 04 §7.3: rings the
admin's desk phone (409 if it isn't connected, 400 without an
extension) and, when they answer, runs ChanSpy on the call's first
user leg (never a trunk or Local channel, never the admin's own),
which for an inbound or queue call is the agent. Options qE / qEw /
qEB: no beep or name, the spy leg ends with the call, whisper to that
user only, or barge (both sides hear). Audited as call_spy.
Dashboard: Listen / Whisper / Barge on talking calls with a user on
them, and "your desk phone is ringing" once sent. The spy leg shows as
its own call; its Hang up stops listening.
e2e m10-t4: 380 calls 381, the admin's 382 runs ChanSpy with each
mode's options, and the barge leg ends with the call. The roadmap
records that M10's milestone check (real Yealink/Grandstream, Linphone
scanning the QR) waits for hardware the owner doesn't have yet.
Task: M10-T4
This commit is contained in:
1 parent
c1f8b04e7e
commit
62939f0138
8 files changed
+332
-4
No files matched your search
@@ -1,15 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Admin actions on calls in progress (04 §7.3): hang up, transfer. Admin only.
|
||||
// Admin actions on calls in progress (04 §7.3): hang up, transfer, listen/whisper/barge. Admin only.
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { InternalNumber } from '@selfphoneng/shared';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { HttpError, notFound } from '../errors.ts';
|
||||
import { extensions } from '../db/schema.ts';
|
||||
import { badRequest, conflict, HttpError, notFound } from '../errors.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { ownerOf } from '../services/numbering.ts';
|
||||
|
||||
const Params = z.object({ linkedid: z.string().regex(/^[\d.]+$/, 'Not a call id') });
|
||||
const TransferBody = z.object({ target: InternalNumber });
|
||||
const SpyBody = z.object({ mode: z.enum(['listen', 'whisper', 'barge']) });
|
||||
/** ChanSpy options: q no beep or name, E leave when the spied call ends; w whisper, B barge. */
|
||||
const SPY_OPTIONS = { listen: 'qE', whisper: 'qEw', barge: 'qEB' } as const;
|
||||
const SPY_VERB = {
|
||||
listen: 'Listened to',
|
||||
whisper: 'Whispered into',
|
||||
barge: 'Barged into',
|
||||
} as const;
|
||||
/** A user's phone leg (desk or browser), not a trunk or Local channel. */
|
||||
const USER_CHANNEL = /^PJSIP\/(\d+)(?:-web)?-[0-9a-f]+$/;
|
||||
|
||||
export async function liveCallRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
@@ -58,4 +70,39 @@ export async function liveCallRoutes(app: FastifyInstance) {
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/**
|
||||
* Rings the admin's desk phone; when they answer, ChanSpy on the call's first user leg (the
|
||||
* agent): listen, whisper to that user only, or barge (both sides hear).
|
||||
*/
|
||||
app.post('/api/live/calls/:linkedid/spy', async (req) => {
|
||||
const { linkedid } = Params.parse(req.params);
|
||||
const { mode } = SpyBody.parse(req.body);
|
||||
const call = callOf(linkedid);
|
||||
const [me] = await db.select().from(extensions).where(eq(extensions.userId, req.user!.id));
|
||||
if (!me) throw badRequest('You need an extension of your own to listen in');
|
||||
if (!app.live.deskOnline(me.number))
|
||||
throw conflict("Your desk phone isn't connected, so it can't ring");
|
||||
const target = call.channels.find((c) => {
|
||||
const n = USER_CHANNEL.exec(c)?.[1];
|
||||
return n !== undefined && n !== me.number;
|
||||
});
|
||||
if (!target) throw badRequest('There is no phone of a user on this call to listen to');
|
||||
const user = USER_CHANNEL.exec(target)![1]!;
|
||||
await app.ami.action({
|
||||
Action: 'Originate',
|
||||
Channel: `PJSIP/${me.number}`,
|
||||
Application: 'ChanSpy',
|
||||
Data: `${target},${SPY_OPTIONS[mode]}`,
|
||||
CallerID: `"${mode[0]!.toUpperCase()}${mode.slice(1)} ${user}" <${user}>`,
|
||||
Async: 'true',
|
||||
});
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'call_spy',
|
||||
summary: `${SPY_VERB[mode]} a call ${call.from.number} → ${call.to.number} (on ${user}'s phone)`,
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M10-T4: POST /api/live/calls/:linkedid/spy (04 §7.3). The live call list and the desk phone check
|
||||
// are stubbed; e2e m10-t4 runs it against Asterisk.
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { ActiveCall } from '@selfphoneng/shared';
|
||||
import { auditLog } from '../src/db/schema.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
const call: ActiveCall = {
|
||||
linkedid: '1792420000.10',
|
||||
startedAt: '2026-10-19T12:00:00.000Z',
|
||||
direction: 'inbound',
|
||||
from: { number: '13125551234' },
|
||||
to: { number: '101', name: 'Dan Doe' },
|
||||
state: 'talking',
|
||||
extensions: ['101'],
|
||||
channels: ['PJSIP/voipms-0000000a', 'PJSIP/101-0000000b'],
|
||||
recording: false,
|
||||
};
|
||||
|
||||
describe.skipIf(!hasTestDb)('/api/live/calls/:linkedid/spy', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
let deskOnline = true;
|
||||
const spy = (mode: string, linkedid = call.linkedid) =>
|
||||
req('POST', `/api/live/calls/${linkedid}/spy`, { mode });
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||
req = (method, url, payload) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie },
|
||||
});
|
||||
t.app.live.calls.list = () => [call];
|
||||
t.app.live.deskOnline = () => deskOnline;
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
it('needs an extension of my own', async () => {
|
||||
const r = await spy('listen');
|
||||
expect(r.statusCode).toBe(400);
|
||||
expect(r.json().error.message).toMatch(/extension of your own/);
|
||||
});
|
||||
|
||||
it('rings my desk phone running ChanSpy on the user leg, per mode; audited', async () => {
|
||||
const me = (await req('GET', '/api/auth/me')).json().user;
|
||||
await req('POST', '/api/extensions', { number: '199', name: 'Boss', userId: me.id });
|
||||
for (const [mode, opts] of [
|
||||
['listen', 'qE'],
|
||||
['whisper', 'qEw'],
|
||||
['barge', 'qEB'],
|
||||
] as const) {
|
||||
t.ami.actions.length = 0;
|
||||
const r = await spy(mode);
|
||||
expect(r.statusCode, r.body).toBe(200);
|
||||
expect(t.ami.actions).toEqual([
|
||||
expect.objectContaining({
|
||||
Action: 'Originate',
|
||||
Channel: 'PJSIP/199',
|
||||
Application: 'ChanSpy',
|
||||
Data: `PJSIP/101-0000000b,${opts}`,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
const logged = JSON.stringify(await t.db.select().from(auditLog));
|
||||
expect(logged).toContain("Whispered into a call 13125551234 → 101 (on 101's phone)");
|
||||
});
|
||||
|
||||
it('refuses: bad mode, ended call, desk phone offline, no user leg', async () => {
|
||||
expect((await spy('record')).statusCode).toBe(400);
|
||||
expect((await spy('listen', '1792420000.99')).statusCode).toBe(404);
|
||||
deskOnline = false;
|
||||
expect((await spy('listen')).statusCode).toBe(409);
|
||||
deskOnline = true;
|
||||
const trunkOnly = { ...call, channels: ['PJSIP/voipms-0000000a', 'Local/550@sp-x-0001;2'] };
|
||||
t.app.live.calls.list = () => [trunkOnly];
|
||||
expect((await spy('listen')).statusCode).toBe(400);
|
||||
t.app.live.calls.list = () => [call];
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user