From 2201917f333cf2008db58f29c947f3fab0b04ba7 Mon Sep 17 00:00:00 2001
From: nitin <142569587+ehconitin@users.noreply.github.com>
Date: Wed, 15 Jul 2026 15:20:18 +0530
Subject: [PATCH] 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.
---
.../call-recorder/src/application-config.ts | 7 -
...call-recorder-max-media-file-size-bytes.ts | 1 +
...der-max-media-file-size-mb-env-var-name.ts | 2 -
...lt-call-recorder-max-media-file-size-mb.ts | 1 -
.../get-max-media-file-size-bytes.ts | 23 --
.../recall-api-adhoc-pool-retry-delay-ms.ts | 1 +
...recall-api-max-in-process-retry-wait-ms.ts | 1 +
.../recall-api-max-retry-after-ms.ts | 1 +
.../cleanup-orphaned-recall-bots.test.ts | 261 ++++++++++++------
.../converge-diverged-call-recordings.test.ts | 104 +++----
.../__tests__/handle-recall-webhook.test.ts | 13 +-
.../import-call-recording-media.test.ts | 62 +----
.../cleanup-orphaned-recall-bots.util.ts | 40 +--
.../flows/import-call-recording-media.util.ts | 18 +-
.../extract-recall-bot-sync-state.test.ts | 182 ++++++------
.../parse-recall-bot-snapshot.test.ts | 86 ++++++
.../parse-recall-retry-after.test.ts | 40 +++
.../__tests__/recall-bot-api.test.ts | 202 +++++++++++---
.../cancel-or-eject-recall-bot.util.ts | 33 +++
.../extract-recall-bot-sync-state.util.ts | 63 +----
.../recall-api/get-recall-bot.util.ts | 6 +-
.../list-scheduled-recall-bots.util.ts | 51 ++--
.../parse-recall-bot-snapshot.util.ts | 56 ++++
.../parse-recall-retry-after.util.ts | 36 +++
.../recall-api-retry-policy.util.ts | 38 +++
.../recall-api/recall-bot-api-request.util.ts | 35 ++-
.../recall-api/recall-bot-snapshot.type.ts | 18 ++
27 files changed, 922 insertions(+), 459 deletions(-)
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-max-media-file-size-bytes.ts
delete mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-max-media-file-size-mb-env-var-name.ts
delete mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-max-media-file-size-mb.ts
delete mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/get-max-media-file-size-bytes.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-api-adhoc-pool-retry-delay-ms.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-api-max-in-process-retry-wait-ms.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-api-max-retry-after-ms.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/parse-recall-bot-snapshot.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/parse-recall-retry-after.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/cancel-or-eject-recall-bot.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/parse-recall-bot-snapshot.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/parse-recall-retry-after.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/recall-api-retry-policy.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/recall-bot-snapshot.type.ts
diff --git a/packages/twenty-apps/public/call-recorder/src/application-config.ts b/packages/twenty-apps/public/call-recorder/src/application-config.ts
index 5aa2f72b08..f5bd6f0b45 100644
--- a/packages/twenty-apps/public/call-recorder/src/application-config.ts
+++ b/packages/twenty-apps/public/call-recorder/src/application-config.ts
@@ -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.',
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-max-media-file-size-bytes.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-max-media-file-size-bytes.ts
new file mode 100644
index 0000000000..d5391c495b
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-max-media-file-size-bytes.ts
@@ -0,0 +1 @@
+export const CALL_RECORDER_MAX_MEDIA_FILE_SIZE_BYTES = 500 * 1024 * 1024;
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-max-media-file-size-mb-env-var-name.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-max-media-file-size-mb-env-var-name.ts
deleted file mode 100644
index 7638ae89a9..0000000000
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-max-media-file-size-mb-env-var-name.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME =
- 'CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB';
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-max-media-file-size-mb.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-max-media-file-size-mb.ts
deleted file mode 100644
index 7165af6aca..0000000000
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-max-media-file-size-mb.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const DEFAULT_CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB = 80;
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/get-max-media-file-size-bytes.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/get-max-media-file-size-bytes.ts
deleted file mode 100644
index 40d8b7aad3..0000000000
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/get-max-media-file-size-bytes.ts
+++ /dev/null
@@ -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;
-};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-api-adhoc-pool-retry-delay-ms.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-api-adhoc-pool-retry-delay-ms.ts
new file mode 100644
index 0000000000..e9deb0ff51
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-api-adhoc-pool-retry-delay-ms.ts
@@ -0,0 +1 @@
+export const RECALL_API_ADHOC_POOL_RETRY_DELAY_MS = 10_000;
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-api-max-in-process-retry-wait-ms.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-api-max-in-process-retry-wait-ms.ts
new file mode 100644
index 0000000000..4a3ddb0248
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-api-max-in-process-retry-wait-ms.ts
@@ -0,0 +1 @@
+export const RECALL_API_MAX_IN_PROCESS_RETRY_WAIT_MS = 10_000;
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-api-max-retry-after-ms.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-api-max-retry-after-ms.ts
new file mode 100644
index 0000000000..4b9230fd58
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/recall-api-max-retry-after-ms.ts
@@ -0,0 +1 @@
+export const RECALL_API_MAX_RETRY_AFTER_MS = 60_000;
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/cleanup-orphaned-recall-bots.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/cleanup-orphaned-recall-bots.test.ts
index 33270ab0b9..cee38876ca 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/cleanup-orphaned-recall-bots.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/cleanup-orphaned-recall-bots.test.ts
@@ -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 =>
+ [
+ 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);
});
});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts
index 3747012adf..80019607a8 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts
@@ -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([
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts
index d02acc7c13..f53bee1cca 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts
@@ -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([
{
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/import-call-recording-media.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/import-call-recording-media.test.ts
index 80824fda56..6c2e154192 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/import-call-recording-media.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/import-call-recording-media.test.ts
@@ -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;
}) => {
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,
}),
},
});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/cleanup-orphaned-recall-bots.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/cleanup-orphaned-recall-bots.util.ts
index 57448b498f..ceebcc044c 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/cleanup-orphaned-recall-bots.util.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/cleanup-orphaned-recall-bots.util.ts
@@ -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 => {
- 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;
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/import-call-recording-media.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/import-call-recording-media.util.ts
index c28c9512ec..677c8c8cc6 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/import-call-recording-media.util.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/import-call-recording-media.util.ts
@@ -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;
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/extract-recall-bot-sync-state.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/extract-recall-bot-sync-state.test.ts
index 22ed42fa6c..34b2658fa2 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/extract-recall-bot-sync-state.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/extract-recall-bot-sync-state.test.ts
@@ -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 => ({
+ 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');
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/parse-recall-bot-snapshot.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/parse-recall-bot-snapshot.test.ts
new file mode 100644
index 0000000000..be8592c42a
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/parse-recall-bot-snapshot.test.ts
@@ -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: [],
+ });
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/parse-recall-retry-after.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/parse-recall-retry-after.test.ts
new file mode 100644
index 0000000000..f1979dd2f5
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/parse-recall-retry-after.test.ts
@@ -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();
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts
index 93a142c544..c62bc31632 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts
@@ -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,
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/cancel-or-eject-recall-bot.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/cancel-or-eject-recall-bot.util.ts
new file mode 100644
index 0000000000..09a36fe437
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/cancel-or-eject-recall-bot.util.ts
@@ -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 => {
+ 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;
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/extract-recall-bot-sync-state.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/extract-recall-bot-sync-state.util.ts
index 8ebc92e373..f6bf088744 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/extract-recall-bot-sync-state.util.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/extract-recall-bot-sync-state.util.ts
@@ -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,
+ 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,
-): 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,
-):
- | {
- 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,
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/get-recall-bot.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/get-recall-bot.util.ts
index 7772622d6d..eaf738c3e6 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/get-recall-bot.util.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/get-recall-bot.util.ts
@@ -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 }
+ | { 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) };
};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/list-scheduled-recall-bots.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/list-scheduled-recall-bots.util.ts
index 19fe22fae3..56bdc56511 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/list-scheduled-recall-bots.util.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/list-scheduled-recall-bots.util.ts
@@ -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;
};
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;
}): Promise => {
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 }];
});
};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/parse-recall-bot-snapshot.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/parse-recall-bot-snapshot.util.ts
new file mode 100644
index 0000000000..9e23203c9c
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/parse-recall-bot-snapshot.util.ts
@@ -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,
+): 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),
+ },
+ ];
+ });
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/parse-recall-retry-after.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/parse-recall-retry-after.util.ts
new file mode 100644
index 0000000000..3291525c7d
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/parse-recall-retry-after.util.ts
@@ -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);
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/recall-api-retry-policy.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/recall-api-retry-policy.util.ts
new file mode 100644
index 0000000000..1bdc43ab9d
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/recall-api-retry-policy.util.ts
@@ -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);
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/recall-bot-api-request.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/recall-bot-api-request.util.ts
index 9f91a4938f..5deb889faa 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/recall-bot-api-request.util.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/recall-bot-api-request.util.ts
@@ -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 (
requestArgs: RecallBotApiRequestArgs,
): Promise> => {
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(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 ({
}: RecallBotApiRequestArgs): Promise<{
result: RecallBotApiRequestResult;
isRetryable: boolean;
+ retryAfterMs?: number;
}> => {
let response: Response;
@@ -103,6 +125,10 @@ const performRecallBotApiRequestAttempt = async ({
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 ({
}
};
-const isRetryableRecallApiStatus = (status: number): boolean =>
- status === 429 || status >= 500;
-
const sleep = (delayMs: number): Promise =>
new Promise((resolve) => {
setTimeout(resolve, delayMs);
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/recall-bot-snapshot.type.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/recall-bot-snapshot.type.ts
new file mode 100644
index 0000000000..8e39c6cabb
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/recall-bot-snapshot.type.ts
@@ -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;
+ statusChanges: RecallBotStatusChange[];
+ recordings: RecallBotRecording[];
+};