live: listen, whisper and barge on calls in progress
ci / check (push) Successful in 54s
ci / images (push) Successful in 23s

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:
iamdoubz committed 2026-10-19 14:30:48 -05:00
1 parent c1f8b04e7e
commit 62939f0138
8 files changed
+332 -4

No files matched your search

+49 -2
View File
@@ -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 };
});
}
+87
View File
@@ -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];
});
});
+4
View File
@@ -374,4 +374,8 @@ export const liveApi = {
hangup: (linkedid: string) => api<{ ok: true }>('POST', `/api/live/calls/${linkedid}/hangup`, {}),
transfer: (linkedid: string, target: string) =>
api<{ ok: true }>('POST', `/api/live/calls/${linkedid}/transfer`, { target }),
spy: (linkedid: string, mode: SpyMode) =>
api<{ ok: true }>('POST', `/api/live/calls/${linkedid}/spy`, { mode }),
};
export type SpyMode = 'listen' | 'whisper' | 'barge';
+37 -1
View File
@@ -5,7 +5,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react';
import type { ActiveCall, QueueLive } from '@selfphoneng/shared';
import { Link } from 'react-router';
import { liveApi, settingsApi } from '../../api/client.ts';
import { liveApi, settingsApi, type SpyMode } from '../../api/client.ts';
import { ConfirmDialog } from '../../components/Dialog.tsx';
import { Alert, Button, inputClass } from '../../components/ui.tsx';
import { fmtNumber } from './CallsPage.tsx';
@@ -198,6 +198,17 @@ export const since = (iso: string, now: number) => {
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
};
const SPY_MODES = [
['listen', 'Listen', 'Your desk phone rings, then you hear the call; nobody hears you'],
['whisper', 'Whisper', 'Only your user hears you (coaching); the caller does not'],
['barge', 'Barge', 'You join the call: both sides hear you'],
] as const satisfies readonly (readonly [SpyMode, string, string])[];
const SPY_DONE = {
listen: 'listen in',
whisper: 'whisper to your user',
barge: 'join the call',
} as const;
/** Calls in progress, from the live socket (04 §5.1). */
function LiveCalls() {
const calls = Object.values(useLive((s) => s.calls)).sort((a, b) =>
@@ -214,10 +225,20 @@ function LiveCalls() {
mutationFn: ({ c, target }: { c: ActiveCall; target: string }) =>
liveApi.transfer(c.linkedid, target),
});
// Listen in (04 §7.3): the admin's desk phone rings, then hears the call.
const spy = useMutation({
mutationFn: ({ c, mode }: { c: ActiveCall; mode: SpyMode }) => liveApi.spy(c.linkedid, mode),
});
return (
<div>
<h2 className="mb-2 font-medium">Calls now ({calls.length})</h2>
{transfer.error && <Alert>{transfer.error.message}</Alert>}
{spy.error && <Alert>{spy.error.message}</Alert>}
{spy.isSuccess && (
<p role="status" className="mb-2 text-sm text-green-700 dark:text-green-400">
Your desk phone is ringing: answer it to {SPY_DONE[spy.variables.mode]}.
</p>
)}
{calls.length === 0 ? (
<p className="text-sm text-zinc-500">No calls right now.</p>
) : (
@@ -266,6 +287,21 @@ function LiveCalls() {
/>
<Button type="submit">Transfer</Button>
</form>
{c.extensions.length > 0 && c.state === 'talking' && (
<span className="flex gap-1">
{SPY_MODES.map(([mode, label, title]) => (
<Button
key={mode}
variant="ghost"
title={title}
disabled={spy.isPending}
onClick={() => spy.mutate({ c, mode })}
>
{label}
</Button>
))}
</span>
)}
<Button variant="danger" onClick={() => setHangingUp(c)}>
Hang up
</Button>