Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/app/api/__tests__/call-recording.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/lines/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ type Params = { params: Promise<{ id: string }> };
* - contacts_from: "<line id>" 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) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/messages/[id]/recording/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion src/app/settings/lines/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ function LineCard({ line, lines, reload }: { line: Line; lines: Line[]; reload:
{line.provider_type === "telnyx" && (
<label className="flex items-center gap-2 text-sm text-gray-400">
<input type="checkbox" checked={line.record_calls} disabled={busy} onChange={(e) => setRecording(e.target.checked)} />
Record calls as MP3 (callers first hear &ldquo;This call may be recorded&rdquo;; play them in the inbox)
Record incoming and alert calls as MP3 (callers hear &ldquo;This call may be recorded&rdquo;; play them in the inbox)
</label>
)}

Expand Down
30 changes: 30 additions & 0 deletions src/lib/alerts/__tests__/alerts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }> = [];
const command = vi.fn(async (_id: string, action: string, body: Record<string, unknown>) => { 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<string, unknown>) => ({ 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") })]));
});
});
14 changes: 11 additions & 3 deletions src/lib/alerts/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };

Expand Down Expand Up @@ -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<string> {
const { db, command } = deps;
const p = event.payload;
Expand All @@ -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 }),
});
Expand Down
4 changes: 2 additions & 2 deletions src/lib/voice/ivr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading