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. Review in cubic --- .../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[]; +};