softphone: People panel with presence, click-to-call
People lists every extension with its status dot and a search box: a right-hand column on wide screens, /me/people on small ones. Call uses the browser softphone when it's on, else POST /api/me/click-to-call, which originates to the user's desk phone first (409 if none is registered). During a call: Transfer (blind), Talk first (consult), or drag the call card onto a person; a held call card offers Complete transfer whenever another call is active. useSoftphone moved to its own module. e2e m5-t3: the dot changes ~30 ms after Asterisk's hint; click-to-call rings 174 before 172. cdp click() takes a scope (People also has Call/Transfer buttons). trunk-test ignores the RTCP-at-teardown EPERM error seen once. Task: M5-T3
This commit is contained in:
1 parent
c7fd4df77c
commit
609965bba5
16 files changed
+447
-28
No files matched your search
@@ -86,6 +86,15 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
|
|||||||
return admin ? { ...rest, contacts } : rest;
|
return admin ? { ...rest, contacts } : rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A desk phone or phone app (not the browser) is registered for this extension. */
|
||||||
|
deskOnline(number: string): boolean {
|
||||||
|
return (
|
||||||
|
this.exts
|
||||||
|
.get(number)
|
||||||
|
?.contacts.some((c) => c.endpoint === number && c.status !== 'unreachable') ?? false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private setAmi(connected: boolean) {
|
private setAmi(connected: boolean) {
|
||||||
if (this.amiConnected === connected) return;
|
if (this.amiConnected === connected) return;
|
||||||
this.amiConnected = connected;
|
this.amiConnected = connected;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { decrypt } from '../crypto/secrets.ts';
|
|||||||
import { getSetting } from '../services/settings.ts';
|
import { getSetting } from '../services/settings.ts';
|
||||||
import { requireAuth } from '../auth/guards.ts';
|
import { requireAuth } from '../auth/guards.ts';
|
||||||
import { extensions, voicemailMessages } from '../db/schema.ts';
|
import { extensions, voicemailMessages } from '../db/schema.ts';
|
||||||
import { badRequest, HttpError, notFound } from '../errors.ts';
|
import { badRequest, conflict, HttpError, notFound } from '../errors.ts';
|
||||||
import { AudioError, MAX_UPLOAD_BYTES, toPcmWav } from '../media/audio.ts';
|
import { AudioError, MAX_UPLOAD_BYTES, toPcmWav } from '../media/audio.ts';
|
||||||
import { toVoicemailDto, type watchVoicemail } from '../services/voicemail.ts';
|
import { toVoicemailDto, type watchVoicemail } from '../services/voicemail.ts';
|
||||||
|
|
||||||
@@ -29,6 +29,13 @@ declare module 'fastify' {
|
|||||||
const IdParams = z.object({ id: z.uuid() });
|
const IdParams = z.object({ id: z.uuid() });
|
||||||
const KindParams = z.object({ kind: z.enum(GREETINGS) });
|
const KindParams = z.object({ kind: z.enum(GREETINGS) });
|
||||||
const MoveBody = z.object({ folder: z.enum(['INBOX', 'Old']) });
|
const MoveBody = z.object({ folder: z.enum(['INBOX', 'Old']) });
|
||||||
|
/** Anything a person would type: "(312) 555-1234", "+44 20 …", "172". */
|
||||||
|
const ClickToCallBody = z.object({
|
||||||
|
number: z
|
||||||
|
.string()
|
||||||
|
.max(40)
|
||||||
|
.regex(/^[0-9*#+()\s.-]*[0-9*#][0-9*#+()\s.-]*$/, 'Enter a number'),
|
||||||
|
});
|
||||||
|
|
||||||
export async function meRoutes(app: FastifyInstance) {
|
export async function meRoutes(app: FastifyInstance) {
|
||||||
const { db } = app;
|
const { db } = app;
|
||||||
@@ -95,6 +102,27 @@ export async function meRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Rings the user's desk phone first; when they pick up, it dials `number` (04 §7.3). */
|
||||||
|
app.post('/api/me/click-to-call', async (req) => {
|
||||||
|
const { number } = ClickToCallBody.parse(req.body);
|
||||||
|
const ext = await myExtension(req.user!.id);
|
||||||
|
if (!app.ami.isConnected())
|
||||||
|
throw new HttpError(503, 'unavailable', 'Asterisk is not connected');
|
||||||
|
if (!app.live.deskOnline(ext.number))
|
||||||
|
throw conflict("Your desk phone isn't connected, so it can't ring");
|
||||||
|
const target = number.replace(/[^0-9*#+]/g, '');
|
||||||
|
await app.ami.action({
|
||||||
|
Action: 'Originate',
|
||||||
|
Channel: `PJSIP/${ext.number}`,
|
||||||
|
Context: 'sp-from-ext',
|
||||||
|
Exten: target,
|
||||||
|
Priority: '1',
|
||||||
|
CallerID: `"${target}" <${ext.number}>`,
|
||||||
|
Async: 'true',
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/me/voicemail', async (req): Promise<ListResponse<VoicemailDto>> => {
|
app.get('/api/me/voicemail', async (req): Promise<ListResponse<VoicemailDto>> => {
|
||||||
const ext = await myExtension(req.user!.id);
|
const ext = await myExtension(req.user!.id);
|
||||||
const rows = await db
|
const rows = await db
|
||||||
|
|||||||
@@ -471,7 +471,11 @@ async function main() {
|
|||||||
|
|
||||||
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
|
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
|
||||||
const plainLog = docker('logs', PBX).out.replace(/\x1b\[[0-9;]*m/g, '');
|
const plainLog = docker('logs', PBX).out.replace(/\x1b\[[0-9;]*m/g, '');
|
||||||
const errors = plainLog.split('\n').filter((l) => /ERROR\[/.test(l));
|
// An RTCP report sent while a test container's network is going away fails with EPERM;
|
||||||
|
// that's teardown noise, not a config problem.
|
||||||
|
const errors = plainLog
|
||||||
|
.split('\n')
|
||||||
|
.filter((l) => /ERROR\[/.test(l) && !/RTCP SR transmission error/.test(l));
|
||||||
problems.push(...errors.map((l) => `pbx log: ${l.trim()}`));
|
problems.push(...errors.map((l) => `pbx log: ${l.trim()}`));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
problems.push((e as Error).message);
|
problems.push((e as Error).message);
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ describe.skipIf(!hasTestDb)('/api/me/voicemail and greetings', () => {
|
|||||||
// Asterisk's side of VoicemailMove/VoicemailRemove, on disk (one message, msg0000).
|
// Asterisk's side of VoicemailMove/VoicemailRemove, on disk (one message, msg0000).
|
||||||
t.ami.action = async function (f: Record<string, string>) {
|
t.ami.action = async function (f: Record<string, string>) {
|
||||||
this.actions.push(f);
|
this.actions.push(f);
|
||||||
const from = join(box(), f.Folder!, 'msg0000');
|
const from = join(box(), f.Folder ?? '', 'msg0000');
|
||||||
if (f.Action === 'VoicemailMove') {
|
if (f.Action === 'VoicemailMove') {
|
||||||
mkdirSync(join(box(), f.ToFolder!), { recursive: true });
|
mkdirSync(join(box(), f.ToFolder!), { recursive: true });
|
||||||
for (const x of ['wav', 'txt'])
|
for (const x of ['wav', 'txt'])
|
||||||
@@ -144,6 +144,24 @@ describe.skipIf(!hasTestDb)('/api/me/voicemail and greetings', () => {
|
|||||||
t.ami.connected = true;
|
t.ami.connected = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('click-to-call: rings my desk phone first, then dials the number', async () => {
|
||||||
|
expect((await dan('POST', '/api/me/click-to-call', { number: '172' })).statusCode).toBe(409);
|
||||||
|
t.live.deskOnline = (n) => n === '101';
|
||||||
|
const r = await dan('POST', '/api/me/click-to-call', { number: '(312) 555-1234' });
|
||||||
|
expect(r.statusCode).toBe(200);
|
||||||
|
expect(t.ami.actions.at(-1)).toEqual({
|
||||||
|
Action: 'Originate',
|
||||||
|
Channel: 'PJSIP/101',
|
||||||
|
Context: 'sp-from-ext',
|
||||||
|
Exten: '3125551234',
|
||||||
|
Priority: '1',
|
||||||
|
CallerID: '"3125551234" <101>',
|
||||||
|
Async: 'true',
|
||||||
|
});
|
||||||
|
expect((await dan('POST', '/api/me/click-to-call', { number: 'abc' })).statusCode).toBe(400);
|
||||||
|
expect((await eve('POST', '/api/me/click-to-call', { number: '172' })).statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
it('webrtc: creds for <ext>-web with ICE servers; 404 without an extension', async () => {
|
it('webrtc: creds for <ext>-web with ICE servers; 404 without an extension', async () => {
|
||||||
await setSetting(t.db, 'network.stunServer', 'stun.example.org:3478');
|
await setSetting(t.db, 'network.stunServer', 'stun.example.org:3478');
|
||||||
const r = await dan('GET', '/api/me/webrtc');
|
const r = await dan('GET', '/api/me/webrtc');
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export const systemApi = {
|
|||||||
|
|
||||||
export const meApi = {
|
export const meApi = {
|
||||||
webrtc: () => api<WebRtcCreds>('GET', '/api/me/webrtc'),
|
webrtc: () => api<WebRtcCreds>('GET', '/api/me/webrtc'),
|
||||||
|
clickToCall: (number: string) => api<{ ok: true }>('POST', '/api/me/click-to-call', { number }),
|
||||||
voicemail: () => api<ListResponse<VoicemailDto>>('GET', '/api/me/voicemail').then((r) => r.items),
|
voicemail: () => api<ListResponse<VoicemailDto>>('GET', '/api/me/voicemail').then((r) => r.items),
|
||||||
audioUrl: (id: string, download = false) =>
|
audioUrl: (id: string, download = false) =>
|
||||||
`/api/me/voicemail/${id}/audio${download ? '?download=1' : ''}`,
|
`/api/me/voicemail/${id}/audio${download ? '?download=1' : ''}`,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ThemeToggle } from '../../components/ThemeToggle.tsx';
|
|||||||
import { Alert, Button } from '../../components/ui.tsx';
|
import { Alert, Button } from '../../components/ui.tsx';
|
||||||
import { subscribeLive, useLive } from '../../live/store.ts';
|
import { subscribeLive, useLive } from '../../live/store.ts';
|
||||||
import { SoftphoneDock } from '../../softphone/SoftphoneDock.tsx';
|
import { SoftphoneDock } from '../../softphone/SoftphoneDock.tsx';
|
||||||
|
import { PresencePanel } from '../../softphone/PresencePanel.tsx';
|
||||||
import { VoicemailPage, callerOf, isNew, mmss } from './VoicemailPage.tsx';
|
import { VoicemailPage, callerOf, isNew, mmss } from './VoicemailPage.tsx';
|
||||||
|
|
||||||
const link = 'text-sky-700 underline dark:text-sky-400';
|
const link = 'text-sky-700 underline dark:text-sky-400';
|
||||||
@@ -17,6 +18,7 @@ const link = 'text-sky-700 underline dark:text-sky-400';
|
|||||||
export function MePage({ user }: { user: AuthUser }) {
|
export function MePage({ user }: { user: AuthUser }) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
|
const me = user.extension?.number ?? null;
|
||||||
useEffect(subscribeLive, []);
|
useEffect(subscribeLive, []);
|
||||||
const voicemailSeq = useLive((s) => s.voicemailSeq);
|
const voicemailSeq = useLive((s) => s.voicemailSeq);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -26,7 +28,7 @@ export function MePage({ user }: { user: AuthUser }) {
|
|||||||
const tab = ({ isActive }: { isActive: boolean }) =>
|
const tab = ({ isActive }: { isActive: boolean }) =>
|
||||||
`rounded-md px-3 py-2 text-sm font-medium ${isActive ? 'bg-zinc-100 dark:bg-zinc-800' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`;
|
`rounded-md px-3 py-2 text-sm font-medium ${isActive ? 'bg-zinc-100 dark:bg-zinc-800' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`;
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-dvh max-w-3xl flex-col gap-6 p-4 sm:p-8">
|
<main className="mx-auto flex min-h-dvh max-w-6xl flex-col gap-6 p-4 sm:p-8">
|
||||||
<header className="flex flex-wrap items-center justify-between gap-2">
|
<header className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h1 className="text-xl font-semibold">{APP_NAME}</h1>
|
<h1 className="text-xl font-semibold">{APP_NAME}</h1>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -58,12 +60,24 @@ export function MePage({ user }: { user: AuthUser }) {
|
|||||||
<NavLink to="/me/voicemail" className={tab}>
|
<NavLink to="/me/voicemail" className={tab}>
|
||||||
Voicemail
|
Voicemail
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
{/* People is a right-hand column on wide screens, a tab on small ones (05 §4.1). */}
|
||||||
|
<NavLink to="/me/people" className={(s) => `${tab(s)} lg:hidden`}>
|
||||||
|
People
|
||||||
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div className="grid flex-1 gap-8 lg:grid-cols-[1fr_20rem]">
|
||||||
|
<div className="flex min-w-0 flex-col gap-6">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Home user={user} />} />
|
<Route index element={<Home user={user} />} />
|
||||||
<Route path="voicemail" element={<VoicemailPage />} />
|
<Route path="voicemail" element={<VoicemailPage />} />
|
||||||
|
<Route path="people" element={<PresencePanel me={me} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<SoftphoneDock />
|
<SoftphoneDock />
|
||||||
|
</div>
|
||||||
|
<aside className="hidden lg:block">
|
||||||
|
<PresencePanel me={me} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
// Presence panel (05 §4.1): everyone's status, search, click to call. During a call: transfer
|
||||||
|
// (blind), talk first (attended), or drag the call card onto a person (blind).
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { ExtensionLive } from '@selfphoneng/shared';
|
||||||
|
import { meApi } from '../api/client.ts';
|
||||||
|
import { StatusDot, statusLabel } from '../components/StatusDot.tsx';
|
||||||
|
import { Button, inputClass } from '../components/ui.tsx';
|
||||||
|
import { useLive } from '../live/store.ts';
|
||||||
|
import { useSoftphone } from './useSoftphone.ts';
|
||||||
|
import { CALL_DRAG, softphone } from './SoftphoneEngine.ts';
|
||||||
|
|
||||||
|
export function PresencePanel({ me }: { me: string | null }) {
|
||||||
|
const exts = useLive((s) => s.extensions);
|
||||||
|
const { reg, calls } = useSoftphone();
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [note, setNote] = useState<string | null>(null);
|
||||||
|
const active = calls.find((c) => c.state === 'active' && !c.held);
|
||||||
|
const query = q.trim().toLowerCase();
|
||||||
|
const people = Object.values(exts)
|
||||||
|
.filter((e) => e.number !== me)
|
||||||
|
.filter((e) => !query || e.name.toLowerCase().includes(query) || e.number.includes(query))
|
||||||
|
.sort((a, b) => Number(a.number) - Number(b.number));
|
||||||
|
|
||||||
|
const run = (p: Promise<unknown>, ok?: string) => {
|
||||||
|
setNote(null);
|
||||||
|
p.then(
|
||||||
|
() => ok && setNote(ok),
|
||||||
|
(e: Error) => setNote(e.message),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// The softphone when it's on; otherwise the desk phone rings first (click-to-call).
|
||||||
|
const call = (e: ExtensionLive) =>
|
||||||
|
reg === 'registered'
|
||||||
|
? run(softphone.call(e.number))
|
||||||
|
: run(
|
||||||
|
meApi.clickToCall(e.number),
|
||||||
|
`Your desk phone is ringing. Pick it up to call ${e.name}.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="people-h" className="flex flex-col gap-2">
|
||||||
|
<h2 id="people-h" className="text-lg font-semibold">
|
||||||
|
People
|
||||||
|
</h2>
|
||||||
|
<label htmlFor="people-q" className="sr-only">
|
||||||
|
Search people
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="people-q"
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Search people"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
{note && (
|
||||||
|
<p role="status" className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
{note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{people.map((e) => (
|
||||||
|
<li
|
||||||
|
key={e.number}
|
||||||
|
data-number={e.number}
|
||||||
|
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-900"
|
||||||
|
onDragOver={(ev) => {
|
||||||
|
if (active && ev.dataTransfer.types.includes(CALL_DRAG)) ev.preventDefault();
|
||||||
|
}}
|
||||||
|
onDrop={(ev) => {
|
||||||
|
const id = ev.dataTransfer.getData(CALL_DRAG);
|
||||||
|
if (id) run(softphone.blindTransfer(id, e.number));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatusDot ext={e} />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm">
|
||||||
|
<span className="font-medium">{e.name}</span>{' '}
|
||||||
|
<span className="text-zinc-500">
|
||||||
|
{e.number} · {statusLabel(e)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{active ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className="px-2 py-1"
|
||||||
|
aria-label={`Transfer to ${e.name}`}
|
||||||
|
onClick={() => run(softphone.blindTransfer(active.id, e.number))}
|
||||||
|
>
|
||||||
|
Transfer
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="px-2 py-1"
|
||||||
|
aria-label={`Talk to ${e.name} first`}
|
||||||
|
onClick={() => run(softphone.call(e.number))}
|
||||||
|
>
|
||||||
|
Talk first
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className="px-2 py-1"
|
||||||
|
aria-label={`Call ${e.name}`}
|
||||||
|
disabled={calls.length >= 2}
|
||||||
|
onClick={() => call(e)}
|
||||||
|
>
|
||||||
|
Call
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,14 +2,15 @@
|
|||||||
// Softphone on every /me page (05 §5.3): a bar at the bottom that opens a panel with the
|
// Softphone on every /me page (05 §5.3): a bar at the bottom that opens a panel with the
|
||||||
// dialpad and audio devices; call cards with mute/hold/keypad/transfer; the incoming call dialog.
|
// dialpad and audio devices; call cards with mute/hold/keypad/transfer; the incoming call dialog.
|
||||||
// Not yet: DND toggle (M6), Record via *1 and recent calls (M7).
|
// Not yet: DND toggle (M6), Record via *1 and recent calls (M7).
|
||||||
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { formatNumber } from '@selfphoneng/shared';
|
import { formatNumber } from '@selfphoneng/shared';
|
||||||
import { meApi } from '../api/client.ts';
|
import { meApi } from '../api/client.ts';
|
||||||
import { Dialog } from '../components/Dialog.tsx';
|
import { Dialog } from '../components/Dialog.tsx';
|
||||||
import { Button, inputClass } from '../components/ui.tsx';
|
import { Button, inputClass } from '../components/ui.tsx';
|
||||||
import { useLive } from '../live/store.ts';
|
import { useLive } from '../live/store.ts';
|
||||||
import { onTakeOver, stepDown, takeOver, tryLead } from './leader.ts';
|
import { onTakeOver, stepDown, takeOver, tryLead } from './leader.ts';
|
||||||
import { softphone, type CallInfo } from './SoftphoneEngine.ts';
|
import { CALL_DRAG, softphone, type CallInfo } from './SoftphoneEngine.ts';
|
||||||
|
import { useSoftphone } from './useSoftphone.ts';
|
||||||
import { startRinging, stopRinging } from './sounds.ts';
|
import { startRinging, stopRinging } from './sounds.ts';
|
||||||
|
|
||||||
const KEY = 'softphone.enabled';
|
const KEY = 'softphone.enabled';
|
||||||
@@ -29,8 +30,6 @@ const remember = (on: boolean) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSoftphone = () => useSyncExternalStore(softphone.subscribe, () => softphone.state);
|
|
||||||
|
|
||||||
onTakeOver(async () => {
|
onTakeOver(async () => {
|
||||||
await softphone.stop();
|
await softphone.stop();
|
||||||
softphone.setElsewhere();
|
softphone.setElsewhere();
|
||||||
@@ -290,7 +289,10 @@ function CallCard({
|
|||||||
const [transfer, setTransfer] = useState(false);
|
const [transfer, setTransfer] = useState(false);
|
||||||
const [target, setTarget] = useState('');
|
const [target, setTarget] = useState('');
|
||||||
const [consultId, setConsultId] = useState<string | null>(null);
|
const [consultId, setConsultId] = useState<string | null>(null);
|
||||||
const consult = useSoftphone().calls.find((c) => c.id === consultId);
|
const all = useSoftphone().calls;
|
||||||
|
const consult = all.find((c) => c.id === consultId);
|
||||||
|
// Held here while talking on another call (call waiting, or "Talk first" from People).
|
||||||
|
const other = all.find((c) => c.id !== call.id && c.state === 'active' && !c.held);
|
||||||
const run = (p: Promise<unknown>) => {
|
const run = (p: Promise<unknown>) => {
|
||||||
onError(null);
|
onError(null);
|
||||||
p.catch((e: Error) => onError(e.message));
|
p.catch((e: Error) => onError(e.message));
|
||||||
@@ -310,6 +312,9 @@ function CallCard({
|
|||||||
role="group"
|
role="group"
|
||||||
aria-label={`${label}: ${name}`}
|
aria-label={`${label}: ${name}`}
|
||||||
className="flex flex-col gap-2 rounded-md bg-zinc-100 p-2 dark:bg-zinc-900"
|
className="flex flex-col gap-2 rounded-md bg-zinc-100 p-2 dark:bg-zinc-900"
|
||||||
|
// Drop it on a person in People to transfer it there.
|
||||||
|
draggable={call.state === 'active'}
|
||||||
|
onDragStart={(e) => e.dataTransfer.setData(CALL_DRAG, call.id)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="flex-1 text-sm">
|
<span className="flex-1 text-sm">
|
||||||
@@ -330,6 +335,14 @@ function CallCard({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{call.held && other && !consultId && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => run(softphone.attendedTransfer(call.id, other.id))}
|
||||||
|
>
|
||||||
|
Complete transfer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{call.state === 'active' && (
|
{call.state === 'active' && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -379,3 +379,6 @@ export class SoftphoneEngine {
|
|||||||
|
|
||||||
/** One engine per tab. */
|
/** One engine per tab. */
|
||||||
export const softphone = new SoftphoneEngine();
|
export const softphone = new SoftphoneEngine();
|
||||||
|
|
||||||
|
/** dataTransfer type for dragging a call card onto a person in People (blind transfer). */
|
||||||
|
export const CALL_DRAG = 'application/x-selfphoneng-call';
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
// React view of the softphone engine (05 §5.1).
|
||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
import { softphone } from './SoftphoneEngine.ts';
|
||||||
|
|
||||||
|
export const useSoftphone = () => useSyncExternalStore(softphone.subscribe, () => softphone.state);
|
||||||
@@ -106,6 +106,12 @@ Right column on wide screens, a tab on mobile. Lists every extension with its
|
|||||||
StatusDot, name and number, plus a search box. Click to call, or to transfer when
|
StatusDot, name and number, plus a search box. Click to call, or to transfer when
|
||||||
in a call (drop the active call onto a person = blind transfer; a menu offers attended).
|
in a call (drop the active call onto a person = blind transfer; a menu offers attended).
|
||||||
|
|
||||||
|
As built (M5-T3): the tab is `/me/people`, shown only below the `lg` breakpoint. **Call** uses the
|
||||||
|
browser softphone when it's registered, and otherwise click-to-call: the desk phone rings first,
|
||||||
|
or a 409 comes back if no desk phone is registered. During a call each person shows **Transfer**
|
||||||
|
(blind) and **Talk first** (a consult call; the first call is held). A held call card offers
|
||||||
|
**Complete transfer** whenever another call is active, which also covers call waiting.
|
||||||
|
|
||||||
## 5. Browser softphone (`src/softphone/`)
|
## 5. Browser softphone (`src/softphone/`)
|
||||||
|
|
||||||
### 5.1 Structure
|
### 5.1 Structure
|
||||||
|
|||||||
@@ -69,3 +69,8 @@ directions.
|
|||||||
`phones.mjs` and checks the softphone features: the incoming call dialog, DTMF, hold (music on hold
|
`phones.mjs` and checks the softphone features: the incoming call dialog, DTMF, hold (music on hold
|
||||||
at the other end), call waiting, blind and attended transfer, and "Use here" moving the softphone to
|
at the other end), call waiting, blind and attended transfer, and "Use here" moving the softphone to
|
||||||
a second tab (`cdp.mjs` `newTab()`).
|
a second tab (`cdp.mjs` `newTab()`).
|
||||||
|
|
||||||
|
`m5-t3.mjs` (after `m5-t2.mjs`) is the M5-T3 acceptance. It watches People in one browser while 173
|
||||||
|
calls 172 and measures the time from Asterisk's hint changing to the dot changing (must be < 1 s).
|
||||||
|
Then a second browser, as a new user with desk phone 174 and the softphone off, clicks **Call** on
|
||||||
|
172: 174 must ring first (Asterisk channel order), then the two are connected.
|
||||||
+5
-2
@@ -115,10 +115,13 @@ async function attach(wsUrl, { width, height, shotsDir }) {
|
|||||||
return true;
|
return true;
|
||||||
})()`),
|
})()`),
|
||||||
/** Clicks the first visible button/link whose text or aria-label matches. */
|
/** Clicks the first visible button/link whose text or aria-label matches. */
|
||||||
click: (text) =>
|
/** `within`: a CSS selector to search inside, when the same label appears elsewhere. */
|
||||||
|
click: (text, within = 'body') =>
|
||||||
evaluate(`(() => {
|
evaluate(`(() => {
|
||||||
const t = ${JSON.stringify(text)};
|
const t = ${JSON.stringify(text)};
|
||||||
const el = [...document.querySelectorAll('button, a')].reverse().find(e => (e.textContent.trim() === t || e.getAttribute('aria-label') === t) && e.getClientRects().length);
|
const root = document.querySelector(${JSON.stringify(within)});
|
||||||
|
if (!root) throw new Error('no ' + ${JSON.stringify(within)});
|
||||||
|
const el = [...root.querySelectorAll('button, a')].reverse().find(e => (e.textContent.trim() === t || e.getAttribute('aria-label') === t) && e.getClientRects().length);
|
||||||
if (!el) throw new Error('no visible button ' + t);
|
if (!el) throw new Error('no visible button ' + t);
|
||||||
el.click();
|
el.click();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const AST = 'sp-live-ast';
|
|||||||
const PHONE = 'sp-live-phone171';
|
const PHONE = 'sp-live-phone171';
|
||||||
const USER = { email: 'web@example.com', password: 'web-new-password-123' };
|
const USER = { email: 'web@example.com', password: 'web-new-password-123' };
|
||||||
const SECONDS = 8;
|
const SECONDS = 8;
|
||||||
|
/** The softphone's own buttons (People rows also have Call/Transfer). */
|
||||||
|
const SP = '[aria-label=Softphone]';
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
const docker = (...a) => {
|
const docker = (...a) => {
|
||||||
const r = spawnSync('docker', a, { encoding: 'utf8' });
|
const r = spawnSync('docker', a, { encoding: 'utf8' });
|
||||||
@@ -165,14 +167,14 @@ try {
|
|||||||
await p.shot('m5-t1-01-ready');
|
await p.shot('m5-t1-01-ready');
|
||||||
|
|
||||||
// Browser → desk phone.
|
// Browser → desk phone.
|
||||||
await p.click('Keypad');
|
await p.click('Keypad', SP);
|
||||||
await p.fill('Number to call', '171');
|
await p.fill('Number to call', '171');
|
||||||
await p.click('Call');
|
await p.click('Call', SP);
|
||||||
await p.waitFor(`document.body.innerText.includes('On call')`, 'outgoing call answered', 15_000);
|
await p.waitFor(`document.body.innerText.includes('On call')`, 'outgoing call answered', 15_000);
|
||||||
await sleep(SECONDS * 1000);
|
await sleep(SECONDS * 1000);
|
||||||
await p.shot('m5-t1-02-outgoing');
|
await p.shot('m5-t1-02-outgoing');
|
||||||
assertTwoWay('browser → desk');
|
assertTwoWay('browser → desk');
|
||||||
await p.click('Hang up');
|
await p.click('Hang up', SP);
|
||||||
await p.waitFor(`!document.body.innerText.includes('On call')`, 'hung up');
|
await p.waitFor(`!document.body.innerText.includes('On call')`, 'hung up');
|
||||||
await hangupAll();
|
await hangupAll();
|
||||||
|
|
||||||
@@ -180,11 +182,11 @@ try {
|
|||||||
cli(`channel originate PJSIP/171 extension 170@sp-from-ext`);
|
cli(`channel originate PJSIP/171 extension 170@sp-from-ext`);
|
||||||
await p.waitFor(`document.body.innerText.includes('Incoming call')`, 'incoming call', 15_000);
|
await p.waitFor(`document.body.innerText.includes('Incoming call')`, 'incoming call', 15_000);
|
||||||
await p.shot('m5-t1-03-incoming');
|
await p.shot('m5-t1-03-incoming');
|
||||||
await p.click('Answer');
|
await p.click('Answer', SP);
|
||||||
await p.waitFor(`document.body.innerText.includes('On call')`, 'incoming answered', 10_000);
|
await p.waitFor(`document.body.innerText.includes('On call')`, 'incoming answered', 10_000);
|
||||||
await sleep(SECONDS * 1000);
|
await sleep(SECONDS * 1000);
|
||||||
assertTwoWay('desk → browser');
|
assertTwoWay('desk → browser');
|
||||||
await p.click('Hang up');
|
await p.click('Hang up', SP);
|
||||||
await p.waitFor(`!document.body.innerText.includes('On call')`, 'hung up');
|
await p.waitFor(`!document.body.innerText.includes('On call')`, 'hung up');
|
||||||
await hangupAll();
|
await hangupAll();
|
||||||
|
|
||||||
|
|||||||
+14
-12
@@ -12,6 +12,8 @@ import { startPhones } from './phones.mjs';
|
|||||||
const NET = 'sp-live';
|
const NET = 'sp-live';
|
||||||
const AST = 'sp-live-ast';
|
const AST = 'sp-live-ast';
|
||||||
const USER = { email: 'web@example.com', password: 'web-new-password-123' };
|
const USER = { email: 'web@example.com', password: 'web-new-password-123' };
|
||||||
|
/** The softphone's own buttons (People rows also have Call/Transfer). */
|
||||||
|
const SP = '[aria-label=Softphone]';
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
const docker = (...a) => spawnSync('docker', a, { encoding: 'utf8' }).stdout ?? '';
|
const docker = (...a) => spawnSync('docker', a, { encoding: 'utf8' }).stdout ?? '';
|
||||||
const cli = (cmd) => docker('exec', AST, 'asterisk', '-rx', cmd);
|
const cli = (cmd) => docker('exec', AST, 'asterisk', '-rx', cmd);
|
||||||
@@ -132,7 +134,7 @@ try {
|
|||||||
console.log('incoming dialog → answered');
|
console.log('incoming dialog → answered');
|
||||||
|
|
||||||
// 2. DTMF from the keypad reaches the other phone.
|
// 2. DTMF from the keypad reaches the other phone.
|
||||||
await p.click('Keypad');
|
await p.click('Keypad', SP);
|
||||||
await p.evaluate(
|
await p.evaluate(
|
||||||
`[...document.querySelectorAll('[aria-label="Send tones"] button')].find(b => b.textContent === '5').click(), true`,
|
`[...document.querySelectorAll('[aria-label="Send tones"] button')].find(b => b.textContent === '5').click(), true`,
|
||||||
);
|
);
|
||||||
@@ -141,11 +143,11 @@ try {
|
|||||||
|
|
||||||
// 3. Hold / resume: Asterisk plays music on hold to 172 while held.
|
// 3. Hold / resume: Asterisk plays music on hold to 172 while held.
|
||||||
let at = mark();
|
let at = mark();
|
||||||
await p.click('Hold');
|
await p.click('Hold', SP);
|
||||||
await p.waitFor(`document.body.innerText.includes('On hold')`, 'held in the UI');
|
await p.waitFor(`document.body.innerText.includes('On hold')`, 'held in the UI');
|
||||||
await waitUntil(() => /music on hold.*PJSIP\/172-/i.test(logSince(at)), 'MOH to 172', 5000);
|
await waitUntil(() => /music on hold.*PJSIP\/172-/i.test(logSince(at)), 'MOH to 172', 5000);
|
||||||
at = mark();
|
at = mark();
|
||||||
await p.click('Resume');
|
await p.click('Resume', SP);
|
||||||
await waitUntil(
|
await waitUntil(
|
||||||
() => /Stopped music on hold.*PJSIP\/172-/i.test(logSince(at)),
|
() => /Stopped music on hold.*PJSIP\/172-/i.test(logSince(at)),
|
||||||
'MOH stopped',
|
'MOH stopped',
|
||||||
@@ -162,7 +164,7 @@ try {
|
|||||||
15_000,
|
15_000,
|
||||||
);
|
);
|
||||||
await p.shot('m5-t2-02-waiting');
|
await p.shot('m5-t2-02-waiting');
|
||||||
await p.click('Answer');
|
await p.click('Answer', SP);
|
||||||
await waitUntil(() => /music on hold.*PJSIP\/172-/i.test(logSince(at)), '172 held', 8000);
|
await waitUntil(() => /music on hold.*PJSIP\/172-/i.test(logSince(at)), '172 held', 8000);
|
||||||
await p.waitFor(
|
await p.waitFor(
|
||||||
`document.body.innerText.includes('On hold') && document.body.innerText.includes('On call')`,
|
`document.body.innerText.includes('On hold') && document.body.innerText.includes('On call')`,
|
||||||
@@ -172,13 +174,13 @@ try {
|
|||||||
await p.waitFor(`!document.body.innerText.includes('On call')`, 'calls gone in the UI');
|
await p.waitFor(`!document.body.innerText.includes('On call')`, 'calls gone in the UI');
|
||||||
|
|
||||||
// 5. Blind transfer: browser calls 172, transfers it to 173.
|
// 5. Blind transfer: browser calls 172, transfers it to 173.
|
||||||
await p.click('Keypad');
|
await p.click('Keypad', SP);
|
||||||
await p.fill('Number to call', '172');
|
await p.fill('Number to call', '172');
|
||||||
await p.click('Call');
|
await p.click('Call', SP);
|
||||||
await p.waitFor(`document.body.innerText.includes('On call')`, 'browser → 172 answered', 15_000);
|
await p.waitFor(`document.body.innerText.includes('On call')`, 'browser → 172 answered', 15_000);
|
||||||
await p.click('Transfer');
|
await p.click('Transfer', SP);
|
||||||
await p.fill('Transfer to', '173');
|
await p.fill('Transfer to', '173');
|
||||||
await p.click('Transfer now');
|
await p.click('Transfer now', SP);
|
||||||
await waitUntil(transferred, 'blind transfer: 172 ↔ 173');
|
await waitUntil(transferred, 'blind transfer: 172 ↔ 173');
|
||||||
await p.waitFor(`!document.body.innerText.includes('On call')`, 'browser left the call');
|
await p.waitFor(`!document.body.innerText.includes('On call')`, 'browser left the call');
|
||||||
console.log('blind transfer: 172 now talks to 173');
|
console.log('blind transfer: 172 now talks to 173');
|
||||||
@@ -186,18 +188,18 @@ try {
|
|||||||
|
|
||||||
// 6. Attended transfer: browser calls 172, talks to 173 first, then completes.
|
// 6. Attended transfer: browser calls 172, talks to 173 first, then completes.
|
||||||
await p.fill('Number to call', '172');
|
await p.fill('Number to call', '172');
|
||||||
await p.click('Call');
|
await p.click('Call', SP);
|
||||||
await p.waitFor(`document.body.innerText.includes('On call')`, 'browser → 172 answered', 15_000);
|
await p.waitFor(`document.body.innerText.includes('On call')`, 'browser → 172 answered', 15_000);
|
||||||
await p.click('Transfer');
|
await p.click('Transfer', SP);
|
||||||
await p.fill('Transfer to', '173');
|
await p.fill('Transfer to', '173');
|
||||||
await p.click('Talk first');
|
await p.click('Talk first', SP);
|
||||||
await p.waitFor(
|
await p.waitFor(
|
||||||
`[...document.querySelectorAll('button')].some(b => b.textContent === 'Complete transfer' && !b.disabled)`,
|
`[...document.querySelectorAll('button')].some(b => b.textContent === 'Complete transfer' && !b.disabled)`,
|
||||||
'consult call to 173 answered',
|
'consult call to 173 answered',
|
||||||
15_000,
|
15_000,
|
||||||
);
|
);
|
||||||
await p.shot('m5-t2-03-consult');
|
await p.shot('m5-t2-03-consult');
|
||||||
await p.click('Complete transfer');
|
await p.click('Complete transfer', SP);
|
||||||
await waitUntil(transferred, 'attended transfer: 172 ↔ 173');
|
await waitUntil(transferred, 'attended transfer: 172 ↔ 173');
|
||||||
console.log('attended transfer: 172 now talks to 173');
|
console.log('attended transfer: 172 now talks to 173');
|
||||||
await hangupAll();
|
await hangupAll();
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
// M5-T3 acceptance: presence dots change in < 1 s after Asterisk's state changes, and
|
||||||
|
// click-to-call from People rings the user's desk phone first, then the other person.
|
||||||
|
// Run after m5-t2.mjs (extensions 172/173 and user web@example.com exist).
|
||||||
|
// Usage: node scripts/e2e/m5-t3.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { launch } from './cdp.mjs';
|
||||||
|
import { startPhones } from './phones.mjs';
|
||||||
|
|
||||||
|
const NET = 'sp-live';
|
||||||
|
const AST = 'sp-live-ast';
|
||||||
|
const WEB = { email: 'web@example.com', password: 'web-new-password-123' };
|
||||||
|
const CC = { email: 'cc@example.com', password: 'cc-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 exts = (await api('GET', '/api/extensions')).items;
|
||||||
|
const secret = async (id) => (await api('GET', `/api/extensions/${id}/sip-credentials`)).password;
|
||||||
|
const cc = await api('POST', '/api/users', { email: CC.email, displayName: 'Cam Clicker' });
|
||||||
|
const ccExt = await api('POST', '/api/extensions', {
|
||||||
|
number: '174',
|
||||||
|
name: 'Cam Clicker',
|
||||||
|
userId: cc.user.id,
|
||||||
|
});
|
||||||
|
const byNumber = (n) => exts.find((e) => e.number === n);
|
||||||
|
const phones = startPhones({
|
||||||
|
name: 'sp-live-phones-m5t3',
|
||||||
|
net: NET,
|
||||||
|
ast: AST,
|
||||||
|
phones: [
|
||||||
|
{ ext: '172', password: await secret(byNumber('172').id), answer: 'auto' },
|
||||||
|
{ ext: '173', password: await secret(byNumber('173').id), answer: 'auto', toneHz: 660 },
|
||||||
|
{ ext: '174', password: await secret(ccExt.id), answer: 'auto', toneHz: 550 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const channels = () =>
|
||||||
|
cli('core show channels concise')
|
||||||
|
.split('\n')
|
||||||
|
.filter((l) => l.startsWith('PJSIP/'))
|
||||||
|
.map((l) => {
|
||||||
|
const f = l.split('!');
|
||||||
|
return { name: f[0], state: f[4], bridge: f[12], seq: Number(f[13]?.split('.')[1]) };
|
||||||
|
});
|
||||||
|
const hangupAll = async () => {
|
||||||
|
cli('channel request hangup all');
|
||||||
|
for (let i = 0; i < 40 && channels().length; i++) await sleep(250);
|
||||||
|
await sleep(500);
|
||||||
|
};
|
||||||
|
async function signIn(p, who, temp) {
|
||||||
|
await p.goto(`${base}/login`);
|
||||||
|
await p.waitFor(`!!document.querySelector('input[type=email]')`);
|
||||||
|
await p.fill('Email', who.email);
|
||||||
|
await p.fill('Password', temp ?? who.password);
|
||||||
|
await p.click('Sign in');
|
||||||
|
if (temp) {
|
||||||
|
await p.waitFor(`location.pathname === '/change-password'`, 'forced password change');
|
||||||
|
await p.fill('Current password', temp);
|
||||||
|
await p.fill('New password', who.password);
|
||||||
|
await p.fill('Repeat new password', who.password);
|
||||||
|
await p.click('Change password');
|
||||||
|
}
|
||||||
|
await p.waitFor(`location.pathname === '/me'`, 'my phone');
|
||||||
|
}
|
||||||
|
const dotLabel = (p, n) =>
|
||||||
|
p.evaluate(`document.querySelector('aside li[data-number="${n}"] .sr-only')?.textContent ?? ''`);
|
||||||
|
|
||||||
|
const watcher = await launch({ shotsDir });
|
||||||
|
const clicker = await launch({ shotsDir });
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 180; i++) {
|
||||||
|
const c = cli('pjsip show contacts');
|
||||||
|
if (['172', '173', '174'].every((n) => new RegExp(`${n}/sip:.*Avail`).test(c))) break;
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
const reg = cli('pjsip show contacts');
|
||||||
|
for (const n of ['172', '173', '174'])
|
||||||
|
assert.match(reg, new RegExp(`${n}/sip:.*Avail`), `${n} registered`);
|
||||||
|
await signIn(watcher, WEB);
|
||||||
|
await watcher.waitFor(`!!document.querySelector('aside li[data-number="172"]')`, 'People list');
|
||||||
|
await watcher.waitFor(
|
||||||
|
`document.querySelector('aside li[data-number="172"] .sr-only')?.textContent === 'Available'`,
|
||||||
|
'172 shown available',
|
||||||
|
10_000,
|
||||||
|
);
|
||||||
|
await watcher.shot('m5-t3-01-people');
|
||||||
|
|
||||||
|
// Presence: 173 calls 172. Poll Asterisk's hint and the dot side by side.
|
||||||
|
const hintChange = (async () => {
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
if (!/State:Idle/.test(cli('core show hint 172'))) return Date.now();
|
||||||
|
await sleep(20);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
const dotChange = (async () => {
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
if ((await dotLabel(watcher, '172')) !== 'Available') return Date.now();
|
||||||
|
await sleep(10);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
cli('channel originate PJSIP/173 extension 172@sp-from-ext');
|
||||||
|
const [tAst, tUi] = await Promise.all([hintChange, dotChange]);
|
||||||
|
assert.ok(tAst && tUi, `state change seen (asterisk ${tAst}, ui ${tUi})`);
|
||||||
|
console.log(
|
||||||
|
`presence: dot changed ${tUi - tAst} ms after Asterisk's hint (now "${await dotLabel(watcher, '172')}")`,
|
||||||
|
);
|
||||||
|
assert.ok(tUi - tAst < 1000, 'presence dot within 1 s');
|
||||||
|
await watcher.waitFor(
|
||||||
|
`document.querySelector('aside li[data-number="172"] .sr-only')?.textContent === 'On a call'`,
|
||||||
|
'172 on a call',
|
||||||
|
);
|
||||||
|
await watcher.shot('m5-t3-02-on-a-call');
|
||||||
|
await hangupAll();
|
||||||
|
await watcher.waitFor(
|
||||||
|
`document.querySelector('aside li[data-number="172"] .sr-only')?.textContent === 'Available'`,
|
||||||
|
'172 available again',
|
||||||
|
);
|
||||||
|
console.log('presence: back to Available after hang-up');
|
||||||
|
|
||||||
|
// Click-to-call: a user without the browser softphone; the desk phone (174) rings first.
|
||||||
|
await signIn(clicker, CC, cc.temporaryPassword);
|
||||||
|
await clicker.waitFor(`!!document.querySelector('aside li[data-number="172"]')`, 'People list');
|
||||||
|
await clicker.click('Call Pat Desk');
|
||||||
|
// Asterisk numbers channels in creation order (the part of the uniqueid after the dot).
|
||||||
|
const created = {};
|
||||||
|
for (let i = 0; i < 100 && Object.keys(created).length < 2; i++) {
|
||||||
|
for (const c of channels()) {
|
||||||
|
const n = /^PJSIP\/(\d+)-/.exec(c.name)?.[1];
|
||||||
|
if (n && !(n in created)) created[n] = c.seq;
|
||||||
|
}
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
assert.ok(created['174'] < created['172'], `desk phone rang first: ${JSON.stringify(created)}`);
|
||||||
|
await clicker.waitFor(
|
||||||
|
`document.body.innerText.includes('Your desk phone is ringing')`,
|
||||||
|
'tells the user to pick up',
|
||||||
|
);
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const c = channels();
|
||||||
|
const a = c.find((x) => x.name.startsWith('PJSIP/174-'));
|
||||||
|
const b = c.find((x) => x.name.startsWith('PJSIP/172-'));
|
||||||
|
if (a?.bridge && a.bridge === b?.bridge) break;
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
const c = channels();
|
||||||
|
assert.equal(
|
||||||
|
c.find((x) => x.name.startsWith('PJSIP/174-'))?.bridge,
|
||||||
|
c.find((x) => x.name.startsWith('PJSIP/172-'))?.bridge,
|
||||||
|
'174 and 172 talk',
|
||||||
|
);
|
||||||
|
console.log('click-to-call: 174 (desk) rang first, then 172; connected');
|
||||||
|
await clicker.shot('m5-t3-03-click-to-call');
|
||||||
|
await hangupAll();
|
||||||
|
|
||||||
|
console.log('M5-T3 (presence, click-to-call): PASS');
|
||||||
|
} catch (e) {
|
||||||
|
await watcher.shot('m5-t3-fail-watcher').catch(() => {});
|
||||||
|
await clicker.shot('m5-t3-fail-clicker').catch(() => {});
|
||||||
|
console.log(`FAIL: ${e.message}`);
|
||||||
|
console.log(cli('pjsip show contacts'));
|
||||||
|
console.log(cli('core show channels concise'));
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await watcher.close();
|
||||||
|
await clicker.close();
|
||||||
|
phones.stop();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user