Harden call recorder Recall API boundary (#22832)

Part 2/5 of splitting #22739. Stacked on #22831.

- `RecallBotSnapshot`: Recall bot payloads are parsed once at the API
boundary (`parseRecallBotSnapshot`);
`getRecallBot`/`listScheduledRecallBots` return typed snapshots, flows
never touch raw provider records
- Retry policy: honors `Retry-After` (seconds or HTTP-date, capped at
60s), treats 409 and 507 (ad-hoc pool exhausted) as retryable with
tailored delays, adds equal jitter to the linear backoff, and returns
instead of sleeping past 10s in-process so invocations never sleep into
their timeout
- `listScheduledRecallBots` accepts a server-side `metadata__` filter
and reports `truncated` instead of failing beyond 10 pages
- Extracts `cancelOrEjectRecallBot` into the recall-api layer
- Replaces the `CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB` server variable
with a fixed 500 MB constant: uploads stream since #22652, so the cap no
longer guards function memory and does not need to be operator-tunable

Next: billing charge verification, divergence-scoped sync crons, webhook
artifact continuation.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22832?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
nitin
2026-07-15 15:20:18 +05:30
committed by GitHub
parent 58fcb3cb0f
commit 2201917f33
27 changed files with 922 additions and 459 deletions
@@ -16,7 +16,6 @@ import { CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME } from 'src/logic-funct
import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds';
import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds-env-var-name';
import { CALL_RECORDER_JOIN_EARLY_MINUTES_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-join-early-minutes-env-var-name';
import { CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-max-media-file-size-mb-env-var-name';
import { CALL_RECORDER_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-name-env-var-name';
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds';
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds-env-var-name';
@@ -28,7 +27,6 @@ import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS } from 'src/logic-functions/
import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds-env-var-name';
import { DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND } from 'src/logic-functions/constants/default-call-recorder-bot-image-background';
import { DEFAULT_CALL_RECORDER_JOIN_EARLY_MINUTES } from 'src/logic-functions/constants/default-call-recorder-join-early-minutes';
import { DEFAULT_CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB } from 'src/logic-functions/constants/default-call-recorder-max-media-file-size-mb';
import { DEFAULT_CALL_RECORDER_NAME } from 'src/logic-functions/constants/default-call-recorder-name';
import { DEFAULT_CALL_RECORDER_RECORDING_RETENTION_HOURS } from 'src/logic-functions/constants/default-call-recorder-recording-retention-hours';
import { DEFAULT_CALL_RECORDER_SUMMARY_ENABLED } from 'src/logic-functions/constants/default-call-recorder-summary-enabled';
@@ -150,11 +148,6 @@ export default defineApplication({
isSecret: false,
type: FieldType.NUMBER,
},
[CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME]: {
description: `Maximum size in megabytes for a single recording media file (video or audio) ingested from Recall.ai. Larger files are skipped and noted in the call recording failure reason; the recording still completes with its remaining artifacts. Defaults to ${DEFAULT_CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB} MB to keep media ingestion within the logic function memory limit.`,
isSecret: false,
type: FieldType.NUMBER,
},
[RECALL_WEBHOOK_SECRET_ENV_VAR_NAME]: {
description:
'Recall.ai webhook signing secret (whsec_...). Set by the server admin from the Recall webhook endpoint settings; used to verify the Svix signature of incoming Recall webhook deliveries.',
@@ -0,0 +1 @@
export const CALL_RECORDER_MAX_MEDIA_FILE_SIZE_BYTES = 500 * 1024 * 1024;
@@ -1,2 +0,0 @@
export const CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME =
'CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB';
@@ -1 +0,0 @@
export const DEFAULT_CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB = 80;
@@ -1,23 +0,0 @@
import { CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-max-media-file-size-mb-env-var-name';
import { DEFAULT_CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB } from 'src/logic-functions/constants/default-call-recorder-max-media-file-size-mb';
import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
const BYTES_PER_MEGABYTE = 1024 * 1024;
export const getMaxMediaFileSizeBytes = (): number => {
const configuredMaxMediaFileSizeMb = getApplicationVariableValue(
CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME,
);
const maxMediaFileSizeMb = isNonEmptyString(configuredMaxMediaFileSizeMb)
? Number(configuredMaxMediaFileSizeMb.trim())
: NaN;
const resolvedMaxMediaFileSizeMb =
Number.isFinite(maxMediaFileSizeMb) && maxMediaFileSizeMb > 0
? maxMediaFileSizeMb
: DEFAULT_CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB;
return resolvedMaxMediaFileSizeMb * BYTES_PER_MEGABYTE;
};
@@ -0,0 +1 @@
export const RECALL_API_ADHOC_POOL_RETRY_DELAY_MS = 10_000;
@@ -0,0 +1 @@
export const RECALL_API_MAX_IN_PROCESS_RETRY_WAIT_MS = 10_000;
@@ -0,0 +1 @@
export const RECALL_API_MAX_RETRY_AFTER_MS = 60_000;
@@ -1,36 +1,21 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanupOrphanedRecallBots } from 'src/logic-functions/flows/cleanup-orphaned-recall-bots.util';
const listScheduledRecallBotsMock = vi.hoisted(() => vi.fn());
const cancelRecallBotMock = vi.hoisted(() => vi.fn());
const ejectRecallBotMock = vi.hoisted(() => vi.fn());
const getCurrentWorkspaceIdMock = vi.hoisted(() => vi.fn());
vi.mock('src/logic-functions/data/get-current-workspace-id.util', () => ({
getCurrentWorkspaceId: getCurrentWorkspaceIdMock,
}));
vi.mock(
'src/logic-functions/recall-api/list-scheduled-recall-bots.util',
() => ({
listScheduledRecallBots: listScheduledRecallBotsMock,
}),
);
vi.mock('src/logic-functions/recall-api/cancel-recall-bot.util', () => ({
cancelRecallBot: cancelRecallBotMock,
}));
vi.mock('src/logic-functions/recall-api/eject-recall-bot.util', () => ({
ejectRecallBot: ejectRecallBotMock,
}));
const JOIN_AT_AFTER = '2026-01-01T08:00:00.000Z';
const JOIN_AT_BEFORE = '2026-01-02T12:00:00.000Z';
const CURRENT_WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
const OTHER_WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174999';
const BASE_URL = 'https://us-east-1.recall.ai/api/v1';
const ENV_VAR_NAMES = [
'RECALL_API_KEY',
'RECALL_REGION',
'TWENTY_APP_ACCESS_TOKEN',
] as const;
const ORIGINAL_ENV_VALUES = ENV_VAR_NAMES.map(
(envVarName) => [envVarName, process.env[envVarName]] as const,
);
type CallRecordingNode = {
id: string;
@@ -60,6 +45,13 @@ class FakeCoreApiClient {
const buildClient = (callRecordings: CallRecordingNode[]): CoreApiClient =>
new FakeCoreApiClient(callRecordings) as unknown as CoreApiClient;
const buildAccessToken = (payload: Record<string, unknown>): string =>
[
Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'),
Buffer.from(JSON.stringify(payload)).toString('base64url'),
'signature',
].join('.');
const buildBot = ({
id,
twentyCallRecordingId,
@@ -90,20 +82,67 @@ const buildCurrentWorkspaceBot = ({
});
describe('cleanupOrphanedRecallBots', () => {
const fetchMock = vi.fn();
const buildJsonResponse = (status: number) => ({
ok: status < 400,
status,
json: async () => ({}),
});
const stubRecallApi = ({
bots,
cancelStatus = 204,
}: {
bots: unknown[];
cancelStatus?: number;
}) => {
fetchMock.mockImplementation(async (_url: string, init: RequestInit) => {
if (init.method === 'DELETE') {
return buildJsonResponse(cancelStatus);
}
if (init.method === 'POST') {
return buildJsonResponse(200);
}
return {
ok: true,
status: 200,
json: async () => ({ next: null, results: bots }),
};
});
};
const getDeleteCalls = () =>
fetchMock.mock.calls.filter(([, init]) => init.method === 'DELETE');
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
getCurrentWorkspaceIdMock.mockReset();
getCurrentWorkspaceIdMock.mockReturnValue(CURRENT_WORKSPACE_ID);
listScheduledRecallBotsMock.mockReset();
cancelRecallBotMock.mockReset();
cancelRecallBotMock.mockResolvedValue({ ok: true });
ejectRecallBotMock.mockReset();
ejectRecallBotMock.mockResolvedValue({ ok: true });
process.env.RECALL_API_KEY = 'recall-api-key';
process.env.RECALL_REGION = 'us-east-1';
process.env.TWENTY_APP_ACCESS_TOKEN = buildAccessToken({
workspaceId: CURRENT_WORKSPACE_ID,
});
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
ORIGINAL_ENV_VALUES.forEach(([envVarName, originalValue]) => {
if (originalValue === undefined) {
delete process.env[envVarName];
} else {
process.env[envVarName] = originalValue;
}
});
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.useRealTimers();
});
it('keeps bots that their call recording still references', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
stubRecallApi({
bots: [
buildCurrentWorkspaceBot({
id: 'claimed-bot',
@@ -126,14 +165,14 @@ describe('cleanupOrphanedRecallBots', () => {
expect(result).toEqual({
scannedBotCount: 1,
truncatedScan: false,
canceledExternalBotIds: [],
});
expect(cancelRecallBotMock).not.toHaveBeenCalled();
expect(getDeleteCalls()).toHaveLength(0);
});
it('cancels bots whose call recording request was canceled locally', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
stubRecallApi({
bots: [
buildCurrentWorkspaceBot({
id: 'stale-cancel-bot',
@@ -156,16 +195,17 @@ describe('cleanupOrphanedRecallBots', () => {
expect(result).toEqual({
scannedBotCount: 1,
truncatedScan: false,
canceledExternalBotIds: ['stale-cancel-bot'],
});
expect(cancelRecallBotMock).toHaveBeenCalledWith({
externalBotId: 'stale-cancel-bot',
});
expect(fetchMock).toHaveBeenCalledWith(
`${BASE_URL}/bot/stale-cancel-bot/`,
expect.objectContaining({ method: 'DELETE' }),
);
});
it('cancels bots whose call recording references another bot', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
stubRecallApi({
bots: [
buildCurrentWorkspaceBot({
id: 'superseded-bot',
@@ -192,17 +232,18 @@ describe('cleanupOrphanedRecallBots', () => {
expect(result).toEqual({
scannedBotCount: 2,
truncatedScan: false,
canceledExternalBotIds: ['superseded-bot'],
});
expect(cancelRecallBotMock).toHaveBeenCalledTimes(1);
expect(cancelRecallBotMock).toHaveBeenCalledWith({
externalBotId: 'superseded-bot',
});
expect(getDeleteCalls()).toHaveLength(1);
expect(fetchMock).toHaveBeenCalledWith(
`${BASE_URL}/bot/superseded-bot/`,
expect.objectContaining({ method: 'DELETE' }),
);
});
it('cancels bots whose call recording no longer exists', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
stubRecallApi({
bots: [
buildCurrentWorkspaceBot({
id: 'orphan-bot',
@@ -219,13 +260,13 @@ describe('cleanupOrphanedRecallBots', () => {
expect(result).toEqual({
scannedBotCount: 1,
truncatedScan: false,
canceledExternalBotIds: ['orphan-bot'],
});
});
it('grants a grace round to requested recordings without a bot id yet', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
stubRecallApi({
bots: [
buildCurrentWorkspaceBot({
id: 'pending-bot',
@@ -248,16 +289,14 @@ describe('cleanupOrphanedRecallBots', () => {
expect(result).toEqual({
scannedBotCount: 1,
truncatedScan: false,
canceledExternalBotIds: [],
});
expect(cancelRecallBotMock).not.toHaveBeenCalled();
expect(getDeleteCalls()).toHaveLength(0);
});
it('ignores bots that were not created by this app', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [buildBot({ id: 'unrelated-bot' })],
});
stubRecallApi({ bots: [buildBot({ id: 'unrelated-bot' })] });
const result = await cleanupOrphanedRecallBots({
client: buildClient([]),
@@ -267,14 +306,14 @@ describe('cleanupOrphanedRecallBots', () => {
expect(result).toEqual({
scannedBotCount: 1,
truncatedScan: false,
canceledExternalBotIds: [],
});
expect(cancelRecallBotMock).not.toHaveBeenCalled();
expect(getDeleteCalls()).toHaveLength(0);
});
it('ignores untagged bots even when they carry call recording metadata', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
stubRecallApi({
bots: [
buildBot({
id: 'untagged-bot',
@@ -291,14 +330,14 @@ describe('cleanupOrphanedRecallBots', () => {
expect(result).toEqual({
scannedBotCount: 1,
truncatedScan: false,
canceledExternalBotIds: [],
});
expect(cancelRecallBotMock).not.toHaveBeenCalled();
expect(getDeleteCalls()).toHaveLength(0);
});
it('ignores bots claimed by another workspace', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
stubRecallApi({
bots: [
buildBot({
id: 'other-workspace-bot',
@@ -316,14 +355,14 @@ describe('cleanupOrphanedRecallBots', () => {
expect(result).toEqual({
scannedBotCount: 1,
truncatedScan: false,
canceledExternalBotIds: [],
});
expect(cancelRecallBotMock).not.toHaveBeenCalled();
expect(getDeleteCalls()).toHaveLength(0);
});
it('cancels orphaned bots claimed by this workspace', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
stubRecallApi({
bots: [
buildCurrentWorkspaceBot({
id: 'same-workspace-bot',
@@ -340,27 +379,73 @@ describe('cleanupOrphanedRecallBots', () => {
expect(result).toEqual({
scannedBotCount: 1,
truncatedScan: false,
canceledExternalBotIds: ['same-workspace-bot'],
});
expect(cancelRecallBotMock).toHaveBeenCalledWith({
externalBotId: 'same-workspace-bot',
});
expect(fetchMock).toHaveBeenCalledWith(
`${BASE_URL}/bot/same-workspace-bot/`,
expect.objectContaining({ method: 'DELETE' }),
);
});
it('ejects an orphaned bot that already joined when deletion is rejected', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
// 409s are retryable, so fake timers skip the cancel backoff sleeps.
vi.useFakeTimers();
stubRecallApi({
bots: [
buildCurrentWorkspaceBot({
id: 'in-call-orphan',
twentyCallRecordingId: 'call-recording-gone',
}),
],
cancelStatus: 409,
});
cancelRecallBotMock.mockResolvedValue({
ok: false,
status: 409,
errorMessage: 'Recall API responded with HTTP 409',
const resultPromise = cleanupOrphanedRecallBots({
client: buildClient([]),
joinAtAfter: JOIN_AT_AFTER,
joinAtBefore: JOIN_AT_BEFORE,
});
await vi.runAllTimersAsync();
expect(await resultPromise).toEqual({
scannedBotCount: 1,
truncatedScan: false,
canceledExternalBotIds: ['in-call-orphan'],
});
expect(fetchMock).toHaveBeenCalledWith(
`${BASE_URL}/bot/in-call-orphan/leave_call/`,
expect.objectContaining({ method: 'POST' }),
);
});
it('cancels fetched orphans and flags the sweep as partial when pages remain', async () => {
let listCallCount = 0;
fetchMock.mockImplementation(async (_url: string, init: RequestInit) => {
if (init.method === 'DELETE') {
return buildJsonResponse(204);
}
listCallCount += 1;
return {
ok: true,
status: 200,
json: async () => ({
next: `${BASE_URL}/bot/?cursor=page-${listCallCount + 1}`,
results:
listCallCount === 1
? [
buildCurrentWorkspaceBot({
id: 'orphan-bot',
twentyCallRecordingId: 'call-recording-gone',
}),
]
: [],
}),
};
});
const result = await cleanupOrphanedRecallBots({
@@ -371,18 +456,23 @@ describe('cleanupOrphanedRecallBots', () => {
expect(result).toEqual({
scannedBotCount: 1,
canceledExternalBotIds: ['in-call-orphan'],
});
expect(ejectRecallBotMock).toHaveBeenCalledWith({
externalBotId: 'in-call-orphan',
truncatedScan: true,
canceledExternalBotIds: ['orphan-bot'],
});
expect(
fetchMock.mock.calls.filter(([, init]) => init.method === 'GET'),
).toHaveLength(10);
expect(fetchMock).toHaveBeenCalledWith(
`${BASE_URL}/bot/orphan-bot/`,
expect.objectContaining({ method: 'DELETE' }),
);
});
it('reports nothing canceled when listing Recall bots fails', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
fetchMock.mockResolvedValue({
ok: false,
status: 500,
errorMessage: 'Recall API responded with HTTP 500',
status: 400,
json: async () => ({ detail: 'bad request' }),
});
const result = await cleanupOrphanedRecallBots({
@@ -393,15 +483,15 @@ describe('cleanupOrphanedRecallBots', () => {
expect(result).toEqual({
scannedBotCount: 0,
truncatedScan: false,
canceledExternalBotIds: [],
});
expect(cancelRecallBotMock).not.toHaveBeenCalled();
expect(getDeleteCalls()).toHaveLength(0);
});
it('skips cancellation when the current workspace cannot be resolved', async () => {
getCurrentWorkspaceIdMock.mockReturnValue(undefined);
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
delete process.env.TWENTY_APP_ACCESS_TOKEN;
stubRecallApi({
bots: [
buildCurrentWorkspaceBot({
id: 'same-workspace-bot',
@@ -418,8 +508,9 @@ describe('cleanupOrphanedRecallBots', () => {
expect(result).toEqual({
scannedBotCount: 1,
truncatedScan: false,
canceledExternalBotIds: [],
});
expect(cancelRecallBotMock).not.toHaveBeenCalled();
expect(getDeleteCalls()).toHaveLength(0);
});
});
@@ -125,16 +125,16 @@ describe('convergeDivergedCallRecordings', () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'in_call_recording', created_at: '2026-06-09T13:02:30.000Z' },
{ code: 'call_ended', created_at: '2026-06-09T14:00:30.000Z' },
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
statusChanges: [
{ code: 'in_call_recording', createdAt: '2026-06-09T13:02:30.000Z' },
{ code: 'call_ended', createdAt: '2026-06-09T14:00:30.000Z' },
{ code: 'done', createdAt: '2026-06-09T14:05:00.000Z' },
],
recordings: [
{
id: 'recall-recording-1',
started_at: '2026-06-09T13:02:00.000Z',
completed_at: '2026-06-09T14:00:00.000Z',
startedAt: '2026-06-09T13:02:00.000Z',
completedAt: '2026-06-09T14:00:00.000Z',
},
],
},
@@ -187,8 +187,8 @@ describe('convergeDivergedCallRecordings', () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
statusChanges: [
{ code: 'done', createdAt: '2026-06-09T14:05:00.000Z' },
],
recordings: [],
},
@@ -218,14 +218,14 @@ describe('convergeDivergedCallRecordings', () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
statusChanges: [
{ code: 'done', createdAt: '2026-06-09T14:05:00.000Z' },
],
recordings: [
{
id: 'recall-recording-1',
started_at: '2026-06-09T13:02:00.000Z',
completed_at: '2026-06-09T14:00:00.000Z',
startedAt: '2026-06-09T13:02:00.000Z',
completedAt: '2026-06-09T14:00:00.000Z',
},
],
},
@@ -283,14 +283,14 @@ describe('convergeDivergedCallRecordings', () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
statusChanges: [
{ code: 'done', createdAt: '2026-06-09T14:05:00.000Z' },
],
recordings: [
{
id: 'recall-recording-1',
started_at: '2026-06-09T13:02:00.000Z',
completed_at: '2026-06-09T14:00:00.000Z',
startedAt: '2026-06-09T13:02:00.000Z',
completedAt: '2026-06-09T14:00:00.000Z',
},
],
},
@@ -339,14 +339,14 @@ describe('convergeDivergedCallRecordings', () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
statusChanges: [
{ code: 'done', createdAt: '2026-06-09T14:05:00.000Z' },
],
recordings: [
{
id: 'recall-recording-1',
started_at: '2026-06-09T13:02:00.000Z',
completed_at: '2026-06-09T14:00:00.000Z',
startedAt: '2026-06-09T13:02:00.000Z',
completedAt: '2026-06-09T14:00:00.000Z',
},
],
},
@@ -392,14 +392,14 @@ describe('convergeDivergedCallRecordings', () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'fatal', created_at: '2026-06-09T14:05:00.000Z' },
statusChanges: [
{ code: 'fatal', createdAt: '2026-06-09T14:05:00.000Z' },
],
recordings: [
{
id: 'recall-recording-1',
started_at: '2026-06-09T13:02:00.000Z',
completed_at: '2026-06-09T14:00:00.000Z',
startedAt: '2026-06-09T13:02:00.000Z',
completedAt: '2026-06-09T14:00:00.000Z',
},
],
},
@@ -462,14 +462,14 @@ describe('convergeDivergedCallRecordings', () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'done', created_at: '2026-06-10T11:30:00.000Z' },
statusChanges: [
{ code: 'done', createdAt: '2026-06-10T11:30:00.000Z' },
],
recordings: [
{
id: 'recall-recording-1',
started_at: '2026-06-10T11:05:00.000Z',
completed_at: '2026-06-10T11:25:00.000Z',
startedAt: '2026-06-10T11:05:00.000Z',
completedAt: '2026-06-10T11:25:00.000Z',
},
],
},
@@ -566,9 +566,10 @@ describe('convergeDivergedCallRecordings', () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' },
statusChanges: [
{ code: 'in_call_recording', createdAt: '2026-06-09T13:02:00.000Z' },
],
recordings: [],
},
});
const client = buildClient([
@@ -593,11 +594,11 @@ describe('convergeDivergedCallRecordings', () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' },
statusChanges: [
{ code: 'in_call_recording', createdAt: '2026-06-09T13:02:00.000Z' },
],
recordings: [
{ id: 'recall-recording-1', started_at: '2026-06-09T13:02:00.000Z' },
{ id: 'recall-recording-1', startedAt: '2026-06-09T13:02:00.000Z' },
],
},
});
@@ -625,14 +626,14 @@ describe('convergeDivergedCallRecordings', () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
statusChanges: [
{ code: 'done', createdAt: '2026-06-09T14:05:00.000Z' },
],
recordings: [
{
id: 'recall-recording-1',
started_at: '2026-06-09T13:02:00.000Z',
completed_at: '2026-06-09T14:00:00.000Z',
startedAt: '2026-06-09T13:02:00.000Z',
completedAt: '2026-06-09T14:00:00.000Z',
},
],
},
@@ -676,14 +677,14 @@ describe('convergeDivergedCallRecordings', () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
statusChanges: [
{ code: 'done', createdAt: '2026-06-09T14:05:00.000Z' },
],
recordings: [
{
id: 'recall-recording-1',
started_at: '2026-06-09T13:02:00.000Z',
completed_at: '2026-06-09T14:00:00.000Z',
startedAt: '2026-06-09T13:02:00.000Z',
completedAt: '2026-06-09T14:00:00.000Z',
},
],
},
@@ -732,14 +733,14 @@ describe('convergeDivergedCallRecordings', () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
statusChanges: [
{ code: 'done', createdAt: '2026-06-09T14:05:00.000Z' },
],
recordings: [
{
id: 'recall-recording-1',
started_at: '2026-06-09T13:02:00.000Z',
completed_at: '2026-06-09T14:00:00.000Z',
startedAt: '2026-06-09T13:02:00.000Z',
completedAt: '2026-06-09T14:00:00.000Z',
},
],
},
@@ -805,14 +806,14 @@ describe('convergeDivergedCallRecordings', () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
statusChanges: [
{ code: 'done', createdAt: '2026-06-09T14:05:00.000Z' },
],
recordings: [
{
id: 'recall-recording-1',
started_at: '2026-06-09T13:02:00.000Z',
completed_at: '2026-06-09T14:00:00.000Z',
startedAt: '2026-06-09T13:02:00.000Z',
completedAt: '2026-06-09T14:00:00.000Z',
},
],
},
@@ -864,9 +865,10 @@ describe('convergeDivergedCallRecordings', () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' },
statusChanges: [
{ code: 'in_call_recording', createdAt: '2026-06-09T13:02:00.000Z' },
],
recordings: [],
},
});
const client = buildClient([
@@ -829,6 +829,9 @@ describe('handleRecallWebhook', () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
id: 'recall-bot-1',
metadata: {},
statusChanges: [],
recordings: [{ id: 'recall-recording-9' }],
},
});
@@ -885,7 +888,7 @@ describe('handleRecallWebhook', () => {
it('imports media on recording.done and completes once all artifacts are present', async () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: { id: 'recall-bot-1' },
bot: { id: 'recall-bot-1', metadata: {}, statusChanges: [], recordings: [] },
});
importCallRecordingMediaMock.mockResolvedValue({
audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }],
@@ -939,7 +942,7 @@ describe('handleRecallWebhook', () => {
it('completes and keeps the size marker when a media file is too large', async () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: { id: 'recall-bot-1' },
bot: { id: 'recall-bot-1', metadata: {}, statusChanges: [], recordings: [] },
});
importCallRecordingMediaMock.mockResolvedValue({
audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }],
@@ -993,7 +996,7 @@ describe('handleRecallWebhook', () => {
it('keeps the real failure reason over the size marker on recording.failed', async () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: { id: 'recall-bot-1' },
bot: { id: 'recall-bot-1', metadata: {}, statusChanges: [], recordings: [] },
});
importCallRecordingMediaMock.mockResolvedValue({
audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }],
@@ -1043,7 +1046,7 @@ describe('handleRecallWebhook', () => {
it('stays PROCESSING on recording.done while artifacts are missing', async () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: { id: 'recall-bot-1' },
bot: { id: 'recall-bot-1', metadata: {}, statusChanges: [], recordings: [] },
});
importCallRecordingMediaMock.mockResolvedValue({
audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }],
@@ -1088,7 +1091,7 @@ describe('handleRecallWebhook', () => {
it('marks FAILED on recording.done when no recording artifact path exists', async () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: { id: 'recall-bot-1', recordings: [] },
bot: { id: 'recall-bot-1', metadata: {}, statusChanges: [], recordings: [] },
});
const client = new FakeCoreApiClient([
{
@@ -1,7 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-video-field-universal-identifier';
import { CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-max-media-file-size-mb-env-var-name';
import { importCallRecordingMedia } from 'src/logic-functions/flows/import-call-recording-media.util';
const mutationMock = vi.hoisted(() => vi.fn());
@@ -84,21 +83,19 @@ const stubFetch = ({
downloadsByUrl: Record<string, unknown>;
}) => {
fetchMock.mockReset();
fetchMock.mockImplementation(
(url: string, init?: { method?: string }) => {
if (init?.method === 'PUT') {
throw new Error('Upload requests should go through the upload bridge');
}
fetchMock.mockImplementation((url: string, init?: { method?: string }) => {
if (init?.method === 'PUT') {
throw new Error('Upload requests should go through the upload bridge');
}
const downloadResponse = downloadsByUrl[url];
const downloadResponse = downloadsByUrl[url];
if (downloadResponse === undefined) {
throw new Error(`Unhandled fetch url in test: ${url}`);
}
if (downloadResponse === undefined) {
throw new Error(`Unhandled fetch url in test: ${url}`);
}
return Promise.resolve(downloadResponse);
},
);
return Promise.resolve(downloadResponse);
});
vi.stubGlobal('fetch', fetchMock);
};
@@ -175,7 +172,6 @@ describe('importCallRecordingMedia', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it('streams and uploads every missing artifact', async () => {
@@ -381,7 +377,7 @@ describe('importCallRecordingMedia', () => {
stubFetch({
downloadsByUrl: {
[VIDEO_URL]: buildDownloadResponse({
contentLengthBytes: 200 * 1024 * 1024,
contentLengthBytes: 500 * 1024 * 1024 + 1,
body: { cancel: cancelMock },
}),
[AUDIO_URL]: buildDownloadResponse({ contentLengthBytes: 8 }),
@@ -413,10 +409,10 @@ describe('importCallRecordingMedia', () => {
stubFetch({
downloadsByUrl: {
[VIDEO_URL]: buildDownloadResponse({
contentLengthBytes: 200 * 1024 * 1024,
contentLengthBytes: 500 * 1024 * 1024 + 1,
}),
[AUDIO_URL]: buildDownloadResponse({
contentLengthBytes: 120 * 1024 * 1024,
contentLengthBytes: 500 * 1024 * 1024 + 1,
}),
},
});
@@ -434,39 +430,11 @@ describe('importCallRecordingMedia', () => {
expect(mutationMock).not.toHaveBeenCalled();
});
it('honors the cap configured through the environment', async () => {
vi.stubEnv(CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME, '1');
it('accepts a file at the 500 MB cap', async () => {
stubFetch({
downloadsByUrl: {
[VIDEO_URL]: buildDownloadResponse({
contentLengthBytes: 2 * 1024 * 1024,
}),
[AUDIO_URL]: buildDownloadResponse({ contentLengthBytes: 8 }),
},
});
const updateFields = await importCallRecordingMedia({
callRecordingId: 'call-recording-1',
externalRecordingId: 'recall-recording-1',
hasAudio: false,
hasVideo: false,
});
expect(updateFields).toEqual({
audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }],
callRecorderFailureReason: 'video_file_too_large',
});
});
it('falls back to the default cap when the configured value is invalid', async () => {
vi.stubEnv(
CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME,
'not-a-number',
);
stubFetch({
downloadsByUrl: {
[VIDEO_URL]: buildDownloadResponse({
contentLengthBytes: 2 * 1024 * 1024,
contentLengthBytes: 500 * 1024 * 1024,
}),
},
});
@@ -1,10 +1,9 @@
import { isNull, isUndefined } from '@sniptt/guards';
import { isUndefined } from '@sniptt/guards';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type';
import { cancelRecallBot } from 'src/logic-functions/recall-api/cancel-recall-bot.util';
import { ejectRecallBot } from 'src/logic-functions/recall-api/eject-recall-bot.util';
import { cancelOrEjectRecallBot } from 'src/logic-functions/recall-api/cancel-or-eject-recall-bot.util';
import { findCallRecordingsByIds } from 'src/logic-functions/data/find-call-recordings-by-ids.util';
import { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.util';
import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util';
@@ -17,6 +16,7 @@ import {
export type CleanupOrphanedRecallBotsResult = {
scannedBotCount: number;
canceledExternalBotIds: string[];
truncatedScan: boolean;
};
// Bots no open CallRecording request claims would still join; cancel them on Recall.
@@ -39,7 +39,11 @@ export const cleanupOrphanedRecallBots = async ({
`[call-recorder] failed to list Recall bots for orphan cancellation: ${listResult.errorMessage}`,
);
return { scannedBotCount: 0, canceledExternalBotIds: [] };
return {
scannedBotCount: 0,
canceledExternalBotIds: [],
truncatedScan: false,
};
}
const currentWorkspaceId = getCurrentWorkspaceId();
@@ -52,6 +56,7 @@ export const cleanupOrphanedRecallBots = async ({
return {
scannedBotCount: listResult.bots.length,
canceledExternalBotIds: [],
truncatedScan: listResult.truncated,
};
}
@@ -63,6 +68,7 @@ export const cleanupOrphanedRecallBots = async ({
return {
scannedBotCount: listResult.bots.length,
canceledExternalBotIds: [],
truncatedScan: listResult.truncated,
};
}
@@ -99,6 +105,7 @@ export const cleanupOrphanedRecallBots = async ({
return {
scannedBotCount: listResult.bots.length,
canceledExternalBotIds,
truncatedScan: listResult.truncated,
};
};
@@ -154,30 +161,5 @@ const isBotClaimed = ({
return isUndefined(callRecording.externalBotId);
};
const cancelOrEjectRecallBot = async (
externalBotId: string,
): Promise<boolean> => {
const cancelResult = await cancelRecallBot({ externalBotId });
if (cancelResult.ok) {
return true;
}
// Deleting only works for not-yet-joined bots; eject the ones already in a call.
if (!isNull(cancelResult.status)) {
const ejectResult = await ejectRecallBot({ externalBotId });
if (ejectResult.ok) {
return true;
}
}
console.warn(
`[call-recorder] failed to cancel orphaned Recall bot ${externalBotId}: ${cancelResult.errorMessage}`,
);
return false;
};
const normalizeOptionalString = (value: unknown): string | undefined =>
isNonEmptyString(value) ? value.trim() : undefined;
@@ -3,7 +3,7 @@ import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-audio-field-universal-identifier';
import { CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-video-field-universal-identifier';
import { getMaxMediaFileSizeBytes } from 'src/logic-functions/constants/get-max-media-file-size-bytes';
import { CALL_RECORDER_MAX_MEDIA_FILE_SIZE_BYTES } from 'src/logic-functions/constants/call-recorder-max-media-file-size-bytes';
import {
AUDIO_FILE_TOO_LARGE_FAILURE_REASON,
VIDEO_FILE_TOO_LARGE_FAILURE_REASON,
@@ -65,8 +65,6 @@ export const importCallRecordingMedia = async ({
const mediaUrls = extractRecallMediaUrls(recordingResult.recording);
const metadataClient = new MetadataApiClient();
// TODO: raise this cap via config, monitor streamed uploads in prod, then remove the cap once verified.
const maxMediaFileSizeBytes = getMaxMediaFileSizeBytes();
const updateFields: CallRecordingMediaUpdateFields = {};
const tooLargeFailureReasons: string[] = [];
@@ -78,7 +76,7 @@ export const importCallRecordingMedia = async ({
fileName: 'video.mp4',
fieldMetadataUniversalIdentifier:
CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER,
maxMediaFileSizeBytes,
maxMediaFileSizeBytes: CALL_RECORDER_MAX_MEDIA_FILE_SIZE_BYTES,
});
if (video.outcome === 'imported') {
@@ -98,7 +96,7 @@ export const importCallRecordingMedia = async ({
fileName: 'audio.mp3',
fieldMetadataUniversalIdentifier:
CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER,
maxMediaFileSizeBytes,
maxMediaFileSizeBytes: CALL_RECORDER_MAX_MEDIA_FILE_SIZE_BYTES,
});
if (audio.outcome === 'imported') {
@@ -226,7 +224,11 @@ const openMediaDownload = async ({
throw new Error('download returned no body');
}
return { outcome: 'opened', body: response.body, sizeBytes: contentLengthBytes };
return {
outcome: 'opened',
body: response.body,
sizeBytes: contentLengthBytes,
};
};
const uploadMediaStreamToStorage = async ({
@@ -320,7 +322,9 @@ const createFileUploadTarget = async ({
const uploadTarget = mutationResult.createFileUpload;
if (isUndefined(uploadTarget)) {
throw new Error('createFileUpload mutation did not return an upload target');
throw new Error(
'createFileUpload mutation did not return an upload target',
);
}
return uploadTarget;
@@ -1,49 +1,66 @@
import { describe, expect, it } from 'vitest';
import { extractRecallBotSyncState } from 'src/logic-functions/recall-api/extract-recall-bot-sync-state.util';
import { type RecallBotSnapshot } from 'src/logic-functions/recall-api/recall-bot-snapshot.type';
const buildRecallBotSnapshot = (
overrides: Partial<RecallBotSnapshot> = {},
): RecallBotSnapshot => ({
id: 'recall-bot-1',
metadata: {},
statusChanges: [],
recordings: [],
...overrides,
});
describe('extractRecallBotSyncState', () => {
it('maps the latest status change code to a call recording status', () => {
const syncState = extractRecallBotSyncState({
status_changes: [
{ code: 'joining_call', created_at: '2026-01-01T12:58:00.000Z' },
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
{ code: 'call_ended', created_at: '2026-01-01T14:00:00.000Z' },
{ code: 'done', created_at: '2026-01-01T14:05:00.000Z' },
],
});
const syncState = extractRecallBotSyncState(
buildRecallBotSnapshot({
statusChanges: [
{ code: 'joining_call', createdAt: '2026-01-01T12:58:00.000Z' },
{ code: 'in_call_recording', createdAt: '2026-01-01T13:02:00.000Z' },
{ code: 'call_ended', createdAt: '2026-01-01T14:00:00.000Z' },
{ code: 'done', createdAt: '2026-01-01T14:05:00.000Z' },
],
}),
);
// COMPLETED is reserved for full artifact import, never bot state.
expect(syncState.status).toBe('PROCESSING');
expect(syncState.isRecallRecordingDone).toBe(true);
});
it('uses created_at to find the latest status when Recall returns status changes out of order', () => {
const syncState = extractRecallBotSyncState({
status_changes: [
{ code: 'done', created_at: '2026-01-01T14:05:00.000Z' },
{ code: 'joining_call', created_at: '2026-01-01T12:58:00.000Z' },
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
],
});
it('uses createdAt to find the latest status when Recall returns status changes out of order', () => {
const syncState = extractRecallBotSyncState(
buildRecallBotSnapshot({
statusChanges: [
{ code: 'done', createdAt: '2026-01-01T14:05:00.000Z' },
{ code: 'joining_call', createdAt: '2026-01-01T12:58:00.000Z' },
{ code: 'in_call_recording', createdAt: '2026-01-01T13:02:00.000Z' },
],
}),
);
expect(syncState.status).toBe('PROCESSING');
});
it('prefers recording-object timestamps over status change entries', () => {
const syncState = extractRecallBotSyncState({
status_changes: [
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:30.000Z' },
{ code: 'call_ended', created_at: '2026-01-01T14:00:30.000Z' },
],
recordings: [
{
id: 'recall-recording-1',
started_at: '2026-01-01T13:02:00.000Z',
completed_at: '2026-01-01T14:00:00.000Z',
},
],
});
const syncState = extractRecallBotSyncState(
buildRecallBotSnapshot({
statusChanges: [
{ code: 'in_call_recording', createdAt: '2026-01-01T13:02:30.000Z' },
{ code: 'call_ended', createdAt: '2026-01-01T14:00:30.000Z' },
],
recordings: [
{
id: 'recall-recording-1',
startedAt: '2026-01-01T13:02:00.000Z',
completedAt: '2026-01-01T14:00:00.000Z',
},
],
}),
);
expect(syncState).toEqual({
status: 'PROCESSING',
@@ -56,13 +73,21 @@ describe('extractRecallBotSyncState', () => {
});
it('falls back to status change timestamps when recordings carry none', () => {
const syncState = extractRecallBotSyncState({
status_changes: [
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
{ code: 'call_ended', created_at: '2026-01-01T14:00:00.000Z' },
],
recordings: [{ id: 'recall-recording-1' }],
});
const syncState = extractRecallBotSyncState(
buildRecallBotSnapshot({
statusChanges: [
{ code: 'in_call_recording', createdAt: '2026-01-01T13:02:00.000Z' },
{ code: 'call_ended', createdAt: '2026-01-01T14:00:00.000Z' },
],
recordings: [
{
id: 'recall-recording-1',
startedAt: undefined,
completedAt: undefined,
},
],
}),
);
expect(syncState).toEqual({
status: 'PROCESSING',
@@ -75,25 +100,27 @@ describe('extractRecallBotSyncState', () => {
});
it('normalizes microsecond-precision Recall timestamps to millisecond ISO', () => {
const syncState = extractRecallBotSyncState({
status_changes: [
{ code: 'done', created_at: '2026-06-10T12:20:00.123456+00:00' },
],
recordings: [
{
id: 'recall-recording-1',
started_at: '2026-06-10T11:02:28.281597+00:00',
completed_at: '2026-06-10T12:17:28.281597+00:00',
},
],
});
const syncState = extractRecallBotSyncState(
buildRecallBotSnapshot({
statusChanges: [
{ code: 'done', createdAt: '2026-06-10T12:20:00.123456+00:00' },
],
recordings: [
{
id: 'recall-recording-1',
startedAt: '2026-06-10T11:02:28.281597+00:00',
completedAt: '2026-06-10T12:17:28.281597+00:00',
},
],
}),
);
expect(syncState.startedAt).toBe('2026-06-10T11:02:28.281Z');
expect(syncState.endedAt).toBe('2026-06-10T12:17:28.281Z');
});
it('returns nothing derivable from an empty bot response', () => {
expect(extractRecallBotSyncState({})).toEqual({
it('returns nothing derivable from an empty bot snapshot', () => {
expect(extractRecallBotSyncState(buildRecallBotSnapshot())).toEqual({
status: undefined,
failureReason: undefined,
startedAt: undefined,
@@ -103,49 +130,32 @@ describe('extractRecallBotSyncState', () => {
});
});
it('skips malformed status change entries', () => {
const syncState = extractRecallBotSyncState({
status_changes: [
null,
'not-an-object',
{ created_at: '2026-01-01T13:00:00.000Z' },
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
],
recordings: 'not-an-array',
});
expect(syncState).toEqual({
status: 'RECORDING',
failureReason: undefined,
startedAt: '2026-01-01T13:02:00.000Z',
endedAt: undefined,
externalRecordingId: undefined,
isRecallRecordingDone: false,
});
});
it('carries the failing Recall status code as the failure reason', () => {
const syncState = extractRecallBotSyncState({
status_changes: [
{ code: 'joining_call', created_at: '2026-01-01T12:58:00.000Z' },
{
code: 'recording_permission_denied',
created_at: '2026-01-01T13:02:00.000Z',
},
],
});
const syncState = extractRecallBotSyncState(
buildRecallBotSnapshot({
statusChanges: [
{ code: 'joining_call', createdAt: '2026-01-01T12:58:00.000Z' },
{
code: 'recording_permission_denied',
createdAt: '2026-01-01T13:02:00.000Z',
},
],
}),
);
expect(syncState.status).toBe('FAILED');
expect(syncState.failureReason).toBe('recording_permission_denied');
});
it('leaves the status undefined for unknown latest codes', () => {
const syncState = extractRecallBotSyncState({
status_changes: [
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
{ code: 'some_future_code', created_at: '2026-01-01T13:30:00.000Z' },
],
});
const syncState = extractRecallBotSyncState(
buildRecallBotSnapshot({
statusChanges: [
{ code: 'in_call_recording', createdAt: '2026-01-01T13:02:00.000Z' },
{ code: 'some_future_code', createdAt: '2026-01-01T13:30:00.000Z' },
],
}),
);
expect(syncState.status).toBeUndefined();
expect(syncState.startedAt).toBe('2026-01-01T13:02:00.000Z');
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { parseRecallBotSnapshot } from 'src/logic-functions/recall-api/parse-recall-bot-snapshot.util';
describe('parseRecallBotSnapshot', () => {
it('parses id, metadata, status changes and recordings from a bot payload', () => {
expect(
parseRecallBotSnapshot({
id: 'recall-bot-1',
metadata: { twentyWorkspaceId: 'workspace-1' },
status_changes: [
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
],
recordings: [
{
id: 'recall-recording-1',
started_at: '2026-01-01T13:02:00.000Z',
completed_at: '2026-01-01T14:00:00.000Z',
},
],
}),
).toEqual({
id: 'recall-bot-1',
metadata: { twentyWorkspaceId: 'workspace-1' },
statusChanges: [
{ code: 'in_call_recording', createdAt: '2026-01-01T13:02:00.000Z' },
],
recordings: [
{
id: 'recall-recording-1',
startedAt: '2026-01-01T13:02:00.000Z',
completedAt: '2026-01-01T14:00:00.000Z',
},
],
});
});
it('returns an empty snapshot shell from an empty payload', () => {
expect(parseRecallBotSnapshot({})).toEqual({
id: undefined,
metadata: {},
statusChanges: [],
recordings: [],
});
});
it('skips malformed recording entries and keeps recordings without timestamps', () => {
expect(
parseRecallBotSnapshot({
recordings: [null, 'not-a-recording', { id: 'recall-recording-1' }],
}),
).toEqual({
id: undefined,
metadata: {},
statusChanges: [],
recordings: [
{
id: 'recall-recording-1',
startedAt: undefined,
completedAt: undefined,
},
],
});
});
it('skips malformed status change entries and non-array recordings', () => {
expect(
parseRecallBotSnapshot({
status_changes: [
null,
'not-an-object',
{ created_at: '2026-01-01T13:00:00.000Z' },
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
],
recordings: 'not-an-array',
}),
).toEqual({
id: undefined,
metadata: {},
statusChanges: [
{ code: 'in_call_recording', createdAt: '2026-01-01T13:02:00.000Z' },
],
recordings: [],
});
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { parseRecallRetryAfterMs } from 'src/logic-functions/recall-api/parse-recall-retry-after.util';
const NOW_MS = Date.parse('2026-01-01T13:00:00.000Z');
describe('parseRecallRetryAfterMs', () => {
it('parses delay-seconds into milliseconds', () => {
expect(parseRecallRetryAfterMs('9', NOW_MS)).toBe(9_000);
});
it('caps delay-seconds at the retry-after ceiling', () => {
expect(parseRecallRetryAfterMs('120', NOW_MS)).toBe(60_000);
});
it('parses an HTTP-date into a delay from now', () => {
expect(
parseRecallRetryAfterMs('Thu, 01 Jan 2026 13:00:05 GMT', NOW_MS),
).toBe(5_000);
});
it('clamps an HTTP-date in the past to zero', () => {
expect(
parseRecallRetryAfterMs('Thu, 01 Jan 2026 12:59:00 GMT', NOW_MS),
).toBe(0);
});
it.each(['0x10', '1e2', '1.5', '-5', '+9', 'Infinity'])(
'rejects the malformed delay-seconds form %s',
(retryAfterHeader) => {
expect(parseRecallRetryAfterMs(retryAfterHeader, NOW_MS)).toBeUndefined();
},
);
it('rejects values that are neither delay-seconds nor a date', () => {
expect(parseRecallRetryAfterMs('soon', NOW_MS)).toBeUndefined();
expect(parseRecallRetryAfterMs(' ', NOW_MS)).toBeUndefined();
expect(parseRecallRetryAfterMs(null, NOW_MS)).toBeUndefined();
});
});
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cancelOrEjectRecallBot } from 'src/logic-functions/recall-api/cancel-or-eject-recall-bot.util';
import { cancelRecallBot } from 'src/logic-functions/recall-api/cancel-recall-bot.util';
import { createAsyncRecallTranscript } from 'src/logic-functions/recall-api/create-async-recall-transcript.util';
import { ejectRecallBot } from 'src/logic-functions/recall-api/eject-recall-bot.util';
@@ -9,33 +10,34 @@ import { listScheduledRecallBots } from 'src/logic-functions/recall-api/list-sch
import { rescheduleRecallBot } from 'src/logic-functions/recall-api/reschedule-recall-bot.util';
import { retrieveRecallTranscript } from 'src/logic-functions/recall-api/retrieve-recall-transcript.util';
import { scheduleRecallBot } from 'src/logic-functions/recall-api/schedule-recall-bot.util';
import { CALL_RECORDER_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-name-env-var-name';
import { CALL_RECORDER_RECORDING_RETENTION_HOURS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-recording-retention-hours-env-var-name';
import { RECALL_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-api-key-env-var-name';
import { RECALL_REGION_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-region-env-var-name';
const getRecallApiConfigMock = vi.hoisted(() => vi.fn());
const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
const RECALL_ROUTING_METADATA = {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-id',
};
vi.mock('src/logic-functions/recall-api/get-recall-api-config.util', () => ({
getRecallApiConfig: getRecallApiConfigMock,
}));
const ENV_VAR_NAMES = [
RECALL_API_KEY_ENV_VAR_NAME,
RECALL_REGION_ENV_VAR_NAME,
CALL_RECORDER_NAME_ENV_VAR_NAME,
CALL_RECORDER_RECORDING_RETENTION_HOURS_ENV_VAR_NAME,
] as const;
const ORIGINAL_ENV_VALUES = ENV_VAR_NAMES.map(
(envVarName) => [envVarName, process.env[envVarName]] as const,
);
describe('recall bot api', () => {
const fetchMock = vi.fn();
beforeEach(() => {
delete process.env[CALL_RECORDER_RECORDING_RETENTION_HOURS_ENV_VAR_NAME];
getRecallApiConfigMock.mockReset();
getRecallApiConfigMock.mockReturnValue({
success: true,
config: {
apiKey: 'recall-api-key',
baseUrl: 'https://ap-northeast-1.recall.ai/api/v1',
botName: 'Call Recorder',
},
});
process.env[RECALL_API_KEY_ENV_VAR_NAME] = 'recall-api-key';
process.env[RECALL_REGION_ENV_VAR_NAME] = 'ap-northeast-1';
process.env[CALL_RECORDER_NAME_ENV_VAR_NAME] = 'Call Recorder';
fetchMock.mockReset();
fetchMock.mockResolvedValue({
ok: true,
@@ -45,6 +47,17 @@ describe('recall bot api', () => {
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
ORIGINAL_ENV_VALUES.forEach(([envVarName, originalValue]) => {
if (originalValue === undefined) {
delete process.env[envVarName];
} else {
process.env[envVarName] = originalValue;
}
});
vi.unstubAllGlobals();
});
it('creates Recall bot requests with the Token authorization scheme', async () => {
const result = await scheduleRecallBot({
meetingUrl: 'https://meet.google.com/abc-defg-hij',
@@ -166,14 +179,7 @@ describe('recall bot api', () => {
});
it('does not duplicate an existing Token authorization prefix', async () => {
getRecallApiConfigMock.mockReturnValue({
success: true,
config: {
apiKey: 'Token recall-api-key',
baseUrl: 'https://ap-northeast-1.recall.ai/api/v1',
botName: 'Call Recorder',
},
});
process.env[RECALL_API_KEY_ENV_VAR_NAME] = 'Token recall-api-key';
await scheduleRecallBot({
meetingUrl: 'https://meet.google.com/abc-defg-hij',
@@ -220,9 +226,15 @@ describe('recall bot api', () => {
expect(result).toEqual({
ok: true,
bots: [
{ id: 'bot-1', metadata: { twentyCallRecordingId: 'recording-1' } },
{ id: 'bot-2', metadata: {} },
{
id: 'bot-1',
metadata: { twentyCallRecordingId: 'recording-1' },
statusChanges: [],
recordings: [],
},
{ id: 'bot-2', metadata: {}, statusChanges: [], recordings: [] },
],
truncated: false,
});
expect(fetchMock).toHaveBeenNthCalledWith(
1,
@@ -236,7 +248,7 @@ describe('recall bot api', () => {
);
});
it('fails the scheduled bot list when the pagination cap would truncate results', async () => {
it('flags the result as truncated when the pagination cap leaves more pages', async () => {
for (let pageIndex = 1; pageIndex <= 10; pageIndex++) {
fetchMock.mockResolvedValueOnce({
ok: true,
@@ -254,9 +266,14 @@ describe('recall bot api', () => {
});
expect(result).toEqual({
ok: false,
status: null,
errorMessage: 'Recall bot list exceeded 10 pages',
ok: true,
bots: Array.from({ length: 10 }, (_, index) => ({
id: `bot-${index + 1}`,
metadata: {},
statusChanges: [],
recordings: [],
})),
truncated: true,
});
expect(fetchMock).toHaveBeenCalledTimes(10);
});
@@ -278,7 +295,8 @@ describe('recall bot api', () => {
expect(result).toEqual({
ok: true,
bots: [{ id: 'bot-1', metadata: {} }],
bots: [{ id: 'bot-1', metadata: {}, statusChanges: [], recordings: [] }],
truncated: false,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
@@ -319,7 +337,7 @@ describe('recall bot api', () => {
);
});
it('fetches a single bot and returns the raw response', async () => {
it('fetches a single bot and returns its parsed snapshot', async () => {
const botResponse = {
id: 'recall-bot-id',
status_changes: [{ code: 'done' }],
@@ -334,7 +352,21 @@ describe('recall bot api', () => {
const result = await getRecallBot({ externalBotId: 'recall-bot-id' });
expect(result).toEqual({ ok: true, bot: botResponse });
expect(result).toEqual({
ok: true,
bot: {
id: 'recall-bot-id',
metadata: {},
statusChanges: [{ code: 'done', createdAt: undefined }],
recordings: [
{
id: 'recall-recording-id',
startedAt: undefined,
completedAt: undefined,
},
],
},
});
expect(fetchMock).toHaveBeenCalledWith(
'https://ap-northeast-1.recall.ai/api/v1/bot/recall-bot-id/',
expect.objectContaining({ method: 'GET' }),
@@ -645,7 +677,12 @@ describe('recall bot api', () => {
expect(await resultPromise).toEqual({
ok: true,
bot: { id: 'recall-bot-id' },
bot: {
id: 'recall-bot-id',
metadata: {},
statusChanges: [],
recordings: [],
},
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
@@ -668,7 +705,12 @@ describe('recall bot api', () => {
expect(await resultPromise).toEqual({
ok: true,
bot: { id: 'recall-bot-id' },
bot: {
id: 'recall-bot-id',
metadata: {},
statusChanges: [],
recordings: [],
},
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
@@ -693,6 +735,100 @@ describe('recall bot api', () => {
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('defers instead of retrying in-process when Retry-After exceeds the invocation budget', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 429,
headers: {
get: (headerName: string) =>
headerName.toLowerCase() === 'retry-after' ? '120' : null,
},
json: async () => ({ detail: 'rate limited' }),
});
const result = await getRecallBot({ externalBotId: 'recall-bot-id' });
expect(result).toEqual({
ok: false,
status: 429,
errorMessage:
'Recall API responded with HTTP 429: {"detail":"rate limited"}',
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('stops retrying once accumulated retry waits exhaust the invocation budget', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 429,
headers: {
get: (headerName: string) =>
headerName.toLowerCase() === 'retry-after' ? '9' : null,
},
json: async () => ({ detail: 'rate limited' }),
});
const resultPromise = getRecallBot({ externalBotId: 'recall-bot-id' });
await vi.runAllTimersAsync();
expect(await resultPromise).toEqual({
ok: false,
status: 429,
errorMessage:
'Recall API responded with HTTP 429: {"detail":"rate limited"}',
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('reports the eject failure when both cancel and eject fail', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
fetchMock.mockImplementation(async (_url: string, init: RequestInit) => ({
ok: false,
status: init.method === 'DELETE' ? 409 : 500,
json: async () => ({
detail:
init.method === 'DELETE'
? 'cannot delete a joined bot'
: 'leave call failed',
}),
}));
const resultPromise = cancelOrEjectRecallBot('recall-bot-id');
await vi.runAllTimersAsync();
expect(await resultPromise).toBe(false);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'failed to cancel or eject Recall bot recall-bot-id',
),
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('HTTP 500: {"detail":"leave call failed"}'),
);
warnSpy.mockRestore();
});
it('defers 507 adhoc pool exhaustion instead of sleeping in-process', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 507,
json: async () => ({ detail: 'adhoc pool exhausted' }),
});
const result = await getRecallBot({ externalBotId: 'recall-bot-id' });
expect(result).toEqual({
ok: false,
status: 507,
errorMessage:
'Recall API responded with HTTP 507: {"detail":"adhoc pool exhausted"}',
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('does not retry client errors', async () => {
fetchMock.mockResolvedValue({
ok: false,
@@ -0,0 +1,33 @@
import { isNull } from '@sniptt/guards';
import { cancelRecallBot } from 'src/logic-functions/recall-api/cancel-recall-bot.util';
import { ejectRecallBot } from 'src/logic-functions/recall-api/eject-recall-bot.util';
export const cancelOrEjectRecallBot = async (
externalBotId: string,
): Promise<boolean> => {
const cancelResult = await cancelRecallBot({ externalBotId });
if (cancelResult.ok) {
return true;
}
let failureMessage = cancelResult.errorMessage;
// Deleting only works for not-yet-joined bots; eject the ones already in a call.
if (!isNull(cancelResult.status)) {
const ejectResult = await ejectRecallBot({ externalBotId });
if (ejectResult.ok) {
return true;
}
failureMessage = ejectResult.errorMessage;
}
console.warn(
`[call-recorder] failed to cancel or eject Recall bot ${externalBotId}: ${failureMessage}`,
);
return false;
};
@@ -1,10 +1,12 @@
import { isArray, isUndefined } from '@sniptt/guards';
import { isUndefined } from '@sniptt/guards';
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import { asRecord } from 'src/logic-functions/utils/as-record.util';
import { getString } from 'src/logic-functions/utils/get-string.util';
import { mapRecallStatusCodeToCallRecordingStatus } from 'src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util';
import { normalizeRecallTimestamp } from 'src/logic-functions/recall-api/normalize-recall-timestamp.util';
import {
type RecallBotSnapshot,
type RecallBotStatusChange,
} from 'src/logic-functions/recall-api/recall-bot-snapshot.type';
export type RecallBotSyncState = {
status: CallRecordingStatus | undefined;
@@ -15,21 +17,16 @@ export type RecallBotSyncState = {
isRecallRecordingDone: boolean;
};
type RecallBotStatusChange = {
code: string;
createdAt: string | undefined;
};
// Derives the state a full webhook history would have produced from GET /bot.
export const extractRecallBotSyncState = (
bot: Record<string, unknown>,
bot: RecallBotSnapshot,
): RecallBotSyncState => {
const statusChanges = extractStatusChanges(bot);
const { statusChanges } = bot;
const latestStatusChange = getLatestStatusChange(statusChanges);
const status = mapRecallStatusCodeToCallRecordingStatus(
latestStatusChange?.code,
);
const recording = extractFirstRecording(bot);
const recording = bot.recordings[0];
return {
status,
@@ -52,24 +49,6 @@ export const extractRecallBotSyncState = (
};
};
const extractStatusChanges = (
bot: Record<string, unknown>,
): RecallBotStatusChange[] => {
if (!isArray(bot.status_changes)) {
return [];
}
return bot.status_changes.flatMap((statusChange: unknown) => {
const code = getString(asRecord(statusChange)?.code);
if (isUndefined(code)) {
return [];
}
return [{ code, createdAt: getString(asRecord(statusChange)?.created_at) }];
});
};
const getLatestStatusChange = (
statusChanges: RecallBotStatusChange[],
): RecallBotStatusChange | undefined =>
@@ -116,32 +95,6 @@ const getStatusChangeTime = (
return new Date(normalizedTimestamp).getTime();
};
const extractFirstRecording = (
bot: Record<string, unknown>,
):
| {
id: string | undefined;
startedAt: string | undefined;
completedAt: string | undefined;
}
| undefined => {
if (!isArray(bot.recordings)) {
return undefined;
}
const recording = asRecord(bot.recordings[0]);
if (isUndefined(recording)) {
return undefined;
}
return {
id: getString(recording.id),
startedAt: getString(recording.started_at),
completedAt: getString(recording.completed_at),
};
};
const findStatusChangeTimestamp = (
statusChanges: RecallBotStatusChange[],
code: string,
@@ -1,10 +1,12 @@
import { type RecallBotOperationFailure } from 'src/logic-functions/types/recall-bot-operation-result.type';
import { asRecord } from 'src/logic-functions/utils/as-record.util';
import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util';
import { parseRecallBotSnapshot } from 'src/logic-functions/recall-api/parse-recall-bot-snapshot.util';
import { type RecallBotSnapshot } from 'src/logic-functions/recall-api/recall-bot-snapshot.type';
import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util';
type GetRecallBotResult =
| { ok: true; bot: Record<string, unknown> }
| { ok: true; bot: RecallBotSnapshot }
| RecallBotOperationFailure;
export const getRecallBot = async ({
@@ -38,5 +40,5 @@ export const getRecallBot = async ({
};
}
return { ok: true, bot };
return { ok: true, bot: parseRecallBotSnapshot(bot) };
};
@@ -3,11 +3,12 @@ import { isString, isUndefined } from '@sniptt/guards';
import { type RecallBotOperationFailure } from 'src/logic-functions/types/recall-bot-operation-result.type';
import { asRecord } from 'src/logic-functions/utils/as-record.util';
import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util';
import { parseRecallBotSnapshot } from 'src/logic-functions/recall-api/parse-recall-bot-snapshot.util';
import { type RecallBotSnapshot } from 'src/logic-functions/recall-api/recall-bot-snapshot.type';
import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util';
export type RecallScheduledBot = {
export type RecallScheduledBot = RecallBotSnapshot & {
id: string;
metadata: Record<string, unknown>;
};
type RecallBotListResponse = {
@@ -16,7 +17,7 @@ type RecallBotListResponse = {
};
type ListScheduledRecallBotsResult =
| { ok: true; bots: RecallScheduledBot[] }
| { ok: true; bots: RecallScheduledBot[]; truncated: boolean }
| RecallBotOperationFailure;
const RECALL_BOT_LIST_MAX_PAGES = 10;
@@ -24,9 +25,11 @@ const RECALL_BOT_LIST_MAX_PAGES = 10;
export const listScheduledRecallBots = async ({
joinAtAfter,
joinAtBefore,
metadata,
}: {
joinAtAfter: string;
joinAtBefore: string;
metadata?: Record<string, string>;
}): Promise<ListScheduledRecallBotsResult> => {
const configResult = getRecallApiConfig();
@@ -35,9 +38,16 @@ export const listScheduledRecallBots = async ({
}
const bots: RecallScheduledBot[] = [];
let path: string | undefined = `/bot/?join_at_after=${encodeURIComponent(
joinAtAfter,
)}&join_at_before=${encodeURIComponent(joinAtBefore)}`;
const searchParams = new URLSearchParams({
join_at_after: joinAtAfter,
join_at_before: joinAtBefore,
});
Object.entries(metadata ?? {}).forEach(([key, value]) => {
searchParams.set(`metadata__${key}`, value);
});
let path: string | undefined = `/bot/?${searchParams.toString()}`;
for (
let pageIndex = 0;
@@ -58,15 +68,15 @@ export const listScheduledRecallBots = async ({
path = extractNextPath(result.data, configResult.config.baseUrl);
}
if (!isUndefined(path)) {
return {
ok: false,
status: null,
errorMessage: `Recall bot list exceeded ${RECALL_BOT_LIST_MAX_PAGES} pages`,
};
const truncated = !isUndefined(path);
if (truncated && process.env.NODE_ENV !== 'test') {
console.warn(
`[call-recorder] Recall bot list exceeded ${RECALL_BOT_LIST_MAX_PAGES} pages; continuing with ${bots.length} fetched bots`,
);
}
return { ok: true, bots };
return { ok: true, bots, truncated };
};
const extractRecallBots = (
@@ -79,16 +89,17 @@ const extractRecallBots = (
return response.results.flatMap((candidate: unknown) => {
const bot = asRecord(candidate);
if (isUndefined(bot) || !isString(bot.id)) {
if (isUndefined(bot)) {
return [];
}
return [
{
id: bot.id,
metadata: asRecord(bot.metadata) ?? {},
},
];
const snapshot = parseRecallBotSnapshot(bot);
if (isUndefined(snapshot.id)) {
return [];
}
return [{ ...snapshot, id: snapshot.id }];
});
};
@@ -0,0 +1,56 @@
import { isArray, isUndefined } from '@sniptt/guards';
import {
type RecallBotRecording,
type RecallBotSnapshot,
type RecallBotStatusChange,
} from 'src/logic-functions/recall-api/recall-bot-snapshot.type';
import { asRecord } from 'src/logic-functions/utils/as-record.util';
import { getString } from 'src/logic-functions/utils/get-string.util';
export const parseRecallBotSnapshot = (
payload: Record<string, unknown>,
): RecallBotSnapshot => ({
id: getString(payload.id),
metadata: asRecord(payload.metadata) ?? {},
statusChanges: parseStatusChanges(payload.status_changes),
recordings: parseRecordings(payload.recordings),
});
const parseStatusChanges = (value: unknown): RecallBotStatusChange[] => {
if (!isArray(value)) {
return [];
}
return value.flatMap((statusChange: unknown) => {
const code = getString(asRecord(statusChange)?.code);
if (isUndefined(code)) {
return [];
}
return [{ code, createdAt: getString(asRecord(statusChange)?.created_at) }];
});
};
const parseRecordings = (value: unknown): RecallBotRecording[] => {
if (!isArray(value)) {
return [];
}
return value.flatMap((recording: unknown) => {
const recordingRecord = asRecord(recording);
if (isUndefined(recordingRecord)) {
return [];
}
return [
{
id: getString(recordingRecord.id),
startedAt: getString(recordingRecord.started_at),
completedAt: getString(recordingRecord.completed_at),
},
];
});
};
@@ -0,0 +1,36 @@
import { RECALL_API_MAX_RETRY_AFTER_MS } from 'src/logic-functions/constants/recall-api-max-retry-after-ms';
export const parseRecallRetryAfterMs = (
retryAfterHeader: string | null,
nowMs: number,
): number | undefined => {
if (retryAfterHeader === null) {
return undefined;
}
const trimmedRetryAfterHeader = retryAfterHeader.trim();
if (trimmedRetryAfterHeader.length === 0) {
return undefined;
}
if (/^\d+$/.test(trimmedRetryAfterHeader)) {
return capRecallRetryAfterMs(Number(trimmedRetryAfterHeader) * 1000);
}
// Malformed numeric forms (1.5, 1e2, 0x10) would be misread as dates by Date.parse.
if (!Number.isNaN(Number(trimmedRetryAfterHeader))) {
return undefined;
}
const retryAfterDateMs = Date.parse(trimmedRetryAfterHeader);
if (Number.isNaN(retryAfterDateMs)) {
return undefined;
}
return capRecallRetryAfterMs(Math.max(0, retryAfterDateMs - nowMs));
};
const capRecallRetryAfterMs = (retryAfterMs: number): number =>
Math.min(retryAfterMs, RECALL_API_MAX_RETRY_AFTER_MS);
@@ -0,0 +1,38 @@
import { RECALL_API_ADHOC_POOL_RETRY_DELAY_MS } from 'src/logic-functions/constants/recall-api-adhoc-pool-retry-delay-ms';
import { RECALL_API_RETRY_DELAY_MS } from 'src/logic-functions/constants/recall-api-retry-delay-ms';
const RECALL_STATUS_RATE_LIMITED = 429;
const RECALL_STATUS_CONFLICT = 409;
const RECALL_STATUS_ADHOC_POOL_EXHAUSTED = 507;
const isRecallServerError = (status: number): boolean => status >= 500;
export const isRetryableRecallApiStatus = (status: number): boolean =>
status === RECALL_STATUS_RATE_LIMITED ||
status === RECALL_STATUS_CONFLICT ||
isRecallServerError(status);
export const resolveRecallApiRetryDelayMs = ({
retryAfterMs,
status,
attemptNumber,
random = Math.random,
}: {
retryAfterMs: number | undefined;
status: number | null;
attemptNumber: number;
random?: () => number;
}): number => {
if (retryAfterMs !== undefined) {
return retryAfterMs;
}
if (status === RECALL_STATUS_ADHOC_POOL_EXHAUSTED) {
return RECALL_API_ADHOC_POOL_RETRY_DELAY_MS;
}
// Equal jitter so retries sharing a failure instant do not re-collide.
const baseDelayMs = RECALL_API_RETRY_DELAY_MS * attemptNumber;
return Math.round((baseDelayMs * (1 + random())) / 2);
};
@@ -1,8 +1,13 @@
import { isUndefined } from '@sniptt/guards';
import { RECALL_API_MAX_IN_PROCESS_RETRY_WAIT_MS } from 'src/logic-functions/constants/recall-api-max-in-process-retry-wait-ms';
import { RECALL_API_MAX_ATTEMPTS } from 'src/logic-functions/constants/recall-api-max-attempts';
import { RECALL_API_RETRY_DELAY_MS } from 'src/logic-functions/constants/recall-api-retry-delay-ms';
import { type RecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util';
import { parseRecallRetryAfterMs } from 'src/logic-functions/recall-api/parse-recall-retry-after.util';
import {
isRetryableRecallApiStatus,
resolveRecallApiRetryDelayMs,
} from 'src/logic-functions/recall-api/recall-api-retry-policy.util';
type RecallBotApiRequestArgs = {
config: RecallApiConfig;
@@ -31,16 +36,32 @@ export const recallBotApiRequest = async <TData>(
requestArgs: RecallBotApiRequestArgs,
): Promise<RecallBotApiRequestResult<TData>> => {
const maxAttempts = requestArgs.maxAttempts ?? RECALL_API_MAX_ATTEMPTS;
let totalRetryWaitMs = 0;
for (let attemptNumber = 1; ; attemptNumber++) {
const { result, isRetryable } =
const { result, isRetryable, retryAfterMs } =
await performRecallBotApiRequestAttempt<TData>(requestArgs);
if (!isRetryable || attemptNumber >= maxAttempts) {
return result;
}
await sleep(RECALL_API_RETRY_DELAY_MS * attemptNumber);
const retryDelayMs = resolveRecallApiRetryDelayMs({
retryAfterMs,
status: result.status,
attemptNumber,
});
// Sleeping past the invocation budget would hit the timeout kill; defer to the reconcilers.
if (
totalRetryWaitMs + retryDelayMs >=
RECALL_API_MAX_IN_PROCESS_RETRY_WAIT_MS
) {
return result;
}
totalRetryWaitMs += retryDelayMs;
await sleep(retryDelayMs);
}
};
@@ -53,6 +74,7 @@ const performRecallBotApiRequestAttempt = async <TData>({
}: RecallBotApiRequestArgs): Promise<{
result: RecallBotApiRequestResult<TData>;
isRetryable: boolean;
retryAfterMs?: number;
}> => {
let response: Response;
@@ -103,6 +125,10 @@ const performRecallBotApiRequestAttempt = async <TData>({
if (!response.ok) {
return {
isRetryable: isRetryableRecallApiStatus(response.status),
retryAfterMs: parseRecallRetryAfterMs(
response.headers?.get('retry-after') ?? null,
Date.now(),
),
result: {
ok: false,
status: response.status,
@@ -134,9 +160,6 @@ const performRecallBotApiRequestAttempt = async <TData>({
}
};
const isRetryableRecallApiStatus = (status: number): boolean =>
status === 429 || status >= 500;
const sleep = (delayMs: number): Promise<void> =>
new Promise((resolve) => {
setTimeout(resolve, delayMs);
@@ -0,0 +1,18 @@
export type RecallBotStatusChange = {
code: string;
createdAt: string | undefined;
};
export type RecallBotRecording = {
id: string | undefined;
startedAt: string | undefined;
completedAt: string | undefined;
};
// Parsed once at the recall-api boundary so flows never handle raw provider records.
export type RecallBotSnapshot = {
id: string | undefined;
metadata: Record<string, unknown>;
statusChanges: RecallBotStatusChange[];
recordings: RecallBotRecording[];
};