From 95b5551643f69af5d4147c500dd5f4e005f5e290 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 30 Oct 2026 06:27:44 -0700 Subject: [PATCH] Record outbound alert calls when line recording is enabled --- src/app/api/__tests__/call-recording.test.ts | 8 ++++++ src/app/api/lines/[id]/route.ts | 2 +- src/app/api/messages/[id]/recording/route.ts | 2 +- src/app/settings/lines/page.tsx | 2 +- src/lib/alerts/__tests__/alerts.test.ts | 30 ++++++++++++++++++++ src/lib/alerts/call.ts | 14 +++++++-- src/lib/voice/ivr.ts | 4 +-- 7 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/app/api/__tests__/call-recording.test.ts b/src/app/api/__tests__/call-recording.test.ts index ba2b2bf1..c0838ba5 100644 --- a/src/app/api/__tests__/call-recording.test.ts +++ b/src/app/api/__tests__/call-recording.test.ts @@ -41,6 +41,14 @@ describe("GET /api/messages/:id/recording", () => { expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe("Bearer KEY_CUSTOMER"); }); + it("streams outbound alert recordings using their unprefixed session id", async () => { + const { GET } = await import("@/app/api/messages/[id]/recording/route"); + db.rows("messages")[0].provider_message_id = "alert:alert-session"; + const response = await GET(new Request("http://localhost/x"), { params: Promise.resolve({ id: MSG }) }); + expect(response.status).toBe(200); + expect(fetchMock.mock.calls[0][0]).toContain("filter[call_session_id]=alert-session"); + }); + it("is a 404 for someone else's call or a call without a recording", async () => { const { GET } = await import("@/app/api/messages/[id]/recording/route"); db.rows("messages")[0].conversations = { user_id: "u2", phone_number_id: "pn-1" }; diff --git a/src/app/api/lines/[id]/route.ts b/src/app/api/lines/[id]/route.ts index 25945350..58e53f56 100644 --- a/src/app/api/lines/[id]/route.ts +++ b/src/app/api/lines/[id]/route.ts @@ -10,7 +10,7 @@ type Params = { params: Promise<{ id: string }> }; * - contacts_from: "" answers this number from another line's * contacts book (same voice menu, same text prefixes); null gives it its own * again. Sharing a book with a menu also points this number's calls at it. - * - record_calls: true records calls the voice menu answers, as MP3 (callers + * - record_calls: true records incoming menu calls and outbound alert calls, as MP3 (callers * hear "This call may be recorded" first). */ export async function PATCH(request: Request, { params }: Params) { diff --git a/src/app/api/messages/[id]/recording/route.ts b/src/app/api/messages/[id]/recording/route.ts index aea6e409..a00d3501 100644 --- a/src/app/api/messages/[id]/recording/route.ts +++ b/src/app/api/messages/[id]/recording/route.ts @@ -28,7 +28,7 @@ export async function GET(request: Request, { params }: Params) { if (!msg || !convo || convo.user_id !== who.user.userId || msg.kind !== "call" || msg.recording_seconds === null) { return NextResponse.json({ error: "Recording not found" }, { status: 404 }); } - const session = String(msg.provider_message_id ?? "").replace(/^call:/, ""); + const session = String(msg.provider_message_id ?? "").replace(/^(?:call|alert):/, ""); const { data: line } = await db .from("phone_numbers") diff --git a/src/app/settings/lines/page.tsx b/src/app/settings/lines/page.tsx index f0d4e68a..c9a8b29b 100644 --- a/src/app/settings/lines/page.tsx +++ b/src/app/settings/lines/page.tsx @@ -238,7 +238,7 @@ function LineCard({ line, lines, reload }: { line: Line; lines: Line[]; reload: {line.provider_type === "telnyx" && ( )} diff --git a/src/lib/alerts/__tests__/alerts.test.ts b/src/lib/alerts/__tests__/alerts.test.ts index 40387760..3af643c4 100644 --- a/src/lib/alerts/__tests__/alerts.test.ts +++ b/src/lib/alerts/__tests__/alerts.test.ts @@ -258,3 +258,33 @@ describe("alert call flow", () => { expect(fdb.tables.messages[0]).toMatchObject({ body: "Alert call not answered (timeout): x", status: "failed" }); }); }); + + +describe("alert call recordings", () => { + it("records enabled lines, announces recording and saves duration on the alert's message", async () => { + const { fdb } = setup(); + fdb.tables.alerts = [{ id: "alert-record", message: "SSH key changed" }]; + fdb.tables.messages = [{ id: "message-record", body: "Alert call: SSH key changed", provider_message_id: "alert:session" }]; + const calls: Array<{ action: string; body: Record }> = []; + const command = vi.fn(async (_id: string, action: string, body: Record) => { calls.push({ action, body }); return { ok: true as const, data: {} }; }); + const deps = { db: fdb as unknown as SupabaseClient, command }; + const line = { ...FAMILY, user_id: USER, record_calls: true }; + const state = { l: FAMILY.id, m: "message-record", al: "alert-record" }; + await handleCallEvent(deps, line, { event_type: "call.answered", payload: { call_control_id: "cc-record" } }, state); + expect(calls[0]).toMatchObject({ action: "record_start", body: { format: "mp3", channels: "dual", command_id: "alert-record:alert-record:cc-record" } }); + expect(calls[1].body.payload).toMatch(/^This call may be recorded\. This is an alert from SMS Hub/); + expect(decodeState(String(calls[0].body.client_state))).toMatchObject(state); + expect(await handleCallEvent(deps, line, { event_type: "call.recording.saved", payload: { call_control_id: "cc-record", recording_started_at: "2026-10-30T13:00:00Z", recording_ended_at: "2026-10-30T13:01:24Z" } }, state)).toBe("recording saved"); + expect(fdb.rows("messages")[0].recording_seconds).toBe(84); + expect(await handleCallEvent(deps, line, { event_type: "call.speak.ended", payload: { call_control_id: "cc-record" } }, { ...state, h: 1 })).toBe("hung up after goodbye"); + }); + + it("leaves recording disabled for other lines", async () => { + const { fdb } = setup(); + fdb.tables.alerts = [{ id: "alert-off", message: "Backup done" }]; + const command = vi.fn(async (_id: string, _action: string, _body: Record) => ({ ok: true as const, data: {} })); + await handleCallEvent({ db: fdb as unknown as SupabaseClient, command }, { ...FAMILY, user_id: USER, record_calls: false }, { event_type: "call.answered", payload: { call_control_id: "cc-off" } }, { l: FAMILY.id, al: "alert-off" }); + expect(command).toHaveBeenCalledTimes(1); + expect(command.mock.calls[0]).toEqual(expect.arrayContaining(["speak", expect.objectContaining({ payload: expect.not.stringContaining("recorded") })])); + }); +}); diff --git a/src/lib/alerts/call.ts b/src/lib/alerts/call.ts index 51815093..696a2718 100644 --- a/src/lib/alerts/call.ts +++ b/src/lib/alerts/call.ts @@ -10,7 +10,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { encodeState, type CallCommand } from "@/lib/voice/telnyx-voice"; -import { callLength, type CallEvent, type CallState } from "@/lib/voice/ivr"; +import { callLength, RECORDING_NOTICE, type CallEvent, type CallState } from "@/lib/voice/ivr"; const VOICE = { voice: "female", language: "en-US" }; @@ -44,7 +44,8 @@ export function alertSpeech(message: string): string { export async function handleAlertCall( deps: { db: SupabaseClient; command: CallCommand }, event: CallEvent, - state: CallState & { al: string } + state: CallState & { al: string }, + recordCalls = false ): Promise { const { db, command } = deps; const p = event.payload; @@ -57,8 +58,15 @@ export async function handleAlertCall( await command(callId, "hangup", {}); return "alert gone"; } + if (recordCalls) { + await command(callId, "record_start", { + format: "mp3", channels: "dual", + command_id: `alert-record:${state.al}:${callId}`, + client_state: encodeState({ ...state }), + }); + } await command(callId, "speak", { - payload: alertSpeech(String((alert as { message: string }).message)), + payload: `${recordCalls ? `${RECORDING_NOTICE} ` : ""}${alertSpeech(String((alert as { message: string }).message))}`, ...VOICE, client_state: encodeState({ ...state, h: 1 }), }); diff --git a/src/lib/voice/ivr.ts b/src/lib/voice/ivr.ts index 69d5d1e7..d7a5f076 100644 --- a/src/lib/voice/ivr.ts +++ b/src/lib/voice/ivr.ts @@ -103,8 +103,8 @@ export async function handleCallEvent( const callId = String(p.call_control_id ?? ""); if (!callId) return "ignored: no call_control_id"; if (state?.t) return "ignored: transferred leg"; - if (state?.al && event.event_type !== "call.speak.ended") { - return handleAlertCall(deps, event, state as CallState & { al: string }); + if (state?.al && !["call.speak.ended", "call.recording.saved"].includes(event.event_type)) { + return handleAlertCall(deps, event, state as CallState & { al: string }, Boolean(line.record_calls)); } switch (event.event_type) {