From 19f0e3cad59e41f1857f642982f8a142b9ed41cf Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:19:28 +0530 Subject: [PATCH] Reduce Recall bot lifecycle reconciliation traffic (#22908) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - split pending Call Recording request maintenance from stale recording convergence - recover Recall bots by workspace and Call Recording metadata before creating a replacement - retry failed cancellations, including the canceled-plus-botless write-back race - move the broad orphaned-bot list sweep from every five minutes to a dedicated daily job - filter Recall bot lists by workspace metadata at the provider boundary ## Why This is stack 1/3 extracted from #22739. Healthy installed workspaces currently list Recall bots every five minutes even when no local state has diverged. This layer removes that unconditional list sweep while keeping pending request recovery at five-minute latency. The cancellation recovery also closes a crash window where Recall accepted a bot creation but the local bot ID write-back failed before the user canceled the request. The maintenance job now rediscovers and cancels that bot before it can join. ## Stack 1. **Recall bot lifecycle reconciliation** — this PR 2. Divergence-scoped recording synchronization — #22909 3. Artifact import offloading — #22910 ## Validation - `npm run typecheck` - `npm run lint` - `npm run test:unit` — 71 files, 454 tests --------- Co-authored-by: Claude --- .../public/call-recorder/package.json | 2 +- .../call-recorder/src/application-config.ts | 4 +- ...t-claimed-at-field-universal-identifier.ts | 2 + ...ots-logic-function-universal-identifier.ts | 2 + ...cts-logic-function-universal-identifier.ts | 2 + ...ort-call-recording-artifacts-route-path.ts | 2 + ...sts-logic-function-universal-identifier.ts | 2 + ...port-claimed-at-on-call-recording.field.ts | 22 + .../import-call-recording-artifacts.test.ts | 116 +++ .../__tests__/process-recall-webhook.test.ts | 17 +- .../cleanup-orphaned-recall-bots.ts | 50 + ...eanup-orphaned-recall-bots-cron-pattern.ts | 1 + ...ng-call-recording-requests-cron-pattern.ts | 1 + .../constants/stale-bot-state-cron-pattern.ts | 2 +- ...im-call-recording-artifacts-import.test.ts | 116 +++ ...im-call-recording-artifacts-import.util.ts | 53 + .../find-call-recordings-by-filter.util.ts | 12 +- ...led-call-recording-external-bot-id.util.ts | 39 + ...st-call-recording-artifacts-import.util.ts | 11 + .../domain/has-meeting-ended.util.ts | 32 + .../cleanup-orphaned-recall-bots.test.ts | 50 +- .../converge-diverged-call-recordings.test.ts | 908 +++++++++--------- .../__tests__/handle-recall-webhook.test.ts | 759 +++------------ .../import-call-recording-artifacts.test.ts | 507 ++++++++++ .../retry-failed-recall-cancellations.test.ts | 522 ++++++++++ ...l-bots-for-pending-call-recordings.test.ts | 139 ++- ...sting-recall-bot-to-call-recording.util.ts | 44 + .../cleanup-orphaned-recall-bots.util.ts | 38 +- .../converge-diverged-call-recordings.util.ts | 337 ++++--- .../flows/handle-recall-webhook.util.ts | 625 ++---------- .../import-call-recording-artifacts.util.ts | 191 ++++ .../flows/import-call-recording-media.util.ts | 65 +- .../persist-call-recording-progress.util.ts | 27 +- .../retry-failed-recall-cancellations.util.ts | 189 ++++ ...l-bots-for-pending-call-recordings.util.ts | 61 +- .../flows/sync-call-recording.util.ts | 248 +++++ .../import-call-recording-artifacts.ts | 63 ++ ...process-pending-call-recording-requests.ts | 68 ++ .../__tests__/recall-bot-api.test.ts | 64 +- .../fetch-recall-list-pages.util.ts | 75 ++ ...d-recall-bot-id-for-call-recording.util.ts | 41 + .../recall-api/get-recall-api-config.util.ts | 12 +- .../list-recall-transcripts.util.ts | 82 +- .../list-scheduled-recall-bots.util.ts | 94 +- .../recall-api/recall-bot-api-request.util.ts | 9 +- .../recall-api/schedule-recall-bot.util.ts | 24 + .../reconcile-stale-bot-state.ts | 70 +- ...recording-artifacts-import-request.type.ts | 6 + .../types/call-recording-record.type.ts | 2 + .../call-recording-update-fields.type.ts | 2 + .../utils/build-step-failure.util.ts | 14 + .../utils/normalize-optional-string.util.ts | 5 + 52 files changed, 3698 insertions(+), 2131 deletions(-) create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/call-recording-artifacts-import-claimed-at-field-universal-identifier.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/cleanup-orphaned-recall-bots-logic-function-universal-identifier.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/import-call-recording-artifacts-logic-function-universal-identifier.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/import-call-recording-artifacts-route-path.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/pending-call-recording-requests-logic-function-universal-identifier.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/fields/call-recording-artifacts-import-claimed-at-on-call-recording.field.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/import-call-recording-artifacts.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/cleanup-orphaned-recall-bots.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/cleanup-orphaned-recall-bots-cron-pattern.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/pending-call-recording-requests-cron-pattern.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/claim-call-recording-artifacts-import.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/claim-call-recording-artifacts-import.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/replace-canceled-call-recording-external-bot-id.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-artifacts-import.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/has-meeting-ended.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/import-call-recording-artifacts.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/retry-failed-recall-cancellations.test.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/attach-existing-recall-bot-to-call-recording.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/import-call-recording-artifacts.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/retry-failed-recall-cancellations.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/sync-call-recording.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/import-call-recording-artifacts.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/process-pending-call-recording-requests.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/fetch-recall-list-pages.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/find-scheduled-recall-bot-id-for-call-recording.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-artifacts-import-request.type.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/utils/build-step-failure.util.ts create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/utils/normalize-optional-string.util.ts diff --git a/packages/twenty-apps/public/call-recorder/package.json b/packages/twenty-apps/public/call-recorder/package.json index d2d90371f1..646484fd36 100644 --- a/packages/twenty-apps/public/call-recorder/package.json +++ b/packages/twenty-apps/public/call-recorder/package.json @@ -1,6 +1,6 @@ { "name": "@twentyhq/call-recorder", - "version": "1.0.11", + "version": "1.1.0", "license": "MIT", "engines": { "node": "^24.5.0", 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 f5bd6f0b45..94c2b7505a 100644 --- a/packages/twenty-apps/public/call-recorder/src/application-config.ts +++ b/packages/twenty-apps/public/call-recorder/src/application-config.ts @@ -40,10 +40,10 @@ export default defineApplication({ universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, displayName: APP_DISPLAY_NAME, description: APP_DESCRIPTION, - logoUrl: 'public/logo.svg', + logo: 'public/logo.svg', category: 'Productivity', author: 'Twenty', - screenshots: ['public/gallery/call-recorder-cover.png'], + galleryImages: ['public/gallery/call-recorder-cover.png'], applicationVariables: { [CALL_RECORDER_NAME_ENV_VAR_NAME]: { universalIdentifier: CALL_RECORDER_NAME_APP_VARIABLE_UNIVERSAL_IDENTIFIER, diff --git a/packages/twenty-apps/public/call-recorder/src/constants/call-recording-artifacts-import-claimed-at-field-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/call-recording-artifacts-import-claimed-at-field-universal-identifier.ts new file mode 100644 index 0000000000..7f0233edbd --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/call-recording-artifacts-import-claimed-at-field-universal-identifier.ts @@ -0,0 +1,2 @@ +export const CALL_RECORDING_ARTIFACTS_IMPORT_CLAIMED_AT_FIELD_UNIVERSAL_IDENTIFIER = + 'bf8ebf7e-4d52-4a7b-8d41-337c53d478ab'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/cleanup-orphaned-recall-bots-logic-function-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/cleanup-orphaned-recall-bots-logic-function-universal-identifier.ts new file mode 100644 index 0000000000..944d51f1f6 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/cleanup-orphaned-recall-bots-logic-function-universal-identifier.ts @@ -0,0 +1,2 @@ +export const CLEANUP_ORPHANED_RECALL_BOTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + 'fa394597-dc73-4fee-9758-dea7401b0b8f'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/import-call-recording-artifacts-logic-function-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/import-call-recording-artifacts-logic-function-universal-identifier.ts new file mode 100644 index 0000000000..ff1d52cd91 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/import-call-recording-artifacts-logic-function-universal-identifier.ts @@ -0,0 +1,2 @@ +export const IMPORT_CALL_RECORDING_ARTIFACTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + '6b975429-7f0d-4a08-8e5e-5a830e6dc621'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/import-call-recording-artifacts-route-path.ts b/packages/twenty-apps/public/call-recorder/src/constants/import-call-recording-artifacts-route-path.ts new file mode 100644 index 0000000000..017c2f3e81 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/import-call-recording-artifacts-route-path.ts @@ -0,0 +1,2 @@ +export const IMPORT_CALL_RECORDING_ARTIFACTS_ROUTE_PATH = + '/call-recorder/import-call-recording-artifacts'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/pending-call-recording-requests-logic-function-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/pending-call-recording-requests-logic-function-universal-identifier.ts new file mode 100644 index 0000000000..55210023a8 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/pending-call-recording-requests-logic-function-universal-identifier.ts @@ -0,0 +1,2 @@ +export const PENDING_CALL_RECORDING_REQUESTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + 'd7d1170f-abb1-4c9b-8258-13219a611b03'; diff --git a/packages/twenty-apps/public/call-recorder/src/fields/call-recording-artifacts-import-claimed-at-on-call-recording.field.ts b/packages/twenty-apps/public/call-recorder/src/fields/call-recording-artifacts-import-claimed-at-on-call-recording.field.ts new file mode 100644 index 0000000000..876ddd26f2 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/fields/call-recording-artifacts-import-claimed-at-on-call-recording.field.ts @@ -0,0 +1,22 @@ +import { + defineField, + FieldType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { CALL_RECORDING_ARTIFACTS_IMPORT_CLAIMED_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-artifacts-import-claimed-at-field-universal-identifier'; + +export default defineField({ + universalIdentifier: + CALL_RECORDING_ARTIFACTS_IMPORT_CLAIMED_AT_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier, + type: FieldType.DATE_TIME, + name: 'artifactsImportClaimedAt', + label: 'Artifacts Import Claimed At', + description: + 'Lease held by the worker importing this recording’s artifacts; prevents concurrent webhook retries from duplicating provider imports.', + icon: 'IconLock', + isNullable: true, + isUIEditable: false, +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/import-call-recording-artifacts.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/import-call-recording-artifacts.test.ts new file mode 100644 index 0000000000..88e8f9d8b3 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/import-call-recording-artifacts.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type RoutePayload } from 'twenty-sdk/define'; + +import importCallRecordingArtifactsLogicFunction, { + importCallRecordingArtifactsHandler, +} from 'src/logic-functions/import-call-recording-artifacts'; +import { IMPORT_CALL_RECORDING_ARTIFACTS_ROUTE_PATH } from 'src/constants/import-call-recording-artifacts-route-path'; +import { type CallRecordingArtifactsImportRequest } from 'src/logic-functions/types/call-recording-artifacts-import-request.type'; + +const importCallRecordingArtifactsMock = vi.hoisted(() => vi.fn()); +const coreApiClientMock = vi.hoisted(() => vi.fn()); + +vi.mock( + 'src/logic-functions/flows/import-call-recording-artifacts.util', + () => ({ + importCallRecordingArtifacts: importCallRecordingArtifactsMock, + }), +); + +vi.mock('twenty-client-sdk/core', () => ({ + CoreApiClient: coreApiClientMock, +})); + +const buildRoutePayload = ( + body: Partial | null, +): RoutePayload> => + ({ + body, + headers: {}, + queryStringParameters: {}, + pathParameters: {}, + isBase64Encoded: false, + rawBody: undefined, + requestContext: { http: { method: 'POST', path: '/' } }, + userWorkspaceId: null, + }) as never; + +describe('import-call-recording-artifacts', () => { + beforeEach(() => { + importCallRecordingArtifactsMock.mockReset(); + importCallRecordingArtifactsMock.mockResolvedValue({ + status: 'imported', + callRecordingId: 'call-recording-1', + outcome: 'call-recording-artifacts-imported', + }); + coreApiClientMock.mockReset(); + }); + + it('declares an authenticated own-route trigger for continuation requests', () => { + expect(importCallRecordingArtifactsLogicFunction.success).toBe(true); + expect( + importCallRecordingArtifactsLogicFunction.config.httpRouteTriggerSettings, + ).toEqual({ + path: IMPORT_CALL_RECORDING_ARTIFACTS_ROUTE_PATH, + httpMethod: 'POST', + isAuthRequired: true, + }); + }); + + it('forwards a valid continuation request to the worker flow', async () => { + const body = { + callRecordingId: 'call-recording-1', + requestedAt: '2026-01-01T14:06:00.000Z', + }; + + const result = await importCallRecordingArtifactsHandler( + buildRoutePayload(body), + ); + + expect(coreApiClientMock).toHaveBeenCalledTimes(1); + expect(importCallRecordingArtifactsMock).toHaveBeenCalledWith({ + client: coreApiClientMock.mock.instances[0], + request: body, + }); + expect(result).toEqual({ + status: 'imported', + callRecordingId: 'call-recording-1', + outcome: 'call-recording-artifacts-imported', + }); + }); + + it('ignores caller-supplied provider ids instead of forwarding them', async () => { + const result = await importCallRecordingArtifactsHandler( + buildRoutePayload({ + callRecordingId: 'call-recording-1', + requestedAt: '2026-01-01T14:06:00.000Z', + event: 'transcript.done', + externalBotId: 'forged-bot-id', + externalRecordingId: 'forged-recording-id', + transcriptId: 'forged-transcript-id', + } as never), + ); + + expect(importCallRecordingArtifactsMock).toHaveBeenCalledWith({ + client: coreApiClientMock.mock.instances[0], + request: { + callRecordingId: 'call-recording-1', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }); + expect(result).toEqual(expect.objectContaining({ status: 'imported' })); + }); + + it('skips invalid continuation requests without touching the worker flow', async () => { + const result = await importCallRecordingArtifactsHandler( + buildRoutePayload({ requestedAt: '2026-01-01T14:06:00.000Z' }), + ); + + expect(importCallRecordingArtifactsMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + status: 'skipped', + callRecordingId: 'unknown', + reason: 'invalid call recording artifacts import request', + }); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/process-recall-webhook.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/process-recall-webhook.test.ts index 0f1727588b..27c18e961b 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/process-recall-webhook.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/process-recall-webhook.test.ts @@ -6,6 +6,7 @@ import processRecallWebhookLogicFunction, { const queryMock = vi.hoisted(() => vi.fn()); const mutationMock = vi.hoisted(() => vi.fn()); +const requestArtifactImportMock = vi.hoisted(() => vi.fn()); vi.mock('twenty-client-sdk/core', () => ({ CoreApiClient: class { @@ -14,6 +15,13 @@ vi.mock('twenty-client-sdk/core', () => ({ }, })); +vi.mock( + 'src/logic-functions/data/request-call-recording-artifacts-import.util', + () => ({ + requestCallRecordingArtifactsImport: requestArtifactImportMock, + }), +); + const buildRecordingDoneWebhookBody = () => ({ event: 'recording.done', data: { @@ -60,6 +68,8 @@ describe('process-recall-webhook', () => { mutationMock.mockResolvedValue({ updateCallRecording: { id: 'call-recording-1' }, }); + requestArtifactImportMock.mockReset(); + requestArtifactImportMock.mockResolvedValue(true); }); afterEach(() => { @@ -85,7 +95,9 @@ describe('process-recall-webhook', () => { expect(queryMock).toHaveBeenCalledWith( expect.objectContaining({ callRecordings: expect.objectContaining({ - __args: { filter: { id: { eq: 'call-recording-1' } }, first: 1 }, + __args: expect.objectContaining({ + filter: { id: { eq: 'call-recording-1' } }, + }), }), }), ); @@ -103,6 +115,9 @@ describe('process-recall-webhook', () => { id: true, }, }); + expect(requestArtifactImportMock).toHaveBeenCalledWith( + expect.objectContaining({ callRecordingId: 'call-recording-1' }), + ); expect(result).toEqual({ status: 'updated', event: 'recording.done', diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/cleanup-orphaned-recall-bots.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/cleanup-orphaned-recall-bots.ts new file mode 100644 index 0000000000..df55e067c8 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/cleanup-orphaned-recall-bots.ts @@ -0,0 +1,50 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { defineLogicFunction } from 'twenty-sdk/define'; + +import { CLEANUP_ORPHANED_RECALL_BOTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/cleanup-orphaned-recall-bots-logic-function-universal-identifier'; +import { CLEANUP_ORPHANED_RECALL_BOTS_CRON_PATTERN } from 'src/logic-functions/constants/cleanup-orphaned-recall-bots-cron-pattern'; +import { + cleanupOrphanedRecallBots, + type CleanupOrphanedRecallBotsResult, +} from 'src/logic-functions/flows/cleanup-orphaned-recall-bots.util'; +import { + buildStepFailure, + type StepFailure, +} from 'src/logic-functions/utils/build-step-failure.util'; + +// Pending requests handle incomplete cancellation and bot-id write-back; this daily list fetch only finds unclaimed Recall bots. +const ORPHANED_BOT_JOIN_AT_LOOKBACK_HOURS = 25; +const ORPHANED_BOT_JOIN_AT_LOOKAHEAD_HOURS = 24; + +const cleanupOrphanedRecallBotsHandler = async (): Promise< + CleanupOrphanedRecallBotsResult | StepFailure +> => { + const now = new Date(); + + try { + return await cleanupOrphanedRecallBots({ + client: new CoreApiClient(), + joinAtAfter: new Date( + now.getTime() - ORPHANED_BOT_JOIN_AT_LOOKBACK_HOURS * 60 * 60 * 1000, + ).toISOString(), + joinAtBefore: new Date( + now.getTime() + ORPHANED_BOT_JOIN_AT_LOOKAHEAD_HOURS * 60 * 60 * 1000, + ).toISOString(), + }); + } catch (error) { + return buildStepFailure('orphaned bot cancellation', error); + } +}; + +export default defineLogicFunction({ + universalIdentifier: + CLEANUP_ORPHANED_RECALL_BOTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'cleanup-orphaned-recall-bots', + description: + 'Daily cleanup that lists workspace Recall bots and cancels those no CallRecording request claims.', + timeoutSeconds: 250, + handler: cleanupOrphanedRecallBotsHandler, + cronTriggerSettings: { + pattern: CLEANUP_ORPHANED_RECALL_BOTS_CRON_PATTERN, + }, +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/cleanup-orphaned-recall-bots-cron-pattern.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/cleanup-orphaned-recall-bots-cron-pattern.ts new file mode 100644 index 0000000000..1ace597e0f --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/cleanup-orphaned-recall-bots-cron-pattern.ts @@ -0,0 +1 @@ +export const CLEANUP_ORPHANED_RECALL_BOTS_CRON_PATTERN = '30 4 * * *'; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/pending-call-recording-requests-cron-pattern.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/pending-call-recording-requests-cron-pattern.ts new file mode 100644 index 0000000000..0e6bdcafa7 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/pending-call-recording-requests-cron-pattern.ts @@ -0,0 +1 @@ +export const PENDING_CALL_RECORDING_REQUESTS_CRON_PATTERN = '*/5 * * * *'; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/stale-bot-state-cron-pattern.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/stale-bot-state-cron-pattern.ts index a191b0af71..0133de3231 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/stale-bot-state-cron-pattern.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/stale-bot-state-cron-pattern.ts @@ -1 +1 @@ -export const STALE_BOT_STATE_CRON_PATTERN = '*/5 * * * *'; +export const STALE_BOT_STATE_CRON_PATTERN = '*/15 * * * *'; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/claim-call-recording-artifacts-import.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/claim-call-recording-artifacts-import.test.ts new file mode 100644 index 0000000000..246302aebf --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/claim-call-recording-artifacts-import.test.ts @@ -0,0 +1,116 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + claimCallRecordingArtifactsImport, + releaseCallRecordingArtifactsImportClaim, +} from 'src/logic-functions/data/claim-call-recording-artifacts-import.util'; + +const mutationMock = vi.fn(); + +const client = { mutation: mutationMock } as unknown as CoreApiClient; + +describe('claimCallRecordingArtifactsImport', () => { + beforeEach(() => { + mutationMock.mockReset(); + }); + + it('claims when no fresh lease is held and stamps the lease timestamp', async () => { + mutationMock.mockResolvedValue({ + updateCallRecordings: [{ id: 'call-recording-1' }], + }); + + const claimed = await claimCallRecordingArtifactsImport(client, { + callRecordingId: 'call-recording-1', + now: new Date('2026-01-01T14:06:00.000Z'), + }); + + expect(claimed).toBe(true); + expect(mutationMock).toHaveBeenCalledWith({ + updateCallRecordings: { + __args: { + filter: { + id: { eq: 'call-recording-1' }, + or: [ + { artifactsImportClaimedAt: { is: 'NULL' } }, + { artifactsImportClaimedAt: { lte: '2026-01-01T13:56:00.000Z' } }, + ], + }, + data: { artifactsImportClaimedAt: '2026-01-01T14:06:00.000Z' }, + }, + id: true, + }, + }); + }); + + it('does not claim when a fresh lease already blocks the update', async () => { + mutationMock.mockResolvedValue({ updateCallRecordings: [] }); + + const claimed = await claimCallRecordingArtifactsImport(client, { + callRecordingId: 'call-recording-1', + now: new Date('2026-01-01T14:06:00.000Z'), + }); + + expect(claimed).toBe(false); + }); + + it('reclaims a lease older than the TTL', async () => { + // Emulate the DB-side filter so the lte staleBefore branch and TTL math are exercised. + const storedClaimedAt = '2026-01-01T13:45:00.000Z'; // 21 minutes before now + mutationMock.mockImplementation(async (mutation: any) => { + const { filter } = mutation.updateCallRecordings.__args; + const staleBefore = filter.or[1].artifactsImportClaimedAt.lte; + const matches = storedClaimedAt <= staleBefore; + + return { updateCallRecordings: matches ? [{ id: filter.id.eq }] : [] }; + }); + + const claimed = await claimCallRecordingArtifactsImport(client, { + callRecordingId: 'call-recording-1', + now: new Date('2026-01-01T14:06:00.000Z'), + }); + + expect(claimed).toBe(true); + expect( + mutationMock.mock.calls[0][0].updateCallRecordings.__args.data, + ).toEqual({ artifactsImportClaimedAt: '2026-01-01T14:06:00.000Z' }); + }); + + it('does not reclaim a lease still within the TTL', async () => { + const storedClaimedAt = '2026-01-01T14:02:00.000Z'; // 4 minutes before now + mutationMock.mockImplementation(async (mutation: any) => { + const { filter } = mutation.updateCallRecordings.__args; + const staleBefore = filter.or[1].artifactsImportClaimedAt.lte; + const matches = storedClaimedAt <= staleBefore; + + return { updateCallRecordings: matches ? [{ id: filter.id.eq }] : [] }; + }); + + const claimed = await claimCallRecordingArtifactsImport(client, { + callRecordingId: 'call-recording-1', + now: new Date('2026-01-01T14:06:00.000Z'), + }); + + expect(claimed).toBe(false); + }); + + it('releases the lease by clearing the timestamp', async () => { + mutationMock.mockResolvedValue({ + updateCallRecording: { id: 'call-recording-1' }, + }); + + await releaseCallRecordingArtifactsImportClaim(client, { + callRecordingId: 'call-recording-1', + }); + + expect(mutationMock).toHaveBeenCalledWith({ + updateCallRecording: { + __args: { + id: 'call-recording-1', + data: { artifactsImportClaimedAt: null }, + }, + id: true, + }, + }); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/claim-call-recording-artifacts-import.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/claim-call-recording-artifacts-import.util.ts new file mode 100644 index 0000000000..43d0617bf9 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/claim-call-recording-artifacts-import.util.ts @@ -0,0 +1,53 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util'; + +// Crash safety net: a lease older than this is reclaimable so a worker that died +// mid-import never blocks the recording forever. Normal runs release explicitly. +const ARTIFACTS_IMPORT_CLAIM_TTL_MS = 10 * 60 * 1000; + +// Atomic per-recording lease. The conditional update matches only when no fresh +// lease is held, so exactly one of several concurrent webhook retries claims the +// import and performs the provider-facing work. +export const claimCallRecordingArtifactsImport = async ( + client: CoreApiClient, + { + callRecordingId, + now, + }: { + callRecordingId: string; + now: Date; + }, +): Promise => { + const staleBefore = new Date( + now.getTime() - ARTIFACTS_IMPORT_CLAIM_TTL_MS, + ).toISOString(); + + const result = await client.mutation({ + updateCallRecordings: { + __args: { + filter: { + id: { eq: callRecordingId }, + or: [ + { artifactsImportClaimedAt: { is: 'NULL' } }, + { artifactsImportClaimedAt: { lte: staleBefore } }, + ], + }, + data: { artifactsImportClaimedAt: now.toISOString() }, + }, + id: true, + }, + }); + + return (result.updateCallRecordings ?? []).length > 0; +}; + +export const releaseCallRecordingArtifactsImportClaim = async ( + client: CoreApiClient, + { callRecordingId }: { callRecordingId: string }, +): Promise => { + await updateCallRecording(client, { + id: callRecordingId, + data: { artifactsImportClaimedAt: null }, + }); +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recordings-by-filter.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recordings-by-filter.util.ts index b4444772bd..799b2a75cb 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recordings-by-filter.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recordings-by-filter.util.ts @@ -8,13 +8,15 @@ import { fetchAllNodes, type ConnectionPage, } from 'src/logic-functions/data/fetch-all-nodes.util'; -import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; +import { normalizeOptionalString } from 'src/logic-functions/utils/normalize-optional-string.util'; type CallRecordingNode = { id: string; title?: string | null; status?: string | null; recordingRequestStatus?: unknown; + createdAt?: string | null; + updatedAt?: string | null; startedAt?: string | null; endedAt?: string | null; calendarEventId?: string | null; @@ -46,6 +48,8 @@ export const findCallRecordingsByFilter = async ( title: true, status: true, recordingRequestStatus: true, + createdAt: true, + updatedAt: true, startedAt: true, endedAt: true, calendarEventId: true, @@ -70,6 +74,8 @@ export const findCallRecordingsByFilter = async ( recordingRequestStatus: normalizeCallRecordingRequestStatus( callRecording.recordingRequestStatus, ), + createdAt: callRecording.createdAt ?? undefined, + updatedAt: callRecording.updatedAt ?? undefined, startedAt: callRecording.startedAt ?? undefined, endedAt: callRecording.endedAt ?? undefined, calendarEventId: callRecording.calendarEventId ?? undefined, @@ -83,10 +89,6 @@ export const findCallRecordingsByFilter = async ( })); }; -const normalizeOptionalString = ( - value: string | null | undefined, -): string | undefined => (isNonEmptyString(value) ? value : undefined); - const normalizeCallRecordingRequestStatus = ( recordingRequestStatus: unknown, ): CallRecordingRequestStatus | undefined => { diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/replace-canceled-call-recording-external-bot-id.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/replace-canceled-call-recording-external-bot-id.util.ts new file mode 100644 index 0000000000..c6187e9b07 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/replace-canceled-call-recording-external-bot-id.util.ts @@ -0,0 +1,39 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status'; +import { NON_TERMINAL_CALL_RECORDING_STATUSES } from 'src/logic-functions/constants/non-terminal-call-recording-statuses'; + +export const replaceCanceledCallRecordingExternalBotId = async ( + client: CoreApiClient, + { + id, + expectedExternalBotId, + nextExternalBotId, + }: { + id: string; + expectedExternalBotId: string | null; + nextExternalBotId: string | null; + }, +): Promise => { + const result = await client.mutation({ + updateCallRecordings: { + __args: { + filter: { + id: { eq: id }, + recordingRequestStatus: { + eq: CallRecordingRequestStatus.CANCELED, + }, + status: { in: NON_TERMINAL_CALL_RECORDING_STATUSES }, + externalBotId: + expectedExternalBotId === null + ? { is: 'NULL' } + : { eq: expectedExternalBotId }, + }, + data: { externalBotId: nextExternalBotId }, + }, + id: true, + }, + }); + + return (result.updateCallRecordings ?? []).length > 0; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-artifacts-import.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-artifacts-import.util.ts new file mode 100644 index 0000000000..5e06fddabb --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-artifacts-import.util.ts @@ -0,0 +1,11 @@ +import { IMPORT_CALL_RECORDING_ARTIFACTS_ROUTE_PATH } from 'src/constants/import-call-recording-artifacts-route-path'; +import { postToOwnRoute } from 'src/logic-functions/data/post-to-own-route.util'; +import { type CallRecordingArtifactsImportRequest } from 'src/logic-functions/types/call-recording-artifacts-import-request.type'; + +export const requestCallRecordingArtifactsImport = async ( + request: CallRecordingArtifactsImportRequest, +): Promise => + postToOwnRoute({ + path: IMPORT_CALL_RECORDING_ARTIFACTS_ROUTE_PATH, + body: request, + }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/has-meeting-ended.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/has-meeting-ended.util.ts new file mode 100644 index 0000000000..32229bb1d8 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/has-meeting-ended.util.ts @@ -0,0 +1,32 @@ +import { isUndefined } from '@sniptt/guards'; + +export const hasMeetingEnded = ({ + startsAt, + endsAt, + now, + startGraceHours = 0, +}: { + startsAt: string | undefined; + endsAt: string | undefined; + now: Date; + startGraceHours?: number; +}): boolean => { + if (!isUndefined(endsAt)) { + const meetingEndTime = new Date(endsAt).getTime(); + + if (!Number.isNaN(meetingEndTime)) { + return meetingEndTime <= now.getTime(); + } + } + + if (isUndefined(startsAt)) { + return false; + } + + const meetingStartTime = new Date(startsAt).getTime(); + + return ( + !Number.isNaN(meetingStartTime) && + meetingStartTime + startGraceHours * 60 * 60 * 1000 <= now.getTime() + ); +}; 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 cee38876ca..d324c19d0f 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 @@ -165,12 +165,30 @@ describe('cleanupOrphanedRecallBots', () => { expect(result).toEqual({ scannedBotCount: 1, - truncatedScan: false, + truncatedBotList: false, canceledExternalBotIds: [], }); expect(getDeleteCalls()).toHaveLength(0); }); + it('lists only bots claimed by the current workspace', async () => { + stubRecallApi({ bots: [] }); + + await cleanupOrphanedRecallBots({ + client: buildClient([]), + joinAtAfter: JOIN_AT_AFTER, + joinAtBefore: JOIN_AT_BEFORE, + }); + + const [listRequestUrl] = fetchMock.mock.calls[0]; + const listRequestParameters = new URL(listRequestUrl).searchParams; + expect(listRequestParameters.get('join_at_after')).toBe(JOIN_AT_AFTER); + expect(listRequestParameters.get('join_at_before')).toBe(JOIN_AT_BEFORE); + expect(listRequestParameters.get('metadata__twentyWorkspaceId')).toBe( + CURRENT_WORKSPACE_ID, + ); + }); + it('cancels bots whose call recording request was canceled locally', async () => { stubRecallApi({ bots: [ @@ -195,7 +213,7 @@ describe('cleanupOrphanedRecallBots', () => { expect(result).toEqual({ scannedBotCount: 1, - truncatedScan: false, + truncatedBotList: false, canceledExternalBotIds: ['stale-cancel-bot'], }); expect(fetchMock).toHaveBeenCalledWith( @@ -232,7 +250,7 @@ describe('cleanupOrphanedRecallBots', () => { expect(result).toEqual({ scannedBotCount: 2, - truncatedScan: false, + truncatedBotList: false, canceledExternalBotIds: ['superseded-bot'], }); expect(getDeleteCalls()).toHaveLength(1); @@ -260,7 +278,7 @@ describe('cleanupOrphanedRecallBots', () => { expect(result).toEqual({ scannedBotCount: 1, - truncatedScan: false, + truncatedBotList: false, canceledExternalBotIds: ['orphan-bot'], }); }); @@ -289,7 +307,7 @@ describe('cleanupOrphanedRecallBots', () => { expect(result).toEqual({ scannedBotCount: 1, - truncatedScan: false, + truncatedBotList: false, canceledExternalBotIds: [], }); expect(getDeleteCalls()).toHaveLength(0); @@ -306,7 +324,7 @@ describe('cleanupOrphanedRecallBots', () => { expect(result).toEqual({ scannedBotCount: 1, - truncatedScan: false, + truncatedBotList: false, canceledExternalBotIds: [], }); expect(getDeleteCalls()).toHaveLength(0); @@ -330,7 +348,7 @@ describe('cleanupOrphanedRecallBots', () => { expect(result).toEqual({ scannedBotCount: 1, - truncatedScan: false, + truncatedBotList: false, canceledExternalBotIds: [], }); expect(getDeleteCalls()).toHaveLength(0); @@ -355,7 +373,7 @@ describe('cleanupOrphanedRecallBots', () => { expect(result).toEqual({ scannedBotCount: 1, - truncatedScan: false, + truncatedBotList: false, canceledExternalBotIds: [], }); expect(getDeleteCalls()).toHaveLength(0); @@ -379,7 +397,7 @@ describe('cleanupOrphanedRecallBots', () => { expect(result).toEqual({ scannedBotCount: 1, - truncatedScan: false, + truncatedBotList: false, canceledExternalBotIds: ['same-workspace-bot'], }); expect(fetchMock).toHaveBeenCalledWith( @@ -411,7 +429,7 @@ describe('cleanupOrphanedRecallBots', () => { expect(await resultPromise).toEqual({ scannedBotCount: 1, - truncatedScan: false, + truncatedBotList: false, canceledExternalBotIds: ['in-call-orphan'], }); expect(fetchMock).toHaveBeenCalledWith( @@ -456,7 +474,7 @@ describe('cleanupOrphanedRecallBots', () => { expect(result).toEqual({ scannedBotCount: 1, - truncatedScan: true, + truncatedBotList: true, canceledExternalBotIds: ['orphan-bot'], }); expect( @@ -483,13 +501,13 @@ describe('cleanupOrphanedRecallBots', () => { expect(result).toEqual({ scannedBotCount: 0, - truncatedScan: false, + truncatedBotList: false, canceledExternalBotIds: [], }); expect(getDeleteCalls()).toHaveLength(0); }); - it('skips cancellation when the current workspace cannot be resolved', async () => { + it('skips cancellation without listing bots when the current workspace cannot be resolved', async () => { delete process.env.TWENTY_APP_ACCESS_TOKEN; stubRecallApi({ bots: [ @@ -507,10 +525,10 @@ describe('cleanupOrphanedRecallBots', () => { }); expect(result).toEqual({ - scannedBotCount: 1, - truncatedScan: false, + scannedBotCount: 0, + truncatedBotList: false, canceledExternalBotIds: [], }); - expect(getDeleteCalls()).toHaveLength(0); + expect(fetchMock).not.toHaveBeenCalled(); }); }); 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 608f576550..4a97b2df91 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 @@ -1,176 +1,57 @@ -import { type ClientRequest, type IncomingMessage } from 'node:http'; -import { PassThrough, Readable } from 'node:stream'; - import { type CoreApiClient } from 'twenty-client-sdk/core'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { convergeDivergedCallRecordings } from 'src/logic-functions/flows/converge-diverged-call-recordings.util'; -const chargeCreditsMock = vi.hoisted(() => vi.fn()); -const metadataMutationMock = vi.hoisted(() => vi.fn()); -const requestOverHttpsMock = vi.hoisted(() => vi.fn()); +const getRecallBotMock = vi.hoisted(() => vi.fn()); +const getCurrentWorkspaceIdMock = vi.hoisted(() => vi.fn()); +const listScheduledRecallBotsMock = vi.hoisted(() => vi.fn()); +const listRecallTranscriptsMock = vi.hoisted(() => vi.fn()); +const createAsyncRecallTranscriptMock = vi.hoisted(() => vi.fn()); +const downloadTranscriptMock = vi.hoisted(() => vi.fn()); +const importCallRecordingMediaMock = vi.hoisted(() => vi.fn()); +const chargeCompletedCallRecordingMock = vi.hoisted(() => vi.fn()); -vi.mock('twenty-sdk/billing', () => ({ - chargeCredits: chargeCreditsMock, +vi.mock('src/logic-functions/recall-api/get-recall-bot.util', () => ({ + getRecallBot: getRecallBotMock, })); -vi.mock('twenty-client-sdk/metadata', () => ({ - MetadataApiClient: class { - mutation = metadataMutationMock; - }, +vi.mock('src/logic-functions/data/get-current-workspace-id.util', () => ({ + getCurrentWorkspaceId: getCurrentWorkspaceIdMock, })); -vi.mock('node:https', async () => { - const actualHttps = - await vi.importActual('node:https'); +vi.mock('src/logic-functions/recall-api/list-scheduled-recall-bots.util', () => ({ + listScheduledRecallBots: listScheduledRecallBotsMock, +})); - return { ...actualHttps, request: requestOverHttpsMock }; -}); +vi.mock('src/logic-functions/recall-api/list-recall-transcripts.util', () => ({ + listRecallTranscripts: listRecallTranscriptsMock, +})); + +vi.mock( + 'src/logic-functions/recall-api/create-async-recall-transcript.util', + () => ({ + createAsyncRecallTranscript: createAsyncRecallTranscriptMock, + }), +); + +vi.mock('src/logic-functions/flows/download-transcript.util', () => ({ + downloadTranscript: downloadTranscriptMock, +})); + +vi.mock('src/logic-functions/flows/import-call-recording-media.util', () => ({ + importCallRecordingMedia: importCallRecordingMediaMock, +})); + +vi.mock( + 'src/logic-functions/flows/charge-completed-call-recording.util', + () => ({ + chargeCompletedCallRecording: chargeCompletedCallRecordingMock, + }), +); const NOW = new Date('2026-06-10T12:00:00.000Z'); -const RECALL_BASE_URL = 'https://us-west-2.recall.ai/api/v1'; -const RECALL_BOT_URL = `${RECALL_BASE_URL}/bot/recall-bot-1/`; -const RECALL_TRANSCRIPT_LIST_URL = `${RECALL_BASE_URL}/transcript/?recording_id=recall-recording-1`; -const RECALL_CREATE_TRANSCRIPT_URL = `${RECALL_BASE_URL}/recording/recall-recording-1/create_transcript/`; -const RECALL_TRANSCRIPT_DETAILS_URL = `${RECALL_BASE_URL}/transcript/recall-transcript-1/`; -const RECALL_RECORDING_URL = `${RECALL_BASE_URL}/recording/recall-recording-1/`; -const TRANSCRIPT_DOWNLOAD_URL = 'https://media.example.com/transcript.json'; -const VIDEO_DOWNLOAD_URL = 'https://media.example.com/video.mp4'; -const AUDIO_DOWNLOAD_URL = 'https://media.example.com/audio.mp3'; - -const RECORDING_WITH_MEDIA = { - id: 'recall-recording-1', - media_shortcuts: { - video_mixed: { download_url: VIDEO_DOWNLOAD_URL }, - audio_mixed: { download_url: AUDIO_DOWNLOAD_URL }, - }, -}; - -// 2026-06-09T13:02:00.000Z -> 2026-06-09T14:00:00.000Z at 1_000_000 micro-credits per hour. -const CHARGE_FOR_58_RECORDED_MINUTES = { - creditsUsedMicro: 966_667, - quantity: 58, - operationType: 'CALL_RECORDING', - resourceContext: 'recall', -}; - -const fetchMock = vi.fn(); -const fetchResponsesByRequest = new Map unknown>(); - -const setFetchResponse = ( - method: string, - url: string, - respond: () => unknown, -) => { - fetchResponsesByRequest.set(`${method} ${url}`, respond); -}; - -const setFetchJsonResponse = ( - method: string, - url: string, - body: unknown, - status = 200, -) => { - setFetchResponse( - method, - url, - () => new Response(JSON.stringify(body), { status }), - ); -}; - -const setRecallBotResponse = (bot: Record) => { - setFetchJsonResponse('GET', RECALL_BOT_URL, bot); -}; - -const fetchedRequests = (): string[] => - fetchMock.mock.calls.map( - ([requestUrl, requestInit]) => - `${requestInit?.method ?? 'GET'} ${requestUrl}`, - ); - -const buildMediaDownloadResponse = (contentLengthBytes: number) => ({ - ok: true, - status: 200, - headers: { - get: (name: string) => - name.toLowerCase() === 'content-length' - ? String(contentLengthBytes) - : null, - }, - body: new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(contentLengthBytes)); - controller.close(); - }, - }), -}); - -const buildOversizedMediaDownloadResponse = () => ({ - ok: true, - status: 200, - headers: { - get: (name: string) => - name.toLowerCase() === 'content-length' - ? String(500 * 1024 * 1024 + 1) - : null, - }, - body: { cancel: async () => {} }, -}); - -type DirectUploadMutationRequest = - | { createFileUpload: { __args: { filename: string } } } - | { completeFileUpload: { __args: { fileId: string } } }; - -const FINAL_FILE_ID_BY_FILENAME: Record = { - 'video.mp4': 'file-video-1', - 'audio.mp3': 'file-audio-1', -}; - -const stubDirectUpload = () => { - metadataMutationMock.mockReset(); - metadataMutationMock.mockImplementation( - async (mutationRequest: DirectUploadMutationRequest) => { - if ('createFileUpload' in mutationRequest) { - const { filename } = mutationRequest.createFileUpload.__args; - - return { - createFileUpload: { - fileId: filename, - uploadUrl: `https://storage.example.com/${filename}`, - contentType: 'application/octet-stream', - }, - }; - } - - return { - completeFileUpload: { - id: FINAL_FILE_ID_BY_FILENAME[ - mutationRequest.completeFileUpload.__args.fileId - ], - }, - }; - }, - ); -}; - -const stubUploadRequests = () => { - requestOverHttpsMock.mockReset(); - requestOverHttpsMock.mockImplementation(() => { - const uploadRequest = new PassThrough(); - - uploadRequest.resume(); - uploadRequest.on('finish', () => { - const uploadResponse = Readable.from([]) as IncomingMessage; - - uploadResponse.statusCode = 200; - uploadRequest.emit('response', uploadResponse); - }); - - return uploadRequest as unknown as ClientRequest; - }); -}; - type CallRecordingNode = Record; class FakeCoreApiClient { @@ -231,67 +112,59 @@ const buildStuckRecordingNode = ( describe('convergeDivergedCallRecordings', () => { beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}); - vi.spyOn(console, 'log').mockImplementation(() => {}); - vi.stubGlobal('fetch', fetchMock); - vi.stubEnv('RECALL_API_KEY', 'recall-api-key'); - vi.stubEnv('RECALL_REGION', 'us-west-2'); - fetchMock.mockReset(); - fetchMock.mockImplementation( - async (requestUrl: string, requestInit?: { method?: string }) => { - const respond = fetchResponsesByRequest.get( - `${requestInit?.method ?? 'GET'} ${requestUrl}`, - ); - - if (respond === undefined) { - throw new Error( - `Unhandled fetch in test: ${requestInit?.method ?? 'GET'} ${requestUrl}`, - ); - } - - return respond(); - }, - ); - fetchResponsesByRequest.clear(); - setFetchJsonResponse('GET', RECALL_TRANSCRIPT_LIST_URL, { - next: null, - results: [], + getRecallBotMock.mockReset(); + getCurrentWorkspaceIdMock.mockReset(); + getCurrentWorkspaceIdMock.mockReturnValue('workspace-1'); + listScheduledRecallBotsMock.mockReset(); + listScheduledRecallBotsMock.mockResolvedValue({ + ok: true, + bots: [], + truncated: false, }); - setFetchJsonResponse( - 'POST', - RECALL_CREATE_TRANSCRIPT_URL, - { id: 'recall-transcript-1' }, - 201, - ); - setFetchJsonResponse('GET', RECALL_TRANSCRIPT_DETAILS_URL, { - status: { code: 'processing' }, + listRecallTranscriptsMock.mockReset(); + listRecallTranscriptsMock.mockResolvedValue({ + ok: true, + transcripts: [], }); - setFetchJsonResponse('GET', RECALL_RECORDING_URL, { - id: 'recall-recording-1', + createAsyncRecallTranscriptMock.mockReset(); + createAsyncRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcriptId: 'recall-transcript-1', }); - stubDirectUpload(); - stubUploadRequests(); - chargeCreditsMock.mockReset(); - chargeCreditsMock.mockResolvedValue(undefined); + downloadTranscriptMock.mockReset(); + downloadTranscriptMock.mockResolvedValue({ outcome: 'pending' }); + importCallRecordingMediaMock.mockReset(); + importCallRecordingMediaMock.mockResolvedValue({}); + chargeCompletedCallRecordingMock.mockReset(); + chargeCompletedCallRecordingMock.mockResolvedValue(undefined); }); - afterEach(() => { - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); - vi.restoreAllMocks(); + it('does not call Recall when there are no stale database candidates', async () => { + const result = await convergeDivergedCallRecordings({ + client: buildClient([]) as unknown as CoreApiClient, + now: NOW, + }); + + expect(listScheduledRecallBotsMock).not.toHaveBeenCalled(); + expect(getRecallBotMock).not.toHaveBeenCalled(); + expect(result.candidateCount).toBe(0); }); - it('heals a stuck RECORDING record from the Recall bot state', async () => { - setRecallBotResponse({ - 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' }, - ], - recordings: [ + it('uses a listed workspace bot without issuing a per-recording read', async () => { + listScheduledRecallBotsMock.mockResolvedValue({ + ok: true, + truncated: false, + bots: [ { - id: 'recall-recording-1', - started_at: '2026-06-09T13:02:00.000Z', - completed_at: '2026-06-09T14:00:00.000Z', + id: 'recall-bot-1', + metadata: { twentyWorkspaceId: 'workspace-1' }, + statusChanges: [ + { + code: 'in_call_recording', + createdAt: '2026-06-09T13:02:00.000Z', + }, + ], + recordings: [], }, ], }); @@ -302,14 +175,115 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - const [botRequestUrl, botRequestInit] = fetchMock.mock.calls[0]; - expect(botRequestUrl).toBe(RECALL_BOT_URL); - expect(botRequestInit.headers).toMatchObject({ - Authorization: 'Token recall-api-key', + expect(listScheduledRecallBotsMock).toHaveBeenCalledWith({ + joinAtAfter: '2026-06-02T12:00:00.000Z', + joinAtBefore: '2026-06-10T13:00:00.000Z', + metadata: { twentyWorkspaceId: 'workspace-1' }, + }); + expect(getRecallBotMock).not.toHaveBeenCalled(); + expect(result.updatedCallRecordingIds).toEqual(['call-recording-1']); + }); + + it('defers convergence without per-recording fan-out when the bot list fails', async () => { + listScheduledRecallBotsMock.mockResolvedValue({ + ok: false, + status: 429, + errorMessage: 'Recall API responded with HTTP 429', + }); + const client = buildClient([buildStuckRecordingNode()]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(getRecallBotMock).not.toHaveBeenCalled(); + expect(client.mutations).toEqual([]); + expect(result.candidateCount).toBe(1); + }); + + it('advances the capped fallback by a full batch on each interval', async () => { + const candidateNodes = Array.from({ length: 27 }, (_, index) => + buildStuckRecordingNode({ + id: `call-recording-${index + 1}`, + externalBotId: `recall-bot-${index + 1}`, + calendarEvent: null, + createdAt: null, + }), + ); + getRecallBotMock.mockResolvedValue({ + ok: false, + status: 400, + errorMessage: 'Recall API responded with HTTP 400', + }); + + await convergeDivergedCallRecordings({ + client: buildClient(candidateNodes) as unknown as CoreApiClient, + now: new Date(0), + }); + + expect( + getRecallBotMock.mock.calls.map(([input]) => input.externalBotId), + ).toEqual( + Array.from({ length: 25 }, (_, index) => `recall-bot-${index + 1}`), + ); + + getRecallBotMock.mockClear(); + + await convergeDivergedCallRecordings({ + client: buildClient(candidateNodes) as unknown as CoreApiClient, + now: new Date(15 * 60 * 1000), + }); + + expect( + getRecallBotMock.mock.calls.map(([input]) => input.externalBotId), + ).toEqual([ + 'recall-bot-26', + 'recall-bot-27', + ...Array.from({ length: 23 }, (_, index) => `recall-bot-${index + 1}`), + ]); + }); + + it('heals a stuck RECORDING record from the Recall bot state', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + 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', + startedAt: '2026-06-09T13:02:00.000Z', + completedAt: '2026-06-09T14:00:00.000Z', + }, + ], + }, + }); + const client = buildClient([buildStuckRecordingNode()]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(getRecallBotMock).toHaveBeenCalledWith({ + externalBotId: 'recall-bot-1', + }); + expect(importCallRecordingMediaMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: false, + hasVideo: false, + }); + expect(listRecallTranscriptsMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-1', + }); + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-1', }); - expect(fetchedRequests()).toContain(`GET ${RECALL_RECORDING_URL}`); - expect(fetchedRequests()).toContain(`GET ${RECALL_TRANSCRIPT_LIST_URL}`); - expect(fetchedRequests()).toContain(`POST ${RECALL_CREATE_TRANSCRIPT_URL}`); expect(client.mutations).toEqual([ expect.objectContaining({ id: 'call-recording-1', @@ -321,7 +295,7 @@ describe('convergeDivergedCallRecordings', () => { }), }), ]); - expect(chargeCreditsMock).not.toHaveBeenCalled(); + expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); expect(result).toEqual({ candidateCount: 1, updatedCallRecordingIds: ['call-recording-1'], @@ -333,11 +307,14 @@ describe('convergeDivergedCallRecordings', () => { }); it('marks FAILED when Recall is done but has no recording artifact path', async () => { - setRecallBotResponse({ - status_changes: [ - { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, - ], - recordings: [], + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + statusChanges: [ + { code: 'done', createdAt: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [], + }, }); const client = buildClient([buildStuckRecordingNode()]); @@ -346,10 +323,8 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(fetchedRequests()).not.toContain( - `GET ${RECALL_TRANSCRIPT_LIST_URL}`, - ); - expect(fetchedRequests()).not.toContain(`GET ${RECALL_RECORDING_URL}`); + expect(listRecallTranscriptsMock).not.toHaveBeenCalled(); + expect(importCallRecordingMediaMock).not.toHaveBeenCalled(); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -362,26 +337,55 @@ describe('convergeDivergedCallRecordings', () => { expect(result.updatedCallRecordingIds).toEqual(['call-recording-1']); }); - it('completes and charges when convergence lands the last artifact', async () => { - setRecallBotResponse({ - status_changes: [ - { code: 'done', created_at: '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', - }, - ], + it('does not fail a completed bot sync when a persisted artifact remains reachable', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + statusChanges: [ + { code: 'done', createdAt: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [], + }, + }); + const client = buildClient([ + buildStuckRecordingNode({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + }), + ]); + + await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { status: 'PROCESSING' }, + }, + ]); + }); + + it('completes and charges when convergence lands the last artifact', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + statusChanges: [ + { code: 'done', createdAt: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + startedAt: '2026-06-09T13:02:00.000Z', + completedAt: '2026-06-09T14:00:00.000Z', + }, + ], + }, + }); + importCallRecordingMediaMock.mockResolvedValue({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], }); - setFetchJsonResponse('GET', RECALL_RECORDING_URL, RECORDING_WITH_MEDIA); - setFetchResponse('GET', VIDEO_DOWNLOAD_URL, () => - buildMediaDownloadResponse(8), - ); - setFetchResponse('GET', AUDIO_DOWNLOAD_URL, () => - buildMediaDownloadResponse(8), - ); const client = buildClient([ buildStuckRecordingNode({ status: 'PROCESSING', @@ -397,12 +401,8 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(fetchedRequests()).not.toContain( - `POST ${RECALL_CREATE_TRANSCRIPT_URL}`, - ); - expect(fetchedRequests()).not.toContain( - `GET ${RECALL_TRANSCRIPT_LIST_URL}`, - ); + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(listRecallTranscriptsMock).not.toHaveBeenCalled(); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -416,9 +416,11 @@ describe('convergeDivergedCallRecordings', () => { data: { status: 'COMPLETED' }, }, ]); - expect(chargeCreditsMock).toHaveBeenCalledWith( - CHARGE_FOR_58_RECORDED_MINUTES, - ); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + }); expect(result).toEqual({ candidateCount: 1, updatedCallRecordingIds: ['call-recording-1'], @@ -430,25 +432,25 @@ describe('convergeDivergedCallRecordings', () => { }); it('completes and charges when the missing video is marked too large', async () => { - setRecallBotResponse({ - status_changes: [ - { code: 'done', created_at: '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', - }, - ], + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + statusChanges: [ + { code: 'done', createdAt: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + startedAt: '2026-06-09T13:02:00.000Z', + completedAt: '2026-06-09T14:00:00.000Z', + }, + ], + }, + }); + importCallRecordingMediaMock.mockResolvedValue({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + callRecorderFailureReason: 'video_file_too_large', }); - setFetchJsonResponse('GET', RECALL_RECORDING_URL, RECORDING_WITH_MEDIA); - setFetchResponse('GET', VIDEO_DOWNLOAD_URL, () => - buildOversizedMediaDownloadResponse(), - ); - setFetchResponse('GET', AUDIO_DOWNLOAD_URL, () => - buildMediaDownloadResponse(8), - ); const client = buildClient([ buildStuckRecordingNode({ status: 'PROCESSING', @@ -477,29 +479,28 @@ describe('convergeDivergedCallRecordings', () => { data: { status: 'COMPLETED' }, }, ]); - expect(chargeCreditsMock).toHaveBeenCalledWith( - CHARGE_FOR_58_RECORDED_MINUTES, - ); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + }); expect(result.updatedCallRecordingIds).toEqual(['call-recording-1']); }); it('completes from a persisted size marker once the transcript lands', async () => { - setRecallBotResponse({ - status_changes: [ - { code: 'done', created_at: '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', - }, - ], - }); - setFetchJsonResponse('GET', RECALL_RECORDING_URL, { - id: 'recall-recording-1', - media_shortcuts: { - audio_mixed: { download_url: AUDIO_DOWNLOAD_URL }, + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + statusChanges: [ + { code: 'done', createdAt: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + startedAt: '2026-06-09T13:02:00.000Z', + completedAt: '2026-06-09T14:00:00.000Z', + }, + ], }, }); const client = buildClient([ @@ -519,40 +520,46 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(fetchedRequests()).toContain(`GET ${RECALL_RECORDING_URL}`); - expect(fetchedRequests()).not.toContain(`GET ${AUDIO_DOWNLOAD_URL}`); + expect(importCallRecordingMediaMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: true, + hasVideo: false, + }); expect(client.mutations).toEqual([ { id: 'call-recording-1', data: { status: 'COMPLETED' }, }, ]); - expect(chargeCreditsMock).toHaveBeenCalledWith( - CHARGE_FOR_58_RECORDED_MINUTES, - ); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + }); expect(result.updatedCallRecordingIds).toEqual(['call-recording-1']); }); it('keeps the real failure reason over the size marker when the bot failed', async () => { - setRecallBotResponse({ - status_changes: [ - { code: 'fatal', created_at: '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', - }, - ], + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + statusChanges: [ + { code: 'fatal', createdAt: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + startedAt: '2026-06-09T13:02:00.000Z', + completedAt: '2026-06-09T14:00:00.000Z', + }, + ], + }, + }); + importCallRecordingMediaMock.mockResolvedValue({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + callRecorderFailureReason: 'video_file_too_large', }); - setFetchJsonResponse('GET', RECALL_RECORDING_URL, RECORDING_WITH_MEDIA); - setFetchResponse('GET', VIDEO_DOWNLOAD_URL, () => - buildOversizedMediaDownloadResponse(), - ); - setFetchResponse('GET', AUDIO_DOWNLOAD_URL, () => - buildMediaDownloadResponse(8), - ); const client = buildClient([ buildStuckRecordingNode({ status: 'PROCESSING', @@ -578,7 +585,7 @@ describe('convergeDivergedCallRecordings', () => { }, }, ]); - expect(chargeCreditsMock).not.toHaveBeenCalled(); + expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); }); it('skips records whose meeting has not started yet', async () => { @@ -596,7 +603,7 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(fetchMock).not.toHaveBeenCalled(); + expect(getRecallBotMock).not.toHaveBeenCalled(); expect(client.mutations).toEqual([]); expect(result.skippedNotStartedCallRecordingIds).toEqual([ 'call-recording-1', @@ -604,17 +611,20 @@ describe('convergeDivergedCallRecordings', () => { }); it('converges a meeting that ended early while its scheduled end is still in the future', async () => { - setRecallBotResponse({ - status_changes: [ - { code: 'done', created_at: '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', - }, - ], + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + statusChanges: [ + { code: 'done', createdAt: '2026-06-10T11:30:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + startedAt: '2026-06-10T11:05:00.000Z', + completedAt: '2026-06-10T11:25:00.000Z', + }, + ], + }, }); const client = buildClient([ buildStuckRecordingNode({ @@ -630,13 +640,19 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(fetchedRequests()).toContain(`GET ${RECALL_BOT_URL}`); + expect(getRecallBotMock).toHaveBeenCalledWith({ + externalBotId: 'recall-bot-1', + }); expect(result.updatedCallRecordingIds).toEqual(['call-recording-1']); expect(result.skippedNotStartedCallRecordingIds).toEqual([]); }); it('marks FAILED without clearing the bot id when Recall returns 404', async () => { - setFetchJsonResponse('GET', RECALL_BOT_URL, { detail: 'Not found.' }, 404); + getRecallBotMock.mockResolvedValue({ + ok: false, + status: 404, + errorMessage: 'Recall API responded with HTTP 404', + }); const client = buildClient([buildStuckRecordingNode()]); const result = await convergeDivergedCallRecordings({ @@ -658,7 +674,11 @@ describe('convergeDivergedCallRecordings', () => { }); it('does not downgrade a COMPLETED record when its bot 404s', async () => { - setFetchJsonResponse('GET', RECALL_BOT_URL, { detail: 'Not found.' }, 404); + getRecallBotMock.mockResolvedValue({ + ok: false, + status: 404, + errorMessage: 'Recall API responded with HTTP 404', + }); const client = buildClient([ buildStuckRecordingNode({ status: 'COMPLETED', @@ -688,17 +708,21 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(fetchMock).not.toHaveBeenCalled(); + expect(getRecallBotMock).not.toHaveBeenCalled(); expect(client.mutations).toEqual([]); expect(result.unconvergeableCallRecordingIds).toEqual(['call-recording-1']); expect(console.warn).toHaveBeenCalled(); }); it('converges candidates created long before a recently ended meeting', async () => { - setRecallBotResponse({ - status_changes: [ - { code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' }, - ], + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + statusChanges: [ + { code: 'in_call_recording', createdAt: '2026-06-09T13:02:00.000Z' }, + ], + recordings: [], + }, }); const client = buildClient([ buildStuckRecordingNode({ @@ -712,18 +736,23 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(fetchedRequests()).toContain(`GET ${RECALL_BOT_URL}`); + expect(getRecallBotMock).toHaveBeenCalledWith({ + externalBotId: 'recall-bot-1', + }); expect(result.unconvergeableCallRecordingIds).toEqual([]); }); it('applies the downgrade guard to pulled statuses while still filling timestamps', async () => { - setRecallBotResponse({ - status_changes: [ - { code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' }, - ], - recordings: [ - { id: 'recall-recording-1', started_at: '2026-06-09T13:02:00.000Z' }, - ], + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + statusChanges: [ + { code: 'in_call_recording', createdAt: '2026-06-09T13:02:00.000Z' }, + ], + recordings: [ + { id: 'recall-recording-1', startedAt: '2026-06-09T13:02:00.000Z' }, + ], + }, }); const client = buildClient([ buildStuckRecordingNode({ status: 'PROCESSING' }), @@ -746,17 +775,20 @@ describe('convergeDivergedCallRecordings', () => { }); it('requests a transcript for a COMPLETED candidate that has none', async () => { - setRecallBotResponse({ - status_changes: [ - { code: 'done', created_at: '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', - }, - ], + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + statusChanges: [ + { code: 'done', createdAt: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + startedAt: '2026-06-09T13:02:00.000Z', + completedAt: '2026-06-09T14:00:00.000Z', + }, + ], + }, }); const client = buildClient([ buildStuckRecordingNode({ @@ -771,11 +803,10 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect( - fetchedRequests().filter( - (request) => request === `POST ${RECALL_CREATE_TRANSCRIPT_URL}`, - ), - ).toHaveLength(1); + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledTimes(1); + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-1', + }); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -795,22 +826,31 @@ describe('convergeDivergedCallRecordings', () => { }); it('does not create a duplicate transcript when Recall already has one processing', async () => { - setRecallBotResponse({ - status_changes: [ - { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, - ], - recordings: [ + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + statusChanges: [ + { code: 'done', createdAt: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + startedAt: '2026-06-09T13:02:00.000Z', + completedAt: '2026-06-09T14:00:00.000Z', + }, + ], + }, + }); + listRecallTranscriptsMock.mockResolvedValue({ + ok: true, + transcripts: [ { - id: 'recall-recording-1', - started_at: '2026-06-09T13:02:00.000Z', - completed_at: '2026-06-09T14:00:00.000Z', + id: 'recall-transcript-1', + statusCode: 'processing', + statusSubCode: undefined, }, ], }); - setFetchJsonResponse('GET', RECALL_TRANSCRIPT_LIST_URL, { - next: null, - results: [{ id: 'recall-transcript-1', status: { code: 'processing' } }], - }); const client = buildClient([buildStuckRecordingNode()]); const result = await convergeDivergedCallRecordings({ @@ -818,12 +858,8 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(fetchedRequests()).not.toContain( - `POST ${RECALL_CREATE_TRANSCRIPT_URL}`, - ); - expect(fetchedRequests()).not.toContain( - `GET ${RECALL_TRANSCRIPT_DETAILS_URL}`, - ); + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(downloadTranscriptMock).not.toHaveBeenCalled(); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -846,27 +882,35 @@ describe('convergeDivergedCallRecordings', () => { }, ]; - setRecallBotResponse({ - status_changes: [ - { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, - ], - recordings: [ + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + statusChanges: [ + { code: 'done', createdAt: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + startedAt: '2026-06-09T13:02:00.000Z', + completedAt: '2026-06-09T14:00:00.000Z', + }, + ], + }, + }); + listRecallTranscriptsMock.mockResolvedValue({ + ok: true, + transcripts: [ { - id: 'recall-recording-1', - started_at: '2026-06-09T13:02:00.000Z', - completed_at: '2026-06-09T14:00:00.000Z', + id: 'recall-transcript-1', + statusCode: 'done', + statusSubCode: undefined, }, ], }); - setFetchJsonResponse('GET', RECALL_TRANSCRIPT_LIST_URL, { - next: null, - results: [{ id: 'recall-transcript-1', status: { code: 'done' } }], + downloadTranscriptMock.mockResolvedValue({ + outcome: 'filled', + content: transcriptContent, }); - setFetchJsonResponse('GET', RECALL_TRANSCRIPT_DETAILS_URL, { - data: { download_url: TRANSCRIPT_DOWNLOAD_URL }, - status: { code: 'done' }, - }); - setFetchJsonResponse('GET', TRANSCRIPT_DOWNLOAD_URL, transcriptContent); const client = buildClient([ buildStuckRecordingNode({ status: 'PROCESSING', @@ -888,10 +932,10 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(fetchedRequests()).not.toContain( - `POST ${RECALL_CREATE_TRANSCRIPT_URL}`, - ); - expect(fetchedRequests()).toContain(`GET ${RECALL_TRANSCRIPT_DETAILS_URL}`); + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(downloadTranscriptMock).toHaveBeenCalledWith({ + transcriptId: 'recall-transcript-1', + }); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -902,31 +946,37 @@ describe('convergeDivergedCallRecordings', () => { data: { status: 'COMPLETED' }, }, ]); - expect(chargeCreditsMock).toHaveBeenCalledWith( - CHARGE_FOR_58_RECORDED_MINUTES, - ); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + }); expect(result.requestedTranscriptCallRecordingIds).toEqual([]); }); it('marks the call recording failed when Recall has a failed transcript artifact', async () => { - setRecallBotResponse({ - status_changes: [ - { code: 'done', created_at: '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', - }, - ], + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + statusChanges: [ + { code: 'done', createdAt: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + startedAt: '2026-06-09T13:02:00.000Z', + completedAt: '2026-06-09T14:00:00.000Z', + }, + ], + }, }); - setFetchJsonResponse('GET', RECALL_TRANSCRIPT_LIST_URL, { - next: null, - results: [ + listRecallTranscriptsMock.mockResolvedValue({ + ok: true, + transcripts: [ { id: 'recall-transcript-1', - status: { code: 'failed', sub_code: 'audio_missing' }, + statusCode: 'failed', + statusSubCode: 'audio_missing', }, ], }); @@ -944,12 +994,8 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(fetchedRequests()).not.toContain( - `POST ${RECALL_CREATE_TRANSCRIPT_URL}`, - ); - expect(fetchedRequests()).not.toContain( - `GET ${RECALL_TRANSCRIPT_DETAILS_URL}`, - ); + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(downloadTranscriptMock).not.toHaveBeenCalled(); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -968,10 +1014,14 @@ describe('convergeDivergedCallRecordings', () => { }); it('does not mutate a record the bot state agrees with', async () => { - setRecallBotResponse({ - status_changes: [ - { code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' }, - ], + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + statusChanges: [ + { code: 'in_call_recording', createdAt: '2026-06-09T13:02:00.000Z' }, + ], + recordings: [], + }, }); const client = buildClient([ buildStuckRecordingNode({ startedAt: '2026-06-09T13:02:00.000Z' }), 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 5767470a3d..668f8db9ce 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 @@ -1,8 +1,5 @@ -import { type ClientRequest, type IncomingMessage } from 'node:http'; -import { PassThrough, Readable } from 'node:stream'; - import { type CoreApiClient } from 'twenty-client-sdk/core'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { handleRecallWebhook } from 'src/logic-functions/flows/handle-recall-webhook.util'; @@ -24,175 +21,53 @@ const buildRecordingDoneWebhookBody = () => ({ }, }); -const metadataMutationMock = vi.hoisted(() => vi.fn()); -const chargeCreditsMock = vi.hoisted(() => vi.fn()); -const requestOverHttpsMock = vi.hoisted(() => vi.fn()); +const getRecallBotMock = vi.hoisted(() => vi.fn()); +const listRecallTranscriptsMock = vi.hoisted(() => vi.fn()); +const createAsyncRecallTranscriptMock = vi.hoisted(() => vi.fn()); +const retrieveRecallTranscriptMock = vi.hoisted(() => vi.fn()); +const importCallRecordingMediaMock = vi.hoisted(() => vi.fn()); +const chargeCompletedCallRecordingMock = vi.hoisted(() => vi.fn()); +const requestArtifactContinuationMock = vi.hoisted(() => vi.fn()); -vi.mock('twenty-client-sdk/metadata', () => ({ - MetadataApiClient: class { - mutation = metadataMutationMock; - }, +vi.mock('src/logic-functions/recall-api/get-recall-bot.util', () => ({ + getRecallBot: getRecallBotMock, })); -vi.mock('twenty-sdk/billing', () => ({ - chargeCredits: chargeCreditsMock, +vi.mock('src/logic-functions/recall-api/list-recall-transcripts.util', () => ({ + listRecallTranscripts: listRecallTranscriptsMock, })); -vi.mock('node:https', async () => { - const actualHttps = - await vi.importActual('node:https'); +vi.mock( + 'src/logic-functions/recall-api/create-async-recall-transcript.util', + () => ({ + createAsyncRecallTranscript: createAsyncRecallTranscriptMock, + }), +); - return { ...actualHttps, request: requestOverHttpsMock }; -}); +vi.mock( + 'src/logic-functions/recall-api/retrieve-recall-transcript.util', + () => ({ + retrieveRecallTranscript: retrieveRecallTranscriptMock, + }), +); -const RECALL_API_BASE_URL = 'https://us-west-2.recall.ai/api/v1'; -const VIDEO_DOWNLOAD_URL = 'https://recall-media.example.com/video.mp4'; -const AUDIO_DOWNLOAD_URL = 'https://recall-media.example.com/audio.mp3'; -const TOO_LARGE_MEDIA_CONTENT_LENGTH_BYTES = 500 * 1024 * 1024 + 1; +vi.mock('src/logic-functions/flows/import-call-recording-media.util', () => ({ + importCallRecordingMedia: importCallRecordingMediaMock, +})); -const fetchMock = vi.fn(); +vi.mock( + 'src/logic-functions/data/request-call-recording-artifacts-import.util', + () => ({ + requestCallRecordingArtifactsImport: requestArtifactContinuationMock, + }), +); -let fetchRoutes: Record Response>; - -const jsonResponse = (body: unknown, status = 200): Response => - new Response(JSON.stringify(body), { status }); - -const mediaDownloadResponse = (contentLengthBytes: number): Response => - new Response(new Uint8Array(8), { - status: 200, - headers: { 'content-length': String(contentLengthBytes) }, - }); - -const setFetchRoute = ( - method: 'GET' | 'POST', - url: string, - buildResponse: () => Response, -) => { - fetchRoutes[`${method} ${url}`] = buildResponse; -}; - -// Unrouted Recall API calls fail like the old per-util "disabled in test" defaults. -const defaultRecallApiResponse = ( - method: string, - url: string, -): Response | undefined => { - if (method === 'POST' && url.endsWith('/create_transcript/')) { - return jsonResponse({ detail: 'transcript request disabled in test' }, 400); - } - - if (method !== 'GET') { - return undefined; - } - - if (url.startsWith(`${RECALL_API_BASE_URL}/transcript/?recording_id=`)) { - return jsonResponse({ results: [], next: null }); - } - - if (url.startsWith(`${RECALL_API_BASE_URL}/transcript/`)) { - return jsonResponse( - { detail: 'transcript retrieval disabled in test' }, - 400, - ); - } - - if (url.startsWith(`${RECALL_API_BASE_URL}/bot/`)) { - return jsonResponse({ detail: 'bot fetch disabled in test' }, 404); - } - - if (url.startsWith(`${RECALL_API_BASE_URL}/recording/`)) { - return jsonResponse({ detail: 'media import disabled in test' }, 404); - } - - return undefined; -}; - -const fetchedUrls = (): string[] => - fetchMock.mock.calls.map(([requestUrl]) => String(requestUrl)); - -const stubRecallRecordingMedia = ({ - externalRecordingId, - videoContentLengthBytes, - audioContentLengthBytes, -}: { - externalRecordingId: string; - videoContentLengthBytes?: number; - audioContentLengthBytes?: number; -}) => { - setFetchRoute( - 'GET', - `${RECALL_API_BASE_URL}/recording/${externalRecordingId}/`, - () => - jsonResponse({ - id: externalRecordingId, - media_shortcuts: { - ...(videoContentLengthBytes === undefined - ? {} - : { video_mixed: { download_url: VIDEO_DOWNLOAD_URL } }), - ...(audioContentLengthBytes === undefined - ? {} - : { audio_mixed: { download_url: AUDIO_DOWNLOAD_URL } }), - }, - }), - ); - - if (videoContentLengthBytes !== undefined) { - setFetchRoute('GET', VIDEO_DOWNLOAD_URL, () => - mediaDownloadResponse(videoContentLengthBytes), - ); - } - - if (audioContentLengthBytes !== undefined) { - setFetchRoute('GET', AUDIO_DOWNLOAD_URL, () => - mediaDownloadResponse(audioContentLengthBytes), - ); - } -}; - -type MediaUploadMutationRequest = - | { createFileUpload: { __args: { filename: string } } } - | { completeFileUpload: { __args: { fileId: string } } }; - -const FINAL_FILE_ID_BY_UPLOAD_FILE_ID: Record = { - 'upload-video.mp4': 'file-video-1', - 'upload-audio.mp3': 'file-audio-1', -}; - -const stubMediaUploadTargets = () => { - metadataMutationMock.mockImplementation( - (mutation: MediaUploadMutationRequest) => { - if ('createFileUpload' in mutation) { - const { filename } = mutation.createFileUpload.__args; - - return Promise.resolve({ - createFileUpload: { - fileId: `upload-${filename}`, - uploadUrl: `https://storage.example.com/${filename}`, - contentType: 'application/octet-stream', - }, - }); - } - - const { fileId } = mutation.completeFileUpload.__args; - - return Promise.resolve({ - completeFileUpload: { id: FINAL_FILE_ID_BY_UPLOAD_FILE_ID[fileId] }, - }); - }, - ); -}; - -const buildUploadRequest = (): ClientRequest => { - const uploadRequest = new PassThrough(); - - uploadRequest.on('finish', () => { - const uploadResponse = Readable.from([]) as IncomingMessage; - - uploadResponse.statusCode = 200; - uploadRequest.emit('response', uploadResponse); - }); - - return uploadRequest as unknown as ClientRequest; -}; +vi.mock( + 'src/logic-functions/flows/charge-completed-call-recording.util', + () => ({ + chargeCompletedCallRecording: chargeCompletedCallRecordingMock, + }), +); type CallRecordingNode = { id: string; @@ -278,43 +153,35 @@ class FakeCoreApiClient { describe('handleRecallWebhook', () => { beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}); - vi.spyOn(console, 'log').mockImplementation(() => {}); - vi.stubEnv('RECALL_API_KEY', 'recall-api-key'); - vi.stubEnv('RECALL_REGION', 'us-west-2'); - fetchRoutes = {}; - fetchMock.mockReset(); - fetchMock.mockImplementation( - async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - const method = init?.method ?? 'GET'; - const route = fetchRoutes[`${method} ${url}`]; - - if (route !== undefined) { - return route(); - } - - const defaultResponse = defaultRecallApiResponse(method, url); - - if (defaultResponse === undefined) { - throw new Error(`Unhandled fetch in test: ${method} ${url}`); - } - - return defaultResponse; - }, - ); - vi.stubGlobal('fetch', fetchMock); - metadataMutationMock.mockReset(); - stubMediaUploadTargets(); - chargeCreditsMock.mockReset(); - chargeCreditsMock.mockResolvedValue(undefined); - requestOverHttpsMock.mockReset(); - requestOverHttpsMock.mockImplementation(() => buildUploadRequest()); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); - vi.restoreAllMocks(); + getRecallBotMock.mockReset(); + getRecallBotMock.mockResolvedValue({ + ok: false, + status: null, + errorMessage: 'bot fetch disabled in test', + }); + listRecallTranscriptsMock.mockReset(); + listRecallTranscriptsMock.mockResolvedValue({ + ok: true, + transcripts: [], + }); + createAsyncRecallTranscriptMock.mockReset(); + createAsyncRecallTranscriptMock.mockResolvedValue({ + ok: false, + status: null, + errorMessage: 'transcript request disabled in test', + }); + retrieveRecallTranscriptMock.mockReset(); + retrieveRecallTranscriptMock.mockResolvedValue({ + ok: false, + status: null, + errorMessage: 'transcript retrieval disabled in test', + }); + importCallRecordingMediaMock.mockReset(); + importCallRecordingMediaMock.mockResolvedValue({}); + chargeCompletedCallRecordingMock.mockReset(); + chargeCompletedCallRecordingMock.mockResolvedValue('charged'); + requestArtifactContinuationMock.mockReset(); + requestArtifactContinuationMock.mockResolvedValue(true); }); it('updates a call recording from bot metadata on status change events', async () => { @@ -889,12 +756,7 @@ describe('handleRecallWebhook', () => { expect(client.mutations).toEqual([]); }); - it('requests a transcript once when the recording first completes', async () => { - setFetchRoute( - 'POST', - `${RECALL_API_BASE_URL}/recording/recall-recording-1/create_transcript/`, - () => jsonResponse({ id: 'recall-transcript-1' }), - ); + it('queues artifact import when the recording first completes', async () => { const client = new FakeCoreApiClient([ { id: 'call-recording-1', @@ -909,24 +771,12 @@ describe('handleRecallWebhook', () => { body: buildRecordingDoneWebhookBody(), }); - expect( - fetchedUrls().filter((requestUrl) => - requestUrl.endsWith('/create_transcript/'), - ), - ).toHaveLength(1); - expect(fetchMock).toHaveBeenCalledWith( - `${RECALL_API_BASE_URL}/recording/recall-recording-1/create_transcript/`, - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - Authorization: 'Token recall-api-key', - }), - body: JSON.stringify({ - provider: { recallai_async: { language_code: 'auto' } }, - diarization: { use_separate_streams_when_available: true }, - }), - }), - ); + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(importCallRecordingMediaMock).not.toHaveBeenCalled(); + expect(requestArtifactContinuationMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + requestedAt: expect.any(String), + }); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -934,17 +784,33 @@ describe('handleRecallWebhook', () => { status: 'PROCESSING', externalBotId: 'recall-bot-1', externalRecordingId: 'recall-recording-1', - transcript: { - recallTranscriptId: 'recall-transcript-1', - status: 'PENDING', - requestedAt: expect.any(String), - }, }, }, ]); }); - it('does not re-request a transcript on a redelivered done event while Recall list is stale', async () => { + it('throws when the artifact import request fails so Svix redelivers', async () => { + requestArtifactContinuationMock.mockResolvedValue(false); + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + transcript: null, + }, + ]); + + await expect( + handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: buildRecordingDoneWebhookBody(), + }), + ).rejects.toThrow( + 'failed to request artifact import for call recording call-recording-1', + ); + }); + + it('queues redelivered done events without touching transcript APIs inline', async () => { const client = new FakeCoreApiClient([ { id: 'call-recording-1', @@ -964,17 +830,10 @@ describe('handleRecallWebhook', () => { body: buildRecordingDoneWebhookBody(), }); - expect( - fetchedUrls().filter((requestUrl) => - requestUrl.endsWith('/create_transcript/'), - ), - ).toEqual([]); - expect(fetchedUrls()).toContain( - `${RECALL_API_BASE_URL}/transcript/?recording_id=recall-recording-1`, - ); - expect(fetchedUrls()).toContain( - `${RECALL_API_BASE_URL}/transcript/recall-transcript-1/`, - ); + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(listRecallTranscriptsMock).not.toHaveBeenCalled(); + expect(retrieveRecallTranscriptMock).not.toHaveBeenCalled(); + expect(requestArtifactContinuationMock).toHaveBeenCalledTimes(1); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -987,15 +846,7 @@ describe('handleRecallWebhook', () => { ]); }); - it('resolves the recording id from the bot when the payload and record lack one', async () => { - setFetchRoute('GET', `${RECALL_API_BASE_URL}/bot/recall-bot-1/`, () => - jsonResponse({ recordings: [{ id: 'recall-recording-9' }] }), - ); - setFetchRoute( - 'POST', - `${RECALL_API_BASE_URL}/recording/recall-recording-9/create_transcript/`, - () => jsonResponse({ id: 'recall-transcript-9' }), - ); + it('defers provider lookup when the payload and record lack a recording id', async () => { const client = new FakeCoreApiClient([ { id: 'call-recording-1', @@ -1024,35 +875,24 @@ describe('handleRecallWebhook', () => { }, }); - expect(fetchMock).toHaveBeenCalledWith( - `${RECALL_API_BASE_URL}/bot/recall-bot-1/`, - expect.objectContaining({ method: 'GET' }), - ); - expect(fetchMock).toHaveBeenCalledWith( - `${RECALL_API_BASE_URL}/recording/recall-recording-9/create_transcript/`, - expect.objectContaining({ method: 'POST' }), - ); + expect(getRecallBotMock).not.toHaveBeenCalled(); + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(requestArtifactContinuationMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + requestedAt: expect.any(String), + }); expect(client.mutations).toEqual([ expect.objectContaining({ id: 'call-recording-1', data: expect.objectContaining({ status: 'PROCESSING', externalBotId: 'recall-bot-1', - externalRecordingId: 'recall-recording-9', }), }), ]); }); - it('imports media on recording.done and completes once all artifacts are present', async () => { - setFetchRoute('GET', `${RECALL_API_BASE_URL}/bot/recall-bot-1/`, () => - jsonResponse({ id: 'recall-bot-1' }), - ); - stubRecallRecordingMedia({ - externalRecordingId: 'recall-recording-1', - videoContentLengthBytes: 8, - audioContentLengthBytes: 8, - }); + it('queues media import on recording.done instead of completing inline', async () => { const client = new FakeCoreApiClient([ { id: 'call-recording-1', @@ -1070,98 +910,22 @@ describe('handleRecallWebhook', () => { body: buildRecordingDoneWebhookBody(), }); - expect(fetchedUrls()).toContain( - `${RECALL_API_BASE_URL}/recording/recall-recording-1/`, - ); - expect(fetchedUrls()).toContain(VIDEO_DOWNLOAD_URL); - expect(fetchedUrls()).toContain(AUDIO_DOWNLOAD_URL); + expect(importCallRecordingMediaMock).not.toHaveBeenCalled(); expect(client.mutations).toEqual([ { id: 'call-recording-1', data: { + status: 'PROCESSING', externalBotId: 'recall-bot-1', externalRecordingId: 'recall-recording-1', - audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], - video: [{ fileId: 'file-video-1', label: 'video.mp4' }], }, }, - { - id: 'call-recording-1', - data: { status: 'COMPLETED' }, - }, ]); - expect(chargeCreditsMock).toHaveBeenCalledWith({ - creditsUsedMicro: 1_050_000, - quantity: 63, - operationType: 'CALL_RECORDING', - resourceContext: 'recall', - }); + expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + expect(requestArtifactContinuationMock).toHaveBeenCalledTimes(1); }); - it('completes and keeps the size marker when a media file is too large', async () => { - setFetchRoute('GET', `${RECALL_API_BASE_URL}/bot/recall-bot-1/`, () => - jsonResponse({ id: 'recall-bot-1' }), - ); - stubRecallRecordingMedia({ - externalRecordingId: 'recall-recording-1', - videoContentLengthBytes: TOO_LARGE_MEDIA_CONTENT_LENGTH_BYTES, - audioContentLengthBytes: 8, - }); - const client = new FakeCoreApiClient([ - { - id: 'call-recording-1', - status: 'PROCESSING', - externalBotId: 'recall-bot-1', - externalRecordingId: 'recall-recording-1', - startedAt: '2026-01-01T13:02:00.000Z', - endedAt: '2026-01-01T14:05:00.000Z', - transcript: [{ participant: { id: 1 }, words: [] }], - }, - ]); - - const result = await handleRecallWebhook({ - client: client as unknown as CoreApiClient, - body: buildRecordingDoneWebhookBody(), - }); - - expect(client.mutations).toEqual([ - { - id: 'call-recording-1', - data: { - externalBotId: 'recall-bot-1', - externalRecordingId: 'recall-recording-1', - audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], - callRecorderFailureReason: 'video_file_too_large', - }, - }, - { - id: 'call-recording-1', - data: { status: 'COMPLETED' }, - }, - ]); - expect(chargeCreditsMock).toHaveBeenCalledWith({ - creditsUsedMicro: 1_050_000, - quantity: 63, - operationType: 'CALL_RECORDING', - resourceContext: 'recall', - }); - expect(result).toEqual({ - status: 'updated', - event: 'recording.done', - callRecordingId: 'call-recording-1', - callRecordingStatus: 'COMPLETED', - }); - }); - - it('keeps the real failure reason over the size marker on recording.failed', async () => { - setFetchRoute('GET', `${RECALL_API_BASE_URL}/bot/recall-bot-1/`, () => - jsonResponse({ id: 'recall-bot-1' }), - ); - stubRecallRecordingMedia({ - externalRecordingId: 'recall-recording-1', - videoContentLengthBytes: TOO_LARGE_MEDIA_CONTENT_LENGTH_BYTES, - audioContentLengthBytes: 8, - }); + it('keeps the real failure reason on recording.failed and defers media work', async () => { const client = new FakeCoreApiClient([ { id: 'call-recording-1', @@ -1186,15 +950,16 @@ describe('handleRecallWebhook', () => { { id: 'call-recording-1', data: { + status: 'FAILED', externalBotId: 'recall-bot-1', externalRecordingId: 'recall-recording-1', - status: 'FAILED', callRecorderFailureReason: 'recording.failed', - audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], }, }, ]); - expect(chargeCreditsMock).not.toHaveBeenCalled(); + expect(importCallRecordingMediaMock).not.toHaveBeenCalled(); + expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + expect(requestArtifactContinuationMock).toHaveBeenCalledTimes(1); expect(result).toEqual({ status: 'updated', event: 'recording.failed', @@ -1203,127 +968,7 @@ describe('handleRecallWebhook', () => { }); }); - it('stays PROCESSING on recording.done while artifacts are missing', async () => { - setFetchRoute('GET', `${RECALL_API_BASE_URL}/bot/recall-bot-1/`, () => - jsonResponse({ id: 'recall-bot-1' }), - ); - stubRecallRecordingMedia({ - externalRecordingId: 'recall-recording-1', - audioContentLengthBytes: 8, - }); - setFetchRoute( - 'POST', - `${RECALL_API_BASE_URL}/recording/recall-recording-1/create_transcript/`, - () => jsonResponse({ id: 'recall-transcript-1' }), - ); - const client = new FakeCoreApiClient([ - { - id: 'call-recording-1', - status: 'PROCESSING', - externalBotId: 'recall-bot-1', - startedAt: '2026-01-01T13:02:00.000Z', - endedAt: '2026-01-01T14:05:00.000Z', - transcript: null, - }, - ]); - - await handleRecallWebhook({ - client: client as unknown as CoreApiClient, - body: buildRecordingDoneWebhookBody(), - }); - - expect(fetchMock).toHaveBeenCalledWith( - `${RECALL_API_BASE_URL}/recording/recall-recording-1/create_transcript/`, - expect.objectContaining({ method: 'POST' }), - ); - expect(client.mutations).toEqual([ - expect.objectContaining({ - id: 'call-recording-1', - data: expect.objectContaining({ - status: 'PROCESSING', - externalBotId: 'recall-bot-1', - externalRecordingId: 'recall-recording-1', - audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], - }), - }), - ]); - expect(chargeCreditsMock).not.toHaveBeenCalled(); - }); - - it('marks FAILED on recording.done when no recording artifact path exists', async () => { - setFetchRoute('GET', `${RECALL_API_BASE_URL}/bot/recall-bot-1/`, () => - jsonResponse({ id: 'recall-bot-1', recordings: [] }), - ); - const client = new FakeCoreApiClient([ - { - id: 'call-recording-1', - status: 'PROCESSING', - externalBotId: 'recall-bot-1', - startedAt: '2026-01-01T13:02:00.000Z', - endedAt: '2026-01-01T14:05:00.000Z', - transcript: null, - }, - ]); - - const result = await handleRecallWebhook({ - client: client as unknown as CoreApiClient, - body: { - event: 'recording.done', - data: { - bot: { - id: 'recall-bot-1', - metadata: { - twentyWorkspaceId: WORKSPACE_ID, - }, - }, - }, - }, - }); - - expect(result).toEqual({ - status: 'updated', - event: 'recording.done', - callRecordingId: 'call-recording-1', - callRecordingStatus: 'FAILED', - }); - expect(client.mutations).toEqual([ - { - id: 'call-recording-1', - data: { - status: 'FAILED', - externalBotId: 'recall-bot-1', - callRecorderFailureReason: 'recording_artifacts_unavailable', - }, - }, - ]); - expect(chargeCreditsMock).not.toHaveBeenCalled(); - }); - - it('completes and charges on transcript.done when media is already imported', async () => { - const transcriptContent = [ - { - participant: { id: 1, name: 'Alice' }, - words: [{ text: 'hello', start_timestamp: { relative: 0.5 } }], - }, - ]; - - setFetchRoute( - 'GET', - `${RECALL_API_BASE_URL}/transcript/recall-transcript-1/`, - () => - jsonResponse({ - data: { - download_url: 'https://recall-transcripts.example.com/transcript-1', - }, - status: { code: 'done', sub_code: null }, - }), - ); - setFetchRoute( - 'GET', - 'https://recall-transcripts.example.com/transcript-1', - () => jsonResponse(transcriptContent), - ); - + it('queues transcript.done without downloading the transcript inline', async () => { const client = new FakeCoreApiClient([ { id: 'call-recording-1', @@ -1337,8 +982,6 @@ describe('handleRecallWebhook', () => { status: 'PENDING', requestedAt: '2026-01-01T14:06:00.000Z', }, - audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], - video: [{ fileId: 'file-video-1', label: 'video.mp4' }], }, ]); @@ -1362,112 +1005,19 @@ describe('handleRecallWebhook', () => { }); expect(result).toEqual({ - status: 'updated', + status: 'queued', event: 'transcript.done', callRecordingId: 'call-recording-1', - transcriptOutcome: 'FILLED', }); - expect(client.mutations).toEqual([ - { - id: 'call-recording-1', - data: { transcript: transcriptContent }, - }, - { - id: 'call-recording-1', - data: { status: 'COMPLETED' }, - }, - ]); - expect(chargeCreditsMock).toHaveBeenCalledWith({ - creditsUsedMicro: 1_050_000, - quantity: 63, - operationType: 'CALL_RECORDING', - resourceContext: 'recall', - }); - }); - - it('fills the transcript from the download URL on transcript.done', async () => { - const transcriptContent = [ - { - participant: { id: 1, name: 'Alice' }, - words: [{ text: 'hello', start_timestamp: { relative: 0.5 } }], - }, - ]; - - setFetchRoute( - 'GET', - `${RECALL_API_BASE_URL}/transcript/recall-transcript-1/`, - () => - jsonResponse({ - data: { - download_url: 'https://recall-transcripts.example.com/transcript-1', - }, - status: { code: 'done', sub_code: null }, - }), - ); - setFetchRoute( - 'GET', - 'https://recall-transcripts.example.com/transcript-1', - () => jsonResponse(transcriptContent), - ); - - const client = new FakeCoreApiClient([ - { - id: 'call-recording-1', - status: 'COMPLETED', - externalBotId: 'recall-bot-1', - transcript: { - recallTranscriptId: 'recall-transcript-1', - status: 'PENDING', - requestedAt: '2026-01-01T14:06:00.000Z', - }, - }, - ]); - - const result = await handleRecallWebhook({ - client: client as unknown as CoreApiClient, - body: { - event: 'transcript.done', - data: { - bot: { - id: 'recall-bot-1', - metadata: { - twentyWorkspaceId: WORKSPACE_ID, - twentyCallRecordingId: 'call-recording-1', - }, - }, - transcript: { - id: 'recall-transcript-1', - }, - recording: { - id: 'recall-recording-1', - }, - }, - }, - }); - - expect(result).toEqual({ - status: 'updated', - event: 'transcript.done', + expect(retrieveRecallTranscriptMock).not.toHaveBeenCalled(); + expect(requestArtifactContinuationMock).toHaveBeenCalledWith({ callRecordingId: 'call-recording-1', - transcriptOutcome: 'FILLED', + requestedAt: expect.any(String), }); - expect(fetchMock).toHaveBeenCalledWith( - `${RECALL_API_BASE_URL}/transcript/recall-transcript-1/`, - expect.objectContaining({ method: 'GET' }), - ); - expect(client.mutations).toEqual([ - { - id: 'call-recording-1', - data: { - transcript: transcriptContent, - externalRecordingId: 'recall-recording-1', - }, - }, - ]); - expect(chargeCreditsMock).not.toHaveBeenCalled(); + expect(client.mutations).toEqual([]); }); - it('writes a FAILED marker on transcript.failed', async () => { + it('queues transcript.failed without writing the failure marker inline', async () => { const client = new FakeCoreApiClient([ { id: 'call-recording-1', @@ -1505,64 +1055,13 @@ describe('handleRecallWebhook', () => { }); expect(result).toEqual({ - status: 'updated', + status: 'queued', event: 'transcript.failed', callRecordingId: 'call-recording-1', - transcriptOutcome: 'FAILED', }); - expect(client.mutations).toEqual([ - { - id: 'call-recording-1', - data: { - transcript: { - recallTranscriptId: 'recall-transcript-1', - status: 'FAILED', - subCode: 'transcription_failed', - }, - callRecorderFailureReason: 'transcript_failed:transcription_failed', - status: 'FAILED', - }, - }, - ]); - expect(console.warn).toHaveBeenCalled(); - }); - - it('does not clobber a downloaded transcript with a late transcript.failed', async () => { - const client = new FakeCoreApiClient([ - { - id: 'call-recording-1', - status: 'COMPLETED', - externalBotId: 'recall-bot-1', - transcript: [{ participant: { id: 1 }, words: [] }], - }, - ]); - - const result = await handleRecallWebhook({ - client: client as unknown as CoreApiClient, - body: { - event: 'transcript.failed', - data: { - bot: { - id: 'recall-bot-1', - metadata: { - twentyWorkspaceId: WORKSPACE_ID, - twentyCallRecordingId: 'call-recording-1', - }, - }, - transcript: { - id: 'recall-transcript-1', - }, - status: { - sub_code: 'transcription_failed', - }, - }, - }, - }); - - expect(result).toEqual({ - status: 'skipped', - event: 'transcript.failed', - reason: 'transcript already filled', + expect(requestArtifactContinuationMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + requestedAt: expect.any(String), }); expect(client.mutations).toEqual([]); }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/import-call-recording-artifacts.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/import-call-recording-artifacts.test.ts new file mode 100644 index 0000000000..e54f0f6a50 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/import-call-recording-artifacts.test.ts @@ -0,0 +1,507 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { importCallRecordingArtifacts } from 'src/logic-functions/flows/import-call-recording-artifacts.util'; + +const getRecallBotMock = vi.hoisted(() => vi.fn()); +const listRecallTranscriptsMock = vi.hoisted(() => vi.fn()); +const createAsyncRecallTranscriptMock = vi.hoisted(() => vi.fn()); +const downloadTranscriptMock = vi.hoisted(() => vi.fn()); +const importCallRecordingMediaMock = vi.hoisted(() => vi.fn()); +const chargeCompletedCallRecordingMock = vi.hoisted(() => vi.fn()); +const claimArtifactsImportMock = vi.hoisted(() => vi.fn()); +const releaseArtifactsImportClaimMock = vi.hoisted(() => vi.fn()); + +vi.mock('src/logic-functions/recall-api/get-recall-bot.util', () => ({ + getRecallBot: getRecallBotMock, +})); + +vi.mock('src/logic-functions/recall-api/list-recall-transcripts.util', () => ({ + listRecallTranscripts: listRecallTranscriptsMock, +})); + +vi.mock( + 'src/logic-functions/recall-api/create-async-recall-transcript.util', + () => ({ + createAsyncRecallTranscript: createAsyncRecallTranscriptMock, + }), +); + +vi.mock('src/logic-functions/flows/download-transcript.util', () => ({ + downloadTranscript: downloadTranscriptMock, +})); + +vi.mock('src/logic-functions/flows/import-call-recording-media.util', () => ({ + importCallRecordingMedia: importCallRecordingMediaMock, +})); + +vi.mock( + 'src/logic-functions/flows/charge-completed-call-recording.util', + () => ({ + chargeCompletedCallRecording: chargeCompletedCallRecordingMock, + }), +); + +vi.mock( + 'src/logic-functions/data/claim-call-recording-artifacts-import.util', + () => ({ + claimCallRecordingArtifactsImport: claimArtifactsImportMock, + releaseCallRecordingArtifactsImportClaim: releaseArtifactsImportClaimMock, + }), +); + +type CallRecordingNode = { + id: string; + status?: string | null; + externalBotId?: string | null; + externalRecordingId?: string | null; + startedAt?: string | null; + endedAt?: string | null; + callRecorderFailureReason?: string | null; + transcript?: unknown; + audio?: unknown; + video?: unknown; +}; + +class FakeCoreApiClient { + mutations: Array<{ id: string; data: Record }> = []; + + constructor(private callRecordings: CallRecordingNode[]) {} + + async query(query: any): Promise { + if (query.callRecordings !== undefined) { + const id = query.callRecordings.__args.filter.id.eq; + + return { + callRecordings: { + edges: this.callRecordings + .filter((callRecording) => callRecording.id === id) + .map((node) => ({ node })), + }, + }; + } + + throw new Error(`Unhandled query: ${JSON.stringify(query)}`); + } + + async mutation(mutation: any): Promise { + if (mutation.updateCallRecordings !== undefined) { + const { filter, data } = mutation.updateCallRecordings.__args; + const id = filter.id.eq; + + this.mutations.push({ id, data }); + + return { updateCallRecordings: [{ id }] }; + } + + if (mutation.updateCallRecording !== undefined) { + const { id, data } = mutation.updateCallRecording.__args; + + this.mutations.push({ id, data }); + + return { updateCallRecording: { id } }; + } + + throw new Error(`Unhandled mutation: ${JSON.stringify(mutation)}`); + } +} + +const buildClient = (callRecordings: CallRecordingNode[]) => + new FakeCoreApiClient(callRecordings); + +const buildProcessingCallRecording = ( + overrides: Partial = {}, +): CallRecordingNode => ({ + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:05:00.000Z', + transcript: null, + audio: null, + video: null, + ...overrides, +}); + +describe('importCallRecordingArtifacts', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + getRecallBotMock.mockReset(); + getRecallBotMock.mockResolvedValue({ + ok: false, + status: null, + errorMessage: 'bot fetch disabled in test', + }); + listRecallTranscriptsMock.mockReset(); + listRecallTranscriptsMock.mockResolvedValue({ + ok: true, + transcripts: [], + }); + createAsyncRecallTranscriptMock.mockReset(); + createAsyncRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcriptId: 'recall-transcript-1', + }); + downloadTranscriptMock.mockReset(); + downloadTranscriptMock.mockResolvedValue({ outcome: 'pending' }); + importCallRecordingMediaMock.mockReset(); + importCallRecordingMediaMock.mockResolvedValue({}); + chargeCompletedCallRecordingMock.mockReset(); + chargeCompletedCallRecordingMock.mockResolvedValue('charged'); + claimArtifactsImportMock.mockReset(); + claimArtifactsImportMock.mockResolvedValue(true); + releaseArtifactsImportClaimMock.mockReset(); + releaseArtifactsImportClaimMock.mockResolvedValue(undefined); + }); + + it('requests transcript and media artifacts after a recording completion webhook', async () => { + const client = buildClient([buildProcessingCallRecording()]); + + const result = await importCallRecordingArtifacts({ + client: client as unknown as CoreApiClient, + request: { + callRecordingId: 'call-recording-1', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }); + + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-1', + }); + expect(importCallRecordingMediaMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: false, + hasVideo: false, + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }, + }, + ]); + expect(result).toEqual({ + status: 'imported', + callRecordingId: 'call-recording-1', + outcome: 'call-recording-artifacts-imported', + }); + }); + + it('resolves a missing recording id from the Recall bot inside the worker', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + statusChanges: [], + recordings: [ + { + id: 'recall-recording-9', + startedAt: undefined, + completedAt: undefined, + }, + ], + }, + }); + const client = buildClient([ + buildProcessingCallRecording({ externalRecordingId: null }), + ]); + + await importCallRecordingArtifacts({ + client: client as unknown as CoreApiClient, + request: { + callRecordingId: 'call-recording-1', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }); + + expect(getRecallBotMock).toHaveBeenCalledWith({ + externalBotId: 'recall-bot-1', + }); + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-9', + }); + expect(client.mutations[0]).toEqual( + expect.objectContaining({ + id: 'call-recording-1', + data: expect.objectContaining({ + externalRecordingId: 'recall-recording-9', + }), + }), + ); + }); + + it('keeps a terminal webhook retryable when Recall has not exposed the recording id yet', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + statusChanges: [], + recordings: [], + }, + }); + const client = buildClient([ + buildProcessingCallRecording({ externalRecordingId: null }), + ]); + + const result = await importCallRecordingArtifacts({ + client: client as unknown as CoreApiClient, + request: { + callRecordingId: 'call-recording-1', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }); + + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(importCallRecordingMediaMock).not.toHaveBeenCalled(); + expect(client.mutations).toEqual([]); + expect(result).toEqual({ + status: 'skipped', + callRecordingId: 'call-recording-1', + reason: 'no artifact updates', + }); + }); + + it('completes and charges when artifact reconciliation lands the final media files', async () => { + importCallRecordingMediaMock.mockResolvedValue({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + }); + const client = buildClient([ + buildProcessingCallRecording({ + transcript: [{ participant: { id: 1 }, words: [] }], + }), + ]); + + await importCallRecordingArtifacts({ + client: client as unknown as CoreApiClient, + request: { + callRecordingId: 'call-recording-1', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + }, + }, + { + id: 'call-recording-1', + data: { status: 'COMPLETED' }, + }, + ]); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:05:00.000Z', + }); + }); + + it('completes when all artifacts were already present before the continuation ran', async () => { + const client = buildClient([ + buildProcessingCallRecording({ + transcript: [{ participant: { id: 1 }, words: [] }], + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + }), + ]); + + await importCallRecordingArtifacts({ + client: client as unknown as CoreApiClient, + request: { + callRecordingId: 'call-recording-1', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { status: 'COMPLETED' }, + }, + ]); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:05:00.000Z', + }); + }); + + it('fills a transcript and completes once media is already imported', async () => { + const transcriptContent = [ + { + participant: { id: 1, name: 'Alice' }, + words: [{ text: 'hello', start_timestamp: { relative: 0.5 } }], + }, + ]; + + downloadTranscriptMock.mockResolvedValue({ + outcome: 'filled', + content: transcriptContent, + }); + const client = buildClient([ + buildProcessingCallRecording({ + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + }), + ]); + + const result = await importCallRecordingArtifacts({ + client: client as unknown as CoreApiClient, + request: { + callRecordingId: 'call-recording-1', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }); + + expect(downloadTranscriptMock).toHaveBeenCalledWith({ + transcriptId: 'recall-transcript-1', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { transcript: transcriptContent }, + }, + { + id: 'call-recording-1', + data: { status: 'COMPLETED' }, + }, + ]); + expect(result).toEqual({ + status: 'imported', + callRecordingId: 'call-recording-1', + outcome: 'call-recording-artifacts-imported', + }); + }); + + it('does not clobber a downloaded transcript with a late transcript.failed', async () => { + const client = buildClient([ + buildProcessingCallRecording({ + status: 'COMPLETED', + transcript: [{ participant: { id: 1 }, words: [] }], + }), + ]); + + const result = await importCallRecordingArtifacts({ + client: client as unknown as CoreApiClient, + request: { + callRecordingId: 'call-recording-1', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }); + + expect(result).toEqual({ + status: 'skipped', + callRecordingId: 'call-recording-1', + reason: 'no artifact updates', + }); + expect(client.mutations).toEqual([]); + }); + + it('writes a failed transcript marker from the listed transcript on transcript.failed', async () => { + listRecallTranscriptsMock.mockResolvedValue({ + ok: true, + transcripts: [ + { + id: 'recall-transcript-1', + statusCode: 'failed', + statusSubCode: 'transcription_failed', + }, + ], + }); + const client = buildClient([ + buildProcessingCallRecording({ + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }), + ]); + + const result = await importCallRecordingArtifacts({ + client: client as unknown as CoreApiClient, + request: { + callRecordingId: 'call-recording-1', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'FAILED', + subCode: 'transcription_failed', + }, + callRecorderFailureReason: 'transcript_failed:transcription_failed', + status: 'FAILED', + }, + }, + ]); + expect(result).toEqual({ + status: 'imported', + callRecordingId: 'call-recording-1', + outcome: 'call-recording-artifacts-imported', + }); + }); + + it('skips provider work when another worker holds the import lease', async () => { + claimArtifactsImportMock.mockResolvedValue(false); + const client = buildClient([buildProcessingCallRecording()]); + + const result = await importCallRecordingArtifacts({ + client: client as unknown as CoreApiClient, + request: { + callRecordingId: 'call-recording-1', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }); + + expect(claimArtifactsImportMock).toHaveBeenCalledWith(expect.anything(), { + callRecordingId: 'call-recording-1', + now: expect.any(Date), + }); + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(importCallRecordingMediaMock).not.toHaveBeenCalled(); + expect(releaseArtifactsImportClaimMock).not.toHaveBeenCalled(); + expect(client.mutations).toEqual([]); + expect(result).toEqual({ + status: 'skipped', + callRecordingId: 'call-recording-1', + reason: 'artifact import already in progress', + }); + }); + + it('releases the import lease after doing provider work', async () => { + const client = buildClient([buildProcessingCallRecording()]); + + await importCallRecordingArtifacts({ + client: client as unknown as CoreApiClient, + request: { + callRecordingId: 'call-recording-1', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }); + + expect(releaseArtifactsImportClaimMock).toHaveBeenCalledWith( + expect.anything(), + { callRecordingId: 'call-recording-1' }, + ); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/retry-failed-recall-cancellations.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/retry-failed-recall-cancellations.test.ts new file mode 100644 index 0000000000..4eb0ad7edf --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/retry-failed-recall-cancellations.test.ts @@ -0,0 +1,522 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { retryFailedRecallCancellations } from 'src/logic-functions/flows/retry-failed-recall-cancellations.util'; + +const BASE_URL = 'https://us-west-2.recall.ai/api/v1'; +const NOW = new Date('2026-01-01T12:00:00.000Z'); +const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000'; + +const buildAccessToken = (payload: Record): string => + [ + Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'), + Buffer.from(JSON.stringify(payload)).toString('base64url'), + 'signature', + ].join('.'); + +type CallRecordingNode = { + id: string; + recordingRequestStatus?: string | null; + status?: string | null; + createdAt?: string | null; + updatedAt?: string | null; + calendarEventId?: string | null; + externalBotId?: string | null; +}; + +type CalendarEventNode = { + id: string; + startsAt?: string | null; + endsAt?: string | null; +}; + +class FakeCoreApiClient { + callRecordings: CallRecordingNode[]; + callRecordingQueryResponses: CallRecordingNode[][] = []; + calendarEvents: CalendarEventNode[]; + filters: Array> = []; + conditionalMutationFilters: Array> = []; + mutations: Array<{ id: string; data: Record }> = []; + + constructor( + callRecordings: CallRecordingNode[], + calendarEvents: CalendarEventNode[] = [], + ) { + this.callRecordings = callRecordings; + this.calendarEvents = calendarEvents; + } + + async query(query: any): Promise { + if (query.callRecordings !== undefined) { + this.filters.push(query.callRecordings.__args.filter); + + return { + callRecordings: buildConnection( + this.callRecordingQueryResponses.shift() ?? this.callRecordings, + ), + }; + } + + if (query.calendarEvents !== undefined) { + const calendarEventIds = query.calendarEvents.__args.filter.id.in; + + return { + calendarEvents: buildConnection( + this.calendarEvents.filter((calendarEvent) => + calendarEventIds.includes(calendarEvent.id), + ), + ), + }; + } + + throw new Error(`Unhandled query: ${JSON.stringify(query)}`); + } + + async mutation(mutation: any): Promise { + if (mutation.updateCallRecordings !== undefined) { + const { filter, data } = mutation.updateCallRecordings.__args; + + this.conditionalMutationFilters.push(filter); + + const matchingCallRecordings = this.callRecordings.filter( + (callRecording) => + callRecording.id === filter.id.eq && + callRecording.recordingRequestStatus === + filter.recordingRequestStatus.eq && + filter.status.in.includes(callRecording.status) && + (filter.externalBotId.is === 'NULL' + ? callRecording.externalBotId === null + : callRecording.externalBotId === filter.externalBotId.eq), + ); + + matchingCallRecordings.forEach((callRecording) => { + this.mutations.push({ id: callRecording.id, data }); + Object.assign(callRecording, data); + }); + + return { + updateCallRecordings: matchingCallRecordings.map(({ id }) => ({ id })), + }; + } + + const { id, data } = mutation.updateCallRecording.__args; + + this.mutations.push({ id, data }); + const callRecording = this.callRecordings.find( + (candidateCallRecording) => candidateCallRecording.id === id, + ); + + if (callRecording !== undefined) { + Object.assign(callRecording, data); + } + + return { updateCallRecording: { id } }; + } +} + +const buildConnection = (nodes: Node[]) => ({ + pageInfo: { hasNextPage: false, endCursor: undefined }, + edges: nodes.map((node) => ({ node })), +}); + +const fetchMock = vi.fn(); + +const buildJsonResponse = (status: number) => ({ + ok: status < 400, + status, + json: async () => ({}), +}); + +describe('retryFailedRecallCancellations', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('RECALL_API_KEY', 'recall-api-key'); + vi.stubEnv('RECALL_REGION', 'us-west-2'); + vi.stubEnv( + 'TWENTY_APP_ACCESS_TOKEN', + buildAccessToken({ workspaceId: WORKSPACE_ID }), + ); + fetchMock.mockReset(); + fetchMock.mockImplementation(async () => buildJsonResponse(204)); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('queries canceled non-terminal recordings including rows missing a bot id', async () => { + const client = new FakeCoreApiClient([]); + + await retryFailedRecallCancellations({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(client.filters).toEqual([ + expect.objectContaining({ + recordingRequestStatus: { eq: 'CANCELED' }, + status: { + in: ['SCHEDULED', 'JOINING', 'RECORDING', 'PROCESSING'], + }, + }), + ]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('cancels the bot and clears the id when the Recall cancel succeeds', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + recordingRequestStatus: 'CANCELED', + status: 'SCHEDULED', + externalBotId: 'recall-bot-1', + }, + ]); + + const result = await retryFailedRecallCancellations({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/bot/recall-bot-1/`, + expect.objectContaining({ method: 'DELETE' }), + ); + expect(client.mutations).toEqual([ + { id: 'call-recording-1', data: { externalBotId: null } }, + ]); + expect(result.canceledExternalBotCallRecordingIds).toEqual([ + 'call-recording-1', + ]); + }); + + it('keeps the bot id when the Recall cancel fails so the next run retries', async () => { + fetchMock.mockImplementation(async () => buildJsonResponse(400)); + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + recordingRequestStatus: 'CANCELED', + status: 'SCHEDULED', + externalBotId: 'recall-bot-1', + }, + ]); + + const result = await retryFailedRecallCancellations({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/bot/recall-bot-1/leave_call/`, + expect.objectContaining({ method: 'POST' }), + ); + expect(client.mutations).toEqual([]); + expect(result.canceledExternalBotCallRecordingIds).toEqual([]); + }); + + it('does not cancel a bot after its request was reactivated', async () => { + const canceledCallRecording = { + id: 'call-recording-1', + recordingRequestStatus: 'CANCELED', + status: 'SCHEDULED', + externalBotId: 'recall-bot-1', + }; + const client = new FakeCoreApiClient([canceledCallRecording]); + client.callRecordingQueryResponses = [ + [canceledCallRecording], + [ + { + ...canceledCallRecording, + recordingRequestStatus: 'REQUESTED', + }, + ], + ]; + + await retryFailedRecallCancellations({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('recovers and cancels a provider bot when endsAt is missing after the meeting starts', async () => { + const client = new FakeCoreApiClient( + [ + { + id: 'call-recording-1', + recordingRequestStatus: 'CANCELED', + status: 'SCHEDULED', + calendarEventId: 'calendar-event-1', + externalBotId: null, + }, + ], + [ + { + id: 'calendar-event-1', + startsAt: '2026-01-01T11:00:00.000Z', + endsAt: null, + }, + ], + ); + fetchMock.mockImplementation( + async (requestUrl: string, requestInit?: { method?: string }) => { + if (requestInit?.method === 'DELETE') { + return buildJsonResponse(204); + } + + if (requestUrl.startsWith(`${BASE_URL}/bot/?`)) { + return { + ...buildJsonResponse(200), + json: async () => ({ + next: null, + results: [ + { + id: 'recall-bot-recovered', + metadata: { + twentyWorkspaceId: WORKSPACE_ID, + twentyCallRecordingId: 'call-recording-1', + }, + }, + ], + }), + }; + } + + throw new Error(`Unhandled fetch: ${requestUrl}`); + }, + ); + + const result = await retryFailedRecallCancellations({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + const listRequestUrl = fetchMock.mock.calls.find( + ([requestUrl]) => + typeof requestUrl === 'string' && requestUrl.includes('/bot/?'), + )?.[0]; + const listRequestParameters = new URL(listRequestUrl).searchParams; + + expect(listRequestParameters.get('metadata__twentyWorkspaceId')).toBe( + WORKSPACE_ID, + ); + expect(listRequestParameters.get('metadata__twentyCallRecordingId')).toBe( + 'call-recording-1', + ); + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/bot/recall-bot-recovered/`, + expect.objectContaining({ method: 'DELETE' }), + ); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { externalBotId: 'recall-bot-recovered' }, + }, + { id: 'call-recording-1', data: { externalBotId: null } }, + ]); + expect(result.canceledExternalBotCallRecordingIds).toEqual([ + 'call-recording-1', + ]); + }); + + it('persists a metadata-recovered bot id when cancellation fails after its calendar event was deleted', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + recordingRequestStatus: 'CANCELED', + status: 'SCHEDULED', + calendarEventId: 'deleted-calendar-event', + externalBotId: null, + }, + ]); + fetchMock + .mockResolvedValueOnce({ + ...buildJsonResponse(200), + json: async () => ({ + next: null, + results: [{ id: 'recall-bot-recovered' }], + }), + }) + .mockResolvedValueOnce(buildJsonResponse(400)) + .mockResolvedValueOnce(buildJsonResponse(400)); + + const result = await retryFailedRecallCancellations({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + const listRequestUrl = fetchMock.mock.calls[0][0]; + const listRequestParameters = new URL(listRequestUrl).searchParams; + + expect(listRequestParameters.has('join_at_after')).toBe(false); + expect(listRequestParameters.has('join_at_before')).toBe(false); + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/bot/recall-bot-recovered/leave_call/`, + expect.objectContaining({ method: 'POST' }), + ); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { externalBotId: 'recall-bot-recovered' }, + }, + ]); + expect(client.callRecordings[0].externalBotId).toBe( + 'recall-bot-recovered', + ); + expect(client.conditionalMutationFilters).toEqual([ + expect.objectContaining({ + recordingRequestStatus: { eq: 'CANCELED' }, + status: { in: ['SCHEDULED', 'JOINING', 'RECORDING', 'PROCESSING'] }, + externalBotId: { is: 'NULL' }, + }), + ]); + expect(result.canceledExternalBotCallRecordingIds).toEqual([]); + }); + + it('stops looking up a botless cancellation without a calendar event once it ages past the recovery window', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + recordingRequestStatus: 'CANCELED', + status: 'SCHEDULED', + createdAt: '2026-01-01T11:00:00.000Z', + calendarEventId: null, + externalBotId: null, + }, + ]); + + const result = await retryFailedRecallCancellations({ + client: client as unknown as CoreApiClient, + now: new Date('2026-01-02T12:00:00.000Z'), + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(client.mutations).toEqual([]); + expect(result.canceledExternalBotCallRecordingIds).toEqual([]); + }); + + it('recovers a long-scheduled cancellation whose recent cancellation is within the recovery window', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + recordingRequestStatus: 'CANCELED', + status: 'SCHEDULED', + createdAt: '2026-01-01T11:00:00.000Z', + updatedAt: '2026-01-02T11:30:00.000Z', + calendarEventId: null, + externalBotId: null, + }, + ]); + fetchMock.mockImplementation( + async (requestUrl: string, requestInit?: { method?: string }) => { + if (requestInit?.method === 'DELETE') { + return buildJsonResponse(204); + } + + if (requestUrl.startsWith(`${BASE_URL}/bot/?`)) { + return { + ...buildJsonResponse(200), + json: async () => ({ + next: null, + results: [{ id: 'recall-bot-recovered' }], + }), + }; + } + + throw new Error(`Unhandled fetch: ${requestUrl}`); + }, + ); + + const result = await retryFailedRecallCancellations({ + client: client as unknown as CoreApiClient, + now: new Date('2026-01-02T12:00:00.000Z'), + }); + + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/bot/recall-bot-recovered/`, + expect.objectContaining({ method: 'DELETE' }), + ); + expect(result.canceledExternalBotCallRecordingIds).toEqual([ + 'call-recording-1', + ]); + }); + + it('still recovers a recently created botless cancellation without a calendar event', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + recordingRequestStatus: 'CANCELED', + status: 'SCHEDULED', + createdAt: '2026-01-01T11:00:00.000Z', + calendarEventId: null, + externalBotId: null, + }, + ]); + fetchMock.mockImplementation( + async (requestUrl: string, requestInit?: { method?: string }) => { + if (requestInit?.method === 'DELETE') { + return buildJsonResponse(204); + } + + if (requestUrl.startsWith(`${BASE_URL}/bot/?`)) { + return { + ...buildJsonResponse(200), + json: async () => ({ + next: null, + results: [{ id: 'recall-bot-recovered' }], + }), + }; + } + + throw new Error(`Unhandled fetch: ${requestUrl}`); + }, + ); + + const result = await retryFailedRecallCancellations({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/bot/recall-bot-recovered/`, + expect.objectContaining({ method: 'DELETE' }), + ); + expect(result.canceledExternalBotCallRecordingIds).toEqual([ + 'call-recording-1', + ]); + }); + + it('does not repeatedly look up botless cancellations after their meeting ended', async () => { + const client = new FakeCoreApiClient( + [ + { + id: 'call-recording-1', + recordingRequestStatus: 'CANCELED', + status: 'SCHEDULED', + calendarEventId: 'calendar-event-1', + externalBotId: null, + }, + ], + [ + { + id: 'calendar-event-1', + startsAt: '2026-01-01T10:00:00.000Z', + endsAt: '2026-01-01T11:00:00.000Z', + }, + ], + ); + + const result = await retryFailedRecallCancellations({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(client.mutations).toEqual([]); + expect(result.canceledExternalBotCallRecordingIds).toEqual([]); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/schedule-recall-bots-for-pending-call-recordings.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/schedule-recall-bots-for-pending-call-recordings.test.ts index e9ad60da27..0d04bd72df 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/schedule-recall-bots-for-pending-call-recordings.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/schedule-recall-bots-for-pending-call-recordings.test.ts @@ -9,7 +9,9 @@ const UPCOMING_STARTS_AT = '2026-01-01T13:00:00.000Z'; const UPCOMING_ENDS_AT = '2026-01-01T14:00:00.000Z'; const PAST_STARTS_AT = '2026-01-01T10:00:00.000Z'; const PAST_ENDS_AT = '2026-01-01T11:00:00.000Z'; -const RECALL_CREATE_BOT_URL = 'https://us-west-2.recall.ai/api/v1/bot/'; +const RECALL_BASE_URL = 'https://us-west-2.recall.ai/api/v1'; +const RECALL_CREATE_BOT_URL = `${RECALL_BASE_URL}/bot/`; +const RECALL_LIST_BOTS_URL_PREFIX = `${RECALL_BASE_URL}/bot/?`; const buildAccessToken = (payload: Record): string => [ @@ -129,6 +131,54 @@ const buildCalendarEvent = ( ...overrides, }); +const stubRecallApi = ({ + listedBots = [], + listStatus = 200, + createBotStatus = 201, +}: { + listedBots?: unknown[]; + listStatus?: number; + createBotStatus?: number; +} = {}) => { + fetchMock.mockImplementation( + async (requestUrl: string, requestInit?: { method?: string }) => { + const method = requestInit?.method ?? 'GET'; + + if ( + method === 'GET' && + requestUrl.startsWith(RECALL_LIST_BOTS_URL_PREFIX) + ) { + return new Response( + JSON.stringify({ next: null, results: listedBots }), + { status: listStatus }, + ); + } + + if (method === 'POST' && requestUrl === RECALL_CREATE_BOT_URL) { + return new Response(JSON.stringify({ id: 'recall-bot-1' }), { + status: createBotStatus, + }); + } + + throw new Error(`Unhandled fetch in test: ${method} ${requestUrl}`); + }, + ); +}; + +const listBotRequestUrls = (): string[] => + fetchMock.mock.calls + .filter( + ([requestUrl, requestInit]) => + (requestInit?.method ?? 'GET') === 'GET' && + requestUrl.startsWith(RECALL_LIST_BOTS_URL_PREFIX), + ) + .map(([requestUrl]) => requestUrl); + +const createBotCalls = () => + fetchMock.mock.calls.filter( + ([, requestInit]) => requestInit?.method === 'POST', + ); + describe('scheduleRecallBotsForPendingCallRecordings', () => { beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -141,10 +191,7 @@ describe('scheduleRecallBotsForPendingCallRecordings', () => { buildAccessToken({ workspaceId: WORKSPACE_ID }), ); fetchMock.mockReset(); - fetchMock.mockImplementation( - async () => - new Response(JSON.stringify({ id: 'recall-bot-1' }), { status: 201 }), - ); + stubRecallApi(); }); afterEach(() => { @@ -166,10 +213,10 @@ describe('scheduleRecallBotsForPendingCallRecordings', () => { }); expect(result.scheduledCallRecordingIds).toEqual(['call-recording-1']); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [requestUrl, requestInit] = fetchMock.mock.calls[0]; + expect(result.attachedCallRecordingIds).toEqual([]); + expect(createBotCalls()).toHaveLength(1); + const [requestUrl, requestInit] = createBotCalls()[0]; expect(requestUrl).toBe(RECALL_CREATE_BOT_URL); - expect(requestInit.method).toBe('POST'); expect(requestInit.headers).toMatchObject({ Authorization: 'Token recall-api-key', }); @@ -184,11 +231,72 @@ describe('scheduleRecallBotsForPendingCallRecordings', () => { expect(client.callRecordings[0].externalBotId).toBe('recall-bot-1'); }); - it('does not report a recording as scheduled when Recall scheduling fails', async () => { - fetchMock.mockImplementation( - async () => - new Response(JSON.stringify({ error: 'boom' }), { status: 500 }), + it('attaches an existing bot claiming the recording instead of scheduling a duplicate', async () => { + stubRecallApi({ + listedBots: [ + { + id: 'recall-bot-existing', + metadata: { + twentyWorkspaceId: WORKSPACE_ID, + twentyCallRecordingId: 'call-recording-1', + }, + }, + ], + }); + const client = new FakeCoreApiClient({ + callRecordings: [buildPendingCallRecording()], + calendarEvents: [buildCalendarEvent()], + }); + + const result = await scheduleRecallBotsForPendingCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(result.attachedCallRecordingIds).toEqual(['call-recording-1']); + expect(result.scheduledCallRecordingIds).toEqual([]); + expect(createBotCalls()).toHaveLength(0); + const lookupParameters = new URL(listBotRequestUrls()[0]).searchParams; + expect(lookupParameters.get('metadata__twentyWorkspaceId')).toBe( + WORKSPACE_ID, ); + expect(lookupParameters.get('metadata__twentyCallRecordingId')).toBe( + 'call-recording-1', + ); + expect(lookupParameters.has('join_at_after')).toBe(false); + expect(lookupParameters.has('join_at_before')).toBe(false); + expect(lookupParameters.getAll('status')).toEqual([ + 'ready', + 'joining_call', + 'in_waiting_room', + 'in_call_not_recording', + 'recording_permission_allowed', + 'recording_permission_denied', + 'in_call_recording', + ]); + expect(client.callRecordings[0].externalBotId).toBe('recall-bot-existing'); + }); + + it('defers scheduling when the existing-bot lookup fails so no duplicate bot is created', async () => { + stubRecallApi({ listStatus: 400 }); + const client = new FakeCoreApiClient({ + callRecordings: [buildPendingCallRecording()], + calendarEvents: [buildCalendarEvent()], + }); + + const result = await scheduleRecallBotsForPendingCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(result.attachedCallRecordingIds).toEqual([]); + expect(result.scheduledCallRecordingIds).toEqual([]); + expect(createBotCalls()).toHaveLength(0); + expect(client.callRecordings[0].externalBotId).toBeNull(); + }); + + it('does not report a recording as scheduled when Recall scheduling fails', async () => { + stubRecallApi({ createBotStatus: 500 }); const client = new FakeCoreApiClient({ callRecordings: [buildPendingCallRecording()], calendarEvents: [buildCalendarEvent()], @@ -204,12 +312,7 @@ describe('scheduleRecallBotsForPendingCallRecordings', () => { expect(result.scheduledCallRecordingIds).toEqual([]); // One scheduling attempt, retried to exhaustion on the wire. - expect(fetchMock).toHaveBeenCalledTimes(3); - expect( - fetchMock.mock.calls.every( - ([requestUrl]) => requestUrl === RECALL_CREATE_BOT_URL, - ), - ).toBe(true); + expect(createBotCalls()).toHaveLength(3); expect(client.callRecordings[0].externalBotId).toBeNull(); }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/attach-existing-recall-bot-to-call-recording.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/attach-existing-recall-bot-to-call-recording.util.ts new file mode 100644 index 0000000000..4e628d956a --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/attach-existing-recall-bot-to-call-recording.util.ts @@ -0,0 +1,44 @@ +import { isUndefined } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type'; +import { findScheduledRecallBotIdForCallRecording } from 'src/logic-functions/recall-api/find-scheduled-recall-bot-id-for-call-recording.util'; +import { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.util'; +import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util'; + +export type AttachExistingRecallBotToCallRecordingResult = + | { status: 'attached'; externalBotId: string } + | { status: 'no-existing-bot' } + | { status: 'lookup-failed' }; + +// A run that POSTed a bot but died before the id write-back leaves the bot claimable by metadata; attaching it instead of re-POSTing prevents duplicate bots. +export const attachExistingRecallBotToCallRecording = async ( + client: CoreApiClient, + { callRecording }: { callRecording: CallRecordingRecord }, +): Promise => { + const workspaceId = getCurrentWorkspaceId(); + + if (isUndefined(workspaceId)) { + return { status: 'no-existing-bot' }; + } + + const findResult = await findScheduledRecallBotIdForCallRecording({ + callRecordingId: callRecording.id, + workspaceId, + }); + + if (!findResult.ok) { + return { status: 'lookup-failed' }; + } + + if (isUndefined(findResult.externalBotId)) { + return { status: 'no-existing-bot' }; + } + + await updateCallRecording(client, { + id: callRecording.id, + data: { externalBotId: findResult.externalBotId }, + }); + + return { status: 'attached', externalBotId: findResult.externalBotId }; +}; 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 ceebcc044c..7511e6209a 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 @@ -16,7 +16,7 @@ import { export type CleanupOrphanedRecallBotsResult = { scannedBotCount: number; canceledExternalBotIds: string[]; - truncatedScan: boolean; + truncatedBotList: boolean; }; // Bots no open CallRecording request claims would still join; cancel them on Recall. @@ -29,9 +29,25 @@ export const cleanupOrphanedRecallBots = async ({ joinAtAfter: string; joinAtBefore: string; }): Promise => { + const currentWorkspaceId = getCurrentWorkspaceId(); + + if (isUndefined(currentWorkspaceId)) { + console.warn( + '[call-recorder] cannot cancel orphaned Recall bots: workspace id unavailable', + ); + + return { + scannedBotCount: 0, + canceledExternalBotIds: [], + truncatedBotList: false, + }; + } + + // Server-side workspace filter: the shared Recall account holds every workspace's bots. const listResult = await listScheduledRecallBots({ joinAtAfter, joinAtBefore, + metadata: { twentyWorkspaceId: currentWorkspaceId }, }); if (!listResult.ok) { @@ -42,21 +58,7 @@ export const cleanupOrphanedRecallBots = async ({ return { scannedBotCount: 0, canceledExternalBotIds: [], - truncatedScan: false, - }; - } - - const currentWorkspaceId = getCurrentWorkspaceId(); - - if (isUndefined(currentWorkspaceId)) { - console.warn( - '[call-recorder] cannot cancel orphaned Recall bots: workspace id unavailable', - ); - - return { - scannedBotCount: listResult.bots.length, - canceledExternalBotIds: [], - truncatedScan: listResult.truncated, + truncatedBotList: false, }; } @@ -68,7 +70,7 @@ export const cleanupOrphanedRecallBots = async ({ return { scannedBotCount: listResult.bots.length, canceledExternalBotIds: [], - truncatedScan: listResult.truncated, + truncatedBotList: listResult.truncated, }; } @@ -105,7 +107,7 @@ export const cleanupOrphanedRecallBots = async ({ return { scannedBotCount: listResult.bots.length, canceledExternalBotIds, - truncatedScan: listResult.truncated, + truncatedBotList: listResult.truncated, }; }; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/converge-diverged-call-recordings.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/converge-diverged-call-recordings.util.ts index 37856aed26..1e4c896c7f 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/converge-diverged-call-recordings.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/converge-diverged-call-recordings.util.ts @@ -1,4 +1,4 @@ -import { isNonEmptyArray, 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'; @@ -6,39 +6,34 @@ import { CallRecordingStatus } from 'src/logic-functions/constants/call-recordin import { NON_TERMINAL_CALL_RECORDING_STATUSES } from 'src/logic-functions/constants/non-terminal-call-recording-statuses'; import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size'; import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type'; -import { - extractRecallBotSyncState, - type RecallBotSyncState, -} from 'src/logic-functions/recall-api/extract-recall-bot-sync-state.util'; import { fetchAllNodes, type ConnectionPage, } from 'src/logic-functions/data/fetch-all-nodes.util'; import { getRecallBot } from 'src/logic-functions/recall-api/get-recall-bot.util'; -import { importCallRecordingMedia } from 'src/logic-functions/flows/import-call-recording-media.util'; +import { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.util'; +import { + listScheduledRecallBots, + type RecallScheduledBot, +} from 'src/logic-functions/recall-api/list-scheduled-recall-bots.util'; +import { type RecallBotSnapshot } from 'src/logic-functions/recall-api/recall-bot-snapshot.type'; import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util'; import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; -import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util'; -import { persistCallRecordingProgress } from 'src/logic-functions/flows/persist-call-recording-progress.util'; -import { importCallRecordingTranscript } from 'src/logic-functions/flows/import-call-recording-transcript.util'; import { type ConvergeDivergedCallRecordingsResult } from 'src/logic-functions/flows/converge-diverged-call-recordings-result.type'; -import { shouldCompleteCallRecordingImport } from 'src/logic-functions/domain/should-complete-call-recording-import.util'; +import { + syncCallRecording, + type SyncableCallRecording, +} from 'src/logic-functions/flows/sync-call-recording.util'; import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util'; -import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type'; const CONVERGENCE_LOOKBACK_DAYS = 7; +const CONVERGENCE_BOT_LIST_LOOKBACK_DAYS = CONVERGENCE_LOOKBACK_DAYS + 1; +const CONVERGENCE_BOT_LIST_LOOKAHEAD_MILLISECONDS = 60 * 60 * 1000; +const PER_RECORDING_FALLBACK_LIMIT = 25; +const FALLBACK_ROTATION_INTERVAL_MILLISECONDS = 15 * 60 * 1000; -type DivergedCallRecordingCandidate = { - id: string; - status: string | undefined; - startedAt: string | undefined; - endedAt: string | undefined; +type DivergedCallRecordingCandidate = SyncableCallRecording & { externalBotId: string | undefined; - externalRecordingId: string | undefined; - callRecorderFailureReason: string | undefined; - transcript: unknown; - audio: FilesFieldValue | undefined; - video: FilesFieldValue | undefined; createdAt: string | undefined; calendarEventStartsAt: string | undefined; calendarEventEndsAt: string | undefined; @@ -79,6 +74,9 @@ export const convergeDivergedCallRecordings = async ({ unconvergeableCallRecordingIds: [], skippedNotStartedCallRecordingIds: [], }; + const actionableCandidates: Array< + DivergedCallRecordingCandidate & { externalBotId: string } + > = []; for (const candidate of candidates) { if (isOutsideConvergenceBound(candidate, convergenceLowerBound)) { @@ -102,10 +100,61 @@ export const convergeDivergedCallRecordings = async ({ continue; } + actionableCandidates.push({ + ...candidate, + externalBotId: candidate.externalBotId, + }); + } + + if (actionableCandidates.length === 0) { + return result; + } + + const listedRecallBotsById = await listRecallBotsByIdForConvergence(now); + + // A failed list means Recall is degraded; avoid fanning out per-recording reads while the provider asks for less load. + if (isUndefined(listedRecallBotsById)) { + return result; + } + + // Only unlisted candidates spend the per-recording fallback budget, so rotate + // them across runs and keep already-listed candidates (which converge for free) last. + const orderedActionableCandidates = [ + ...rotateActionableCandidatesForFallback({ + candidates: actionableCandidates.filter( + (candidate) => !listedRecallBotsById.has(candidate.externalBotId), + ), + now, + }), + ...actionableCandidates.filter((candidate) => + listedRecallBotsById.has(candidate.externalBotId), + ), + ]; + let remainingPerRecordingFallbackCount = PER_RECORDING_FALLBACK_LIMIT; + + for (const candidate of orderedActionableCandidates) { + const listedBot = listedRecallBotsById.get(candidate.externalBotId); + + if ( + isUndefined(listedBot) && + remainingPerRecordingFallbackCount === 0 + ) { + console.warn( + `[call-recorder] skipping Recall bot ${candidate.externalBotId} for call recording ${candidate.id}: per-recording convergence fallback budget exhausted`, + ); + + continue; + } + + if (isUndefined(listedBot)) { + remainingPerRecordingFallbackCount -= 1; + } + await convergeCallRecording({ client, candidate, externalBotId: candidate.externalBotId, + listedBot, now, result, }); @@ -201,7 +250,11 @@ const isOutsideConvergenceBound = ( convergenceLowerBound: Date, ): boolean => { const meetingEndReference = - candidate.calendarEventEndsAt ?? candidate.createdAt; + candidate.calendarEventEndsAt ?? + candidate.endedAt ?? + candidate.calendarEventStartsAt ?? + candidate.startedAt ?? + candidate.createdAt; return ( !isUndefined(meetingEndReference) && @@ -221,16 +274,20 @@ const convergeCallRecording = async ({ client, candidate, externalBotId, + listedBot, now, result, }: { client: CoreApiClient; candidate: DivergedCallRecordingCandidate; externalBotId: string; + listedBot: RecallBotSnapshot | undefined; now: Date; result: ConvergeDivergedCallRecordingsResult; }): Promise => { - const botResult = await getRecallBot({ externalBotId }); + const botResult = isUndefined(listedBot) + ? await getRecallBot({ externalBotId }) + : ({ ok: true, bot: listedBot } as const); if (!botResult.ok) { if (botResult.status === 404) { @@ -251,171 +308,103 @@ const convergeCallRecording = async ({ return; } - const convergence = extractRecallBotSyncState(botResult.bot); - const updateData = buildConvergenceFieldUpdates({ candidate, convergence }); - - const externalRecordingId = - candidate.externalRecordingId ?? convergence.externalRecordingId; - - if (convergence.isRecallRecordingDone && !isUndefined(externalRecordingId)) { - const transcriptArtifactResult = - await importCallRecordingTranscript({ - callRecordingId: candidate.id, - currentStatus: candidate.status, - externalRecordingId, - requestedAt: now.toISOString(), - transcript: candidate.transcript, - }); - - Object.assign(updateData, transcriptArtifactResult.updateData); - - if (transcriptArtifactResult.requestedTranscript) { - result.requestedTranscriptCallRecordingIds.push(candidate.id); - } - - const mediaIngestionUpdate = await importCallRecordingMedia({ - callRecordingId: candidate.id, - externalRecordingId, - hasAudio: isNonEmptyArray(candidate.audio), - hasVideo: isNonEmptyArray(candidate.video), - }); - - if (updateData.status === CallRecordingStatus.FAILED) { - delete mediaIngestionUpdate.callRecorderFailureReason; - } - - Object.assign(updateData, mediaIngestionUpdate); - } - - const terminalArtifactGateFailureUpdate = - buildTerminalArtifactGateFailureUpdate({ - candidate, - convergence, - externalRecordingId, - updateData, - }); - - if (!isUndefined(terminalArtifactGateFailureUpdate)) { - Object.assign(updateData, terminalArtifactGateFailureUpdate); - } - - const completesImport = shouldCompleteCallRecordingImport({ - current: candidate, - updateData, + const syncResult = await syncCallRecording({ + client, + callRecording: candidate, + bot: botResult.bot, + treatRecordingAsDone: false, + requestedAt: now.toISOString(), }); - if (Object.keys(updateData).length === 0 && !completesImport) { - return; + if (syncResult.updated) { + result.updatedCallRecordingIds.push(candidate.id); } - await persistCallRecordingProgress(client, { - id: candidate.id, - current: candidate, - updateData, + if (syncResult.requestedTranscript) { + result.requestedTranscriptCallRecordingIds.push(candidate.id); + } +}; + +const rotateActionableCandidatesForFallback = < + Candidate extends { externalBotId: string }, +>({ + candidates, + now, +}: { + candidates: Candidate[]; + now: Date; +}): Candidate[] => { + if (candidates.length <= PER_RECORDING_FALLBACK_LIMIT) { + return candidates; + } + + const completedRotationIntervalCount = Math.floor( + now.getTime() / FALLBACK_ROTATION_INTERVAL_MILLISECONDS, + ); + const rotationOffset = + (completedRotationIntervalCount * PER_RECORDING_FALLBACK_LIMIT) % + candidates.length; + + return [ + ...candidates.slice(rotationOffset), + ...candidates.slice(0, rotationOffset), + ]; +}; + +const listRecallBotsByIdForConvergence = async ( + now: Date, +): Promise | undefined> => { + const currentWorkspaceId = getCurrentWorkspaceId(); + + if (isUndefined(currentWorkspaceId)) { + console.warn( + '[call-recorder] workspace id unavailable for Recall bot list fetch; using capped per-recording convergence fallback', + ); + + return new Map(); + } + + const listResult = await listScheduledRecallBots({ + joinAtAfter: new Date( + now.getTime() - + CONVERGENCE_BOT_LIST_LOOKBACK_DAYS * 24 * 60 * 60 * 1000, + ).toISOString(), + joinAtBefore: new Date( + now.getTime() + CONVERGENCE_BOT_LIST_LOOKAHEAD_MILLISECONDS, + ).toISOString(), + metadata: { twentyWorkspaceId: currentWorkspaceId }, }); - result.updatedCallRecordingIds.push(candidate.id); -}; + if (!listResult.ok) { + console.warn( + `[call-recorder] Recall bot list fetch failed; deferring stale recording convergence to the next run: ${listResult.errorMessage}`, + ); -// Pure merge: fill only unset candidate fields and never downgrade status. -const buildConvergenceFieldUpdates = ({ - candidate, - convergence, -}: { - candidate: DivergedCallRecordingCandidate; - convergence: RecallBotSyncState; -}): CallRecordingUpdateFields => { - const updateData: CallRecordingUpdateFields = {}; - - if ( - !isUndefined(convergence.status) && - convergence.status !== candidate.status && - !isCallRecordingStatusDowngrade({ - fromStatus: candidate.status, - toStatus: convergence.status, - }) - ) { - updateData.status = convergence.status; - - if (convergence.status === CallRecordingStatus.FAILED) { - updateData.callRecorderFailureReason = - convergence.failureReason ?? 'recall_bot_failed'; - } - } - - if (isUndefined(candidate.startedAt) && !isUndefined(convergence.startedAt)) { - updateData.startedAt = convergence.startedAt; - } - - if (isUndefined(candidate.endedAt) && !isUndefined(convergence.endedAt)) { - updateData.endedAt = convergence.endedAt; - } - - if ( - isUndefined(candidate.externalRecordingId) && - !isUndefined(convergence.externalRecordingId) - ) { - updateData.externalRecordingId = convergence.externalRecordingId; - } - - return updateData; -}; - -type TerminalArtifactGateFailureUpdate = { - status: CallRecordingStatus.FAILED; - callRecorderFailureReason: string; -}; - -const buildTerminalArtifactGateFailureUpdate = ({ - candidate, - convergence, - externalRecordingId, - updateData, -}: { - candidate: DivergedCallRecordingCandidate; - convergence: RecallBotSyncState; - externalRecordingId: string | undefined; - updateData: CallRecordingUpdateFields; -}): TerminalArtifactGateFailureUpdate | undefined => { - if ( - candidate.status === CallRecordingStatus.COMPLETED || - updateData.status === CallRecordingStatus.FAILED || - !convergence.isRecallRecordingDone || - !isUndefined(externalRecordingId) || - hasRecordingArtifactPath({ candidate, updateData }) - ) { return undefined; } - return { - status: CallRecordingStatus.FAILED, - callRecorderFailureReason: - convergence.failureReason ?? 'recording_artifacts_unavailable', - }; -}; - -const hasRecordingArtifactPath = ({ - candidate, - updateData, -}: { - candidate: DivergedCallRecordingCandidate; - updateData: CallRecordingUpdateFields; -}): boolean => { - return ( - isNonEmptyArray(updateData.audio ?? candidate.audio) || - isNonEmptyArray(updateData.video ?? candidate.video) || - hasReachableTranscript(updateData.transcript ?? candidate.transcript) + return new Map( + listResult.bots + .filter((bot) => + isCurrentWorkspaceManagedBot({ bot, currentWorkspaceId }), + ) + .map((bot) => [bot.id, bot]), ); }; -const hasReachableTranscript = (transcript: unknown): boolean => { - if (isUndefined(transcript)) { - return false; - } +const isCurrentWorkspaceManagedBot = ({ + bot, + currentWorkspaceId, +}: { + bot: RecallScheduledBot; + currentWorkspaceId: string; +}): boolean => { + const claimedWorkspaceId = bot.metadata.twentyWorkspaceId; - const marker = parseTranscriptMarker(transcript); - - return isUndefined(marker) || marker.status === 'PENDING'; + return ( + isNonEmptyString(claimedWorkspaceId) && + claimedWorkspaceId.trim() === currentWorkspaceId + ); }; const markCallRecordingFailedAfterBotLoss = async ({ diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/handle-recall-webhook.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/handle-recall-webhook.util.ts index 347fb2311d..e840b1130d 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/handle-recall-webhook.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/handle-recall-webhook.util.ts @@ -1,15 +1,9 @@ -import { isNonEmptyArray, isNull, isUndefined } from '@sniptt/guards'; +import { isUndefined } from '@sniptt/guards'; import { type CoreApiClient } from 'twenty-client-sdk/core'; import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; -import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type'; -import { buildFailedTranscriptMarker } from 'src/logic-functions/domain/build-failed-transcript-marker.util'; -import { buildTranscriptFailureReason } from 'src/logic-functions/domain/build-transcript-failure-reason.util'; -import { downloadTranscript } from 'src/logic-functions/flows/download-transcript.util'; -import { extractRecallBotSyncState } from 'src/logic-functions/recall-api/extract-recall-bot-sync-state.util'; -import { getRecallBot } from 'src/logic-functions/recall-api/get-recall-bot.util'; -import { getString } from 'src/logic-functions/utils/get-string.util'; -import { importCallRecordingMedia } from 'src/logic-functions/flows/import-call-recording-media.util'; +import { findCallRecordingsByFilter } from 'src/logic-functions/data/find-call-recordings-by-filter.util'; +import { requestCallRecordingArtifactsImport } from 'src/logic-functions/data/request-call-recording-artifacts-import.util'; import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util'; import { isRecallRecordingDoneSignal } from 'src/logic-functions/domain/is-recall-recording-done-signal.util'; import { mapRecallStatusCodeToCallRecordingStatus } from 'src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util'; @@ -18,28 +12,9 @@ import { type RecallWebhookBody, type RecallWebhookEvent, } from 'src/logic-functions/recall-api/parse-recall-webhook-event.util'; -import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util'; -import { persistCallRecordingProgress } from 'src/logic-functions/flows/persist-call-recording-progress.util'; -import { importCallRecordingTranscript } from 'src/logic-functions/flows/import-call-recording-transcript.util'; -import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util'; +import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type'; import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type'; - -type MatchedCallRecording = { - id: string; - status?: string; - startedAt?: string; - endedAt?: string; - externalRecordingId?: string; - callRecorderFailureReason?: string; - transcript?: unknown; - audio?: FilesFieldValue; - video?: FilesFieldValue; -}; - -type ExternalRecordingIdResolution = { - externalRecordingId: string | undefined; - providerLookupFailed: boolean; -}; +import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util'; type RecallWebhookHandlerResult = | { @@ -49,10 +24,9 @@ type RecallWebhookHandlerResult = callRecordingStatus: string; } | { - status: 'updated'; + status: 'queued'; callRecordingId: string; event: string; - transcriptOutcome: 'FILLED' | 'FAILED'; } | { status: 'skipped'; @@ -80,7 +54,7 @@ export const handleRecallWebhook = async ({ const { event } = webhookEvent; if (event === 'transcript.done' || event === 'transcript.failed') { - return handleRecallTranscriptEvent({ client, webhookEvent, event }); + return queueCallRecordingArtifactsImport({ client, webhookEvent }); } return handleRecallStatusEvent({ client, webhookEvent }); @@ -107,19 +81,6 @@ const handleRecallStatusEvent = async ({ }; } - const shouldLogTerminalDiagnostics = isRecallRecordingDoneSignal({ - event, - statusCode, - }); - - if (shouldLogTerminalDiagnostics) { - logRecallWebhookPhase({ - phase: 'match-start', - webhookEvent, - callRecordingStatus, - }); - } - const callRecording = await findMatchingCallRecording({ client, webhookEvent, @@ -158,85 +119,19 @@ const handleRecallStatusEvent = async ({ ...buildRecordingTimestampsUpdate({ webhookEvent, callRecording }), }; - if (shouldLogTerminalDiagnostics) { - logRecallWebhookPhase({ - phase: 'terminal-start', - webhookEvent, - callRecording, - callRecordingStatus, - }); - - const externalRecordingIdResolution = await resolveExternalRecordingId({ - callRecording, - webhookEvent, - }); - - logRecallWebhookPhase({ - phase: 'recording-id-resolved', - webhookEvent, - callRecording, - externalRecordingId: externalRecordingIdResolution.externalRecordingId, - providerLookupFailed: externalRecordingIdResolution.providerLookupFailed, - callRecordingStatus, - }); - - Object.assign( - updateData, - await buildTranscriptArtifactUpdate({ - callRecording, - externalRecordingId: externalRecordingIdResolution.externalRecordingId, - }), - ); - - logRecallWebhookPhase({ - phase: 'transcript-complete', - webhookEvent, - callRecording, - externalRecordingId: externalRecordingIdResolution.externalRecordingId, - updateData, - callRecordingStatus, - }); - - const mediaImportUpdate = await buildMediaImportUpdate({ - callRecording, - externalRecordingId: externalRecordingIdResolution.externalRecordingId, - }); - - if (updateData.status === CallRecordingStatus.FAILED) { - delete mediaImportUpdate.callRecorderFailureReason; - } - - Object.assign(updateData, mediaImportUpdate); - - const terminalArtifactGateFailureUpdate = - buildTerminalArtifactGateFailureUpdate({ - callRecording, - providerLookupFailed: - externalRecordingIdResolution.providerLookupFailed, - updateData, - webhookEvent, - }); - - if (!isUndefined(terminalArtifactGateFailureUpdate)) { - Object.assign(updateData, terminalArtifactGateFailureUpdate); - } - } - - const { completesImport } = await persistCallRecordingProgress(client, { + await updateCallRecording(client, { id: callRecording.id, - current: callRecording, - updateData, + data: updateData, }); - if (shouldLogTerminalDiagnostics) { - logRecallWebhookPhase({ - phase: 'terminal-complete', - webhookEvent, - callRecording, - updateData, - callRecordingStatus: completesImport - ? CallRecordingStatus.COMPLETED - : (updateData.status ?? callRecordingStatus), + if ( + isRecallRecordingDoneSignal({ + event, + statusCode, + }) + ) { + await requestCallRecordingArtifactsImportOrThrow({ + callRecordingId: callRecording.id, }); } @@ -244,140 +139,87 @@ const handleRecallStatusEvent = async ({ status: 'updated', event, callRecordingId: callRecording.id, - callRecordingStatus: completesImport - ? CallRecordingStatus.COMPLETED - : (updateData.status ?? callRecordingStatus), + callRecordingStatus: updateData.status ?? callRecordingStatus, }; }; -const logRecallWebhookPhase = ({ - phase, +const queueCallRecordingArtifactsImport = async ({ + client, webhookEvent, - callRecording, - callRecordingStatus, - externalRecordingId, - providerLookupFailed, - updateData, }: { - phase: string; + client: CoreApiClient; webhookEvent: RecallWebhookEvent; - callRecording?: MatchedCallRecording; - callRecordingStatus?: string; - externalRecordingId?: string; - providerLookupFailed?: boolean; - updateData?: CallRecordingUpdateFields; -}) => { - console.log( - [ - `[call-recorder] recall-webhook phase=${phase}`, - `event=${webhookEvent.event}`, - `statusCode=${webhookEvent.statusCode ?? 'n/a'}`, - `callRecordingId=${callRecording?.id ?? webhookEvent.callRecordingIdFromMetadata ?? 'n/a'}`, - `externalBotId=${webhookEvent.externalBotId ?? 'n/a'}`, - `externalRecordingId=${externalRecordingId ?? webhookEvent.externalRecordingId ?? callRecording?.externalRecordingId ?? 'n/a'}`, - `callRecordingStatus=${callRecordingStatus ?? 'n/a'}`, - `currentStatus=${callRecording?.status ?? 'n/a'}`, - `hasTranscript=${hasReachableTranscript(callRecording?.transcript)}`, - `hasAudio=${isNonEmptyArray(callRecording?.audio)}`, - `hasVideo=${isNonEmptyArray(callRecording?.video)}`, - `updates=${formatUpdateDataKeys(updateData)}`, - `providerLookupFailed=${providerLookupFailed ?? false}`, - formatMemoryUsageForLog(), - ].join(' '), - ); -}; +}): Promise => { + const callRecording = await findMatchingCallRecording({ + client, + webhookEvent, + }); -const formatUpdateDataKeys = ( - updateData: CallRecordingUpdateFields | undefined, -): string => { - if (isUndefined(updateData)) { - return 'none'; + if (isUndefined(callRecording)) { + console.warn( + `[call-recorder] skipping Recall ${webhookEvent.event} webhook: no matching call recording for bot ${webhookEvent.externalBotId ?? 'unknown'}`, + ); + + return { + status: 'skipped', + event: webhookEvent.event, + reason: 'no matching call recording', + }; } - const updateDataKeys = Object.keys(updateData); + await requestCallRecordingArtifactsImportOrThrow({ + callRecordingId: callRecording.id, + }); - return updateDataKeys.length === 0 ? 'none' : updateDataKeys.join(','); + return { + status: 'queued', + event: webhookEvent.event, + callRecordingId: callRecording.id, + }; }; -const formatMemoryUsageForLog = (): string => { - const memoryUsage = process.memoryUsage(); +// A throw bubbles to a non-2xx so Svix redelivers; the preceding status update re-applies idempotently. +const requestCallRecordingArtifactsImportOrThrow = async ({ + callRecordingId, +}: { + callRecordingId: string; +}): Promise => { + const importRequested = await requestCallRecordingArtifactsImport({ + callRecordingId, + requestedAt: new Date().toISOString(), + }); - return [ - `rssMegaBytes=${formatBytesAsMegaBytes(memoryUsage.rss)}`, - `heapUsedMegaBytes=${formatBytesAsMegaBytes(memoryUsage.heapUsed)}`, - `externalMegaBytes=${formatBytesAsMegaBytes(memoryUsage.external)}`, - `arrayBuffersMegaBytes=${formatBytesAsMegaBytes(memoryUsage.arrayBuffers)}`, - ].join(' '); + if (!importRequested) { + throw new Error( + `failed to request artifact import for call recording ${callRecordingId}`, + ); + } }; -const formatBytesAsMegaBytes = (bytes: number): string => - (bytes / 1024 / 1024).toFixed(1); - const findMatchingCallRecording = async ({ client, webhookEvent, }: { client: CoreApiClient; webhookEvent: RecallWebhookEvent; -}): Promise => { +}): Promise => { if (!isUndefined(webhookEvent.callRecordingIdFromMetadata)) { - return findCallRecordingByFilter(client, { - id: { eq: webhookEvent.callRecordingIdFromMetadata }, - }); + return ( + await findCallRecordingsByFilter(client, { + id: { eq: webhookEvent.callRecordingIdFromMetadata }, + }) + )[0]; } if (isUndefined(webhookEvent.externalBotId)) { return undefined; } - return findCallRecordingByFilter(client, { - externalBotId: { eq: webhookEvent.externalBotId }, - }); -}; - -const findCallRecordingByFilter = async ( - client: CoreApiClient, - filter: Record, -): Promise => { - const queryResult = await client.query({ - callRecordings: { - __args: { - filter, - first: 1, - }, - edges: { - node: { - id: true, - status: true, - startedAt: true, - endedAt: true, - externalRecordingId: true, - callRecorderFailureReason: true, - transcript: true, - audio: { fileId: true }, - video: { fileId: true }, - }, - }, - }, - }); - - const node = queryResult.callRecordings?.edges?.[0]?.node; - - if (isUndefined(node) || isNull(node)) { - return undefined; - } - - return { - id: node.id, - status: getString(node.status), - startedAt: getString(node.startedAt), - endedAt: getString(node.endedAt), - externalRecordingId: getString(node.externalRecordingId), - callRecorderFailureReason: getString(node.callRecorderFailureReason), - transcript: node.transcript ?? undefined, - audio: node.audio ?? undefined, - video: node.video ?? undefined, - }; + return ( + await findCallRecordingsByFilter(client, { + externalBotId: { eq: webhookEvent.externalBotId }, + }) + )[0]; }; const mapRecallEventToCallRecordingStatus = ({ @@ -403,7 +245,7 @@ const buildRecordingTimestampsUpdate = ({ callRecording, }: { webhookEvent: RecallWebhookEvent; - callRecording: MatchedCallRecording; + callRecording: CallRecordingRecord; }): { startedAt?: string; endedAt?: string } => { const { event, statusCode, statusTimestamp } = webhookEvent; @@ -451,11 +293,6 @@ type CallRecordingStatusUpdate = callRecorderFailureReason: string; }; -type TerminalArtifactGateFailureUpdate = { - status: CallRecordingStatus.FAILED; - callRecorderFailureReason: string; -}; - const buildCallRecordingStatusUpdate = ({ reason, status, @@ -470,327 +307,7 @@ const buildCallRecordingStatusUpdate = ({ return { status }; }; -const buildTerminalArtifactGateFailureUpdate = ({ - callRecording, - providerLookupFailed, - updateData, - webhookEvent, -}: { - callRecording: MatchedCallRecording; - providerLookupFailed: boolean; - updateData: CallRecordingUpdateFields; - webhookEvent: RecallWebhookEvent; -}): TerminalArtifactGateFailureUpdate | undefined => { - if (updateData.status === CallRecordingStatus.FAILED) { - return isUndefined(updateData.callRecorderFailureReason) - ? { - status: CallRecordingStatus.FAILED, - callRecorderFailureReason: - getRecallWebhookFailureReason(webhookEvent), - } - : undefined; - } - - if ( - providerLookupFailed || - hasRecordingArtifactPath({ callRecording, updateData }) - ) { - return undefined; - } - - return { - status: CallRecordingStatus.FAILED, - callRecorderFailureReason: 'recording_artifacts_unavailable', - }; -}; - const getRecallWebhookFailureReason = ({ event, statusCode, }: RecallWebhookEvent): string => statusCode ?? event; - -const hasRecordingArtifactPath = ({ - callRecording, - updateData, -}: { - callRecording: MatchedCallRecording; - updateData: CallRecordingUpdateFields; -}): boolean => { - return ( - !isUndefined( - updateData.externalRecordingId ?? callRecording.externalRecordingId, - ) || - isNonEmptyArray(updateData.audio ?? callRecording.audio) || - isNonEmptyArray(updateData.video ?? callRecording.video) || - hasReachableTranscript(updateData.transcript ?? callRecording.transcript) - ); -}; - -const hasReachableTranscript = (transcript: unknown): boolean => { - if (isNull(transcript) || isUndefined(transcript)) { - return false; - } - - const marker = parseTranscriptMarker(transcript); - - return isUndefined(marker) || marker.status === 'PENDING'; -}; - -const isTranscriptUnset = (callRecording: MatchedCallRecording): boolean => - isUndefined(callRecording.transcript); - -const buildMediaImportUpdate = async ({ - callRecording, - externalRecordingId, -}: { - callRecording: MatchedCallRecording; - externalRecordingId: string | undefined; -}): Promise< - Pick< - CallRecordingUpdateFields, - 'audio' | 'video' | 'callRecorderFailureReason' - > -> => { - const hasAudio = isNonEmptyArray(callRecording.audio); - const hasVideo = isNonEmptyArray(callRecording.video); - - if (hasAudio && hasVideo) { - return {}; - } - - if (isUndefined(externalRecordingId)) { - console.warn( - `[call-recorder] cannot import media for call recording ${callRecording.id}: no Recall recording id available`, - ); - - return {}; - } - - return importCallRecordingMedia({ - callRecordingId: callRecording.id, - externalRecordingId, - hasAudio, - hasVideo, - }); -}; - -const buildTranscriptArtifactUpdate = async ({ - callRecording, - externalRecordingId, -}: { - callRecording: MatchedCallRecording; - externalRecordingId: string | undefined; -}): Promise => { - if (isUndefined(externalRecordingId)) { - console.warn( - `[call-recorder] cannot reconcile transcript for call recording ${callRecording.id}: no Recall recording id available`, - ); - - return {}; - } - - const transcriptArtifactResult = - await importCallRecordingTranscript({ - callRecordingId: callRecording.id, - currentStatus: callRecording.status, - externalRecordingId, - requestedAt: new Date().toISOString(), - transcript: callRecording.transcript, - }); - - return { - ...(isUndefined(callRecording.externalRecordingId) - ? { externalRecordingId } - : {}), - ...transcriptArtifactResult.updateData, - }; -}; - -const resolveExternalRecordingId = async ({ - callRecording, - webhookEvent, -}: { - callRecording: MatchedCallRecording; - webhookEvent: RecallWebhookEvent; -}): Promise => { - const externalRecordingId = - webhookEvent.externalRecordingId ?? callRecording.externalRecordingId; - - if (!isUndefined(externalRecordingId)) { - return { externalRecordingId, providerLookupFailed: false }; - } - - if (isUndefined(webhookEvent.externalBotId)) { - return { externalRecordingId: undefined, providerLookupFailed: false }; - } - - return fetchExternalRecordingIdFromRecallBot(webhookEvent.externalBotId); -}; - -const fetchExternalRecordingIdFromRecallBot = async ( - externalBotId: string, -): Promise => { - const botResult = await getRecallBot({ externalBotId }); - - if (!botResult.ok) { - console.warn( - `[call-recorder] failed to fetch Recall bot ${externalBotId} while resolving a recording id: ${botResult.errorMessage}`, - ); - - return { externalRecordingId: undefined, providerLookupFailed: true }; - } - - return { - externalRecordingId: extractRecallBotSyncState(botResult.bot) - .externalRecordingId, - providerLookupFailed: false, - }; -}; - -const handleRecallTranscriptEvent = async ({ - client, - webhookEvent, - event, -}: { - client: CoreApiClient; - webhookEvent: RecallWebhookEvent; - event: 'transcript.done' | 'transcript.failed'; -}): Promise => { - const callRecording = await findMatchingCallRecording({ - client, - webhookEvent, - }); - - if (isUndefined(callRecording)) { - return { - status: 'skipped', - event, - reason: 'no matching call recording', - }; - } - - const { transcriptId } = webhookEvent; - - if (event === 'transcript.failed') { - return applyTranscriptFailure({ - client, - callRecording, - event, - transcriptId, - subCode: webhookEvent.transcriptFailureSubCode ?? null, - }); - } - - if (isUndefined(transcriptId)) { - return { - status: 'skipped', - event, - reason: 'missing transcript id', - }; - } - - const downloadResult = await downloadTranscript({ transcriptId }); - - switch (downloadResult.outcome) { - case 'filled': { - const updateData: CallRecordingUpdateFields = { - transcript: downloadResult.content as Record, - ...(isUndefined(callRecording.externalRecordingId) - ? buildExternalRecordingIdUpdate(webhookEvent) - : {}), - }; - - await persistCallRecordingProgress(client, { - id: callRecording.id, - current: callRecording, - updateData, - }); - - return { - status: 'updated', - event, - callRecordingId: callRecording.id, - transcriptOutcome: 'FILLED', - }; - } - case 'failed': - return applyTranscriptFailure({ - client, - callRecording, - event, - transcriptId, - subCode: downloadResult.subCode, - }); - case 'pending': - case 'error': { - // 200-acked either way, Svix never redelivers; the cron re-check retries this. - const reason = - downloadResult.outcome === 'pending' - ? 'transcript not downloadable yet' - : downloadResult.errorMessage; - - console.warn( - `[call-recorder] could not fill transcript for call recording ${callRecording.id}: ${reason}`, - ); - - return { - status: 'skipped', - event, - reason, - }; - } - } -}; - -const applyTranscriptFailure = async ({ - client, - callRecording, - event, - transcriptId, - subCode, -}: { - client: CoreApiClient; - callRecording: MatchedCallRecording; - event: string; - transcriptId: string | undefined; - subCode: string | null; -}): Promise => { - const existingMarker = parseTranscriptMarker(callRecording.transcript); - - if (!isTranscriptUnset(callRecording) && isUndefined(existingMarker)) { - return { - status: 'skipped', - event, - reason: 'transcript already filled', - }; - } - - console.warn( - `[call-recorder] transcript failed for call recording ${callRecording.id}${isNull(subCode) ? '' : ` (${subCode})`}`, - ); - - await updateCallRecording(client, { - id: callRecording.id, - data: { - transcript: buildFailedTranscriptMarker({ - recallTranscriptId: - transcriptId ?? existingMarker?.recallTranscriptId ?? null, - subCode, - }), - callRecorderFailureReason: buildTranscriptFailureReason(subCode), - ...(isCallRecordingStatusDowngrade({ - fromStatus: callRecording.status, - toStatus: CallRecordingStatus.FAILED, - }) - ? {} - : { status: CallRecordingStatus.FAILED }), - }, - }); - - return { - status: 'updated', - event, - callRecordingId: callRecording.id, - transcriptOutcome: 'FAILED', - }; -}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/import-call-recording-artifacts.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/import-call-recording-artifacts.util.ts new file mode 100644 index 0000000000..2384f18963 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/import-call-recording-artifacts.util.ts @@ -0,0 +1,191 @@ +import { isNull, isUndefined } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { + claimCallRecordingArtifactsImport, + releaseCallRecordingArtifactsImportClaim, +} from 'src/logic-functions/data/claim-call-recording-artifacts-import.util'; +import { getRecallBot } from 'src/logic-functions/recall-api/get-recall-bot.util'; +import { type RecallBotSnapshot } from 'src/logic-functions/recall-api/recall-bot-snapshot.type'; +import { + syncCallRecording, + type SyncableCallRecording, +} from 'src/logic-functions/flows/sync-call-recording.util'; +import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type'; +import { type CallRecordingArtifactsImportRequest } from 'src/logic-functions/types/call-recording-artifacts-import-request.type'; +import { getString } from 'src/logic-functions/utils/get-string.util'; + +type CallRecordingForArtifactsImport = SyncableCallRecording & { + externalBotId: string | undefined; +}; + +type CallRecordingForArtifactsImportNode = { + id?: string | null; + status?: string | null; + startedAt?: string | null; + endedAt?: string | null; + externalBotId?: string | null; + externalRecordingId?: string | null; + callRecorderFailureReason?: string | null; + transcript?: unknown; + audio?: FilesFieldValue | null; + video?: FilesFieldValue | null; +}; + +export type ImportCallRecordingArtifactsResult = + | { + status: 'imported'; + callRecordingId: string; + outcome: 'call-recording-artifacts-imported'; + } + | { + status: 'skipped'; + callRecordingId: string; + reason: string; + }; + +// Route callers can forge provider ids, so imports resolve only from the +// CallRecording's persisted Recall bot. +export const importCallRecordingArtifacts = async ({ + client, + request, +}: { + client: CoreApiClient; + request: CallRecordingArtifactsImportRequest; +}): Promise => { + const callRecording = await findCallRecordingForArtifactsImport( + client, + request.callRecordingId, + ); + + if (isUndefined(callRecording)) { + return { + status: 'skipped', + callRecordingId: request.callRecordingId, + reason: 'no matching call recording', + }; + } + + // Svix redelivers a webhook to several workers at once; the lease ensures only + // one performs the provider transcript request and media upload. The lease clock + // is wall-clock, not request.requestedAt, so a retry of the same delivery still + // measures real elapsed time and can reclaim a lease left behind by a crash. + const claimedImport = await claimCallRecordingArtifactsImport(client, { + callRecordingId: callRecording.id, + now: new Date(), + }); + + if (!claimedImport) { + return { + status: 'skipped', + callRecordingId: callRecording.id, + reason: 'artifact import already in progress', + }; + } + + try { + const bot = await fetchRecallBotWhenRecordingIdMissing(callRecording); + const syncResult = await syncCallRecording({ + client, + callRecording, + bot, + treatRecordingAsDone: true, + requestedAt: request.requestedAt, + }); + + if (!syncResult.updated) { + return { + status: 'skipped', + callRecordingId: callRecording.id, + reason: 'no artifact updates', + }; + } + + return { + status: 'imported', + callRecordingId: callRecording.id, + outcome: 'call-recording-artifacts-imported', + }; + } finally { + await releaseCallRecordingArtifactsImportClaim(client, { + callRecordingId: callRecording.id, + }); + } +}; + +const fetchRecallBotWhenRecordingIdMissing = async ( + callRecording: CallRecordingForArtifactsImport, +): Promise => { + if (!isUndefined(callRecording.externalRecordingId)) { + return undefined; + } + + if (isUndefined(callRecording.externalBotId)) { + return undefined; + } + + const botResult = await getRecallBot({ + externalBotId: callRecording.externalBotId, + }); + + if (!botResult.ok) { + console.warn( + `[call-recorder] failed to fetch Recall bot ${callRecording.externalBotId} while resolving a recording id: ${botResult.errorMessage}`, + ); + + return undefined; + } + + return botResult.bot; +}; + +const findCallRecordingForArtifactsImport = async ( + client: CoreApiClient, + callRecordingId: string, +): Promise => { + const queryResult = await client.query({ + callRecordings: { + __args: { + filter: { id: { eq: callRecordingId } }, + first: 1, + }, + edges: { + node: { + id: true, + status: true, + startedAt: true, + endedAt: true, + externalBotId: true, + externalRecordingId: true, + callRecorderFailureReason: true, + transcript: true, + audio: { fileId: true }, + video: { fileId: true }, + }, + }, + }, + }); + + const node = queryResult.callRecordings?.edges?.[0]?.node as + | CallRecordingForArtifactsImportNode + | null + | undefined; + const id = getString(node?.id); + + if (isUndefined(node) || isNull(node) || isUndefined(id)) { + return undefined; + } + + return { + id, + status: getString(node.status), + startedAt: getString(node.startedAt), + endedAt: getString(node.endedAt), + externalBotId: getString(node.externalBotId), + externalRecordingId: getString(node.externalRecordingId), + callRecorderFailureReason: getString(node.callRecorderFailureReason), + transcript: node.transcript ?? undefined, + audio: node.audio ?? undefined, + video: node.video ?? 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 677c8c8cc6..26cfa9ce98 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 @@ -38,6 +38,23 @@ type MediaUploadTarget = { const MEDIA_DOWNLOAD_TIMEOUT_MS = 120_000; const MEDIA_FILE_FOLDER = 'FilesField'; +const MEDIA_ARTIFACT_DESCRIPTORS = [ + { + field: 'video', + fileName: 'video.mp4', + fieldMetadataUniversalIdentifier: + CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER, + tooLargeFailureReason: VIDEO_FILE_TOO_LARGE_FAILURE_REASON, + }, + { + field: 'audio', + fileName: 'audio.mp3', + fieldMetadataUniversalIdentifier: + CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER, + tooLargeFailureReason: AUDIO_FILE_TOO_LARGE_FAILURE_REASON, + }, +] as const; + export const importCallRecordingMedia = async ({ callRecordingId, externalRecordingId, @@ -67,44 +84,34 @@ export const importCallRecordingMedia = async ({ const metadataClient = new MetadataApiClient(); const updateFields: CallRecordingMediaUpdateFields = {}; const tooLargeFailureReasons: string[] = []; + const artifactStateByField = { + video: { alreadyImported: hasVideo, url: mediaUrls.videoUrl }, + audio: { alreadyImported: hasAudio, url: mediaUrls.audioUrl }, + }; - if (!hasVideo && !isUndefined(mediaUrls.videoUrl)) { - const video = await importMediaArtifact({ + for (const descriptor of MEDIA_ARTIFACT_DESCRIPTORS) { + const { alreadyImported, url } = artifactStateByField[descriptor.field]; + + if (alreadyImported || isUndefined(url)) { + continue; + } + + const importResult = await importMediaArtifact({ callRecordingId, metadataClient, - url: mediaUrls.videoUrl, - fileName: 'video.mp4', + url, + fileName: descriptor.fileName, fieldMetadataUniversalIdentifier: - CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER, + descriptor.fieldMetadataUniversalIdentifier, maxMediaFileSizeBytes: CALL_RECORDER_MAX_MEDIA_FILE_SIZE_BYTES, }); - if (video.outcome === 'imported') { - updateFields.video = video.files; + if (importResult.outcome === 'imported') { + updateFields[descriptor.field] = importResult.files; } - if (video.outcome === 'too-large') { - tooLargeFailureReasons.push(VIDEO_FILE_TOO_LARGE_FAILURE_REASON); - } - } - - if (!hasAudio && !isUndefined(mediaUrls.audioUrl)) { - const audio = await importMediaArtifact({ - callRecordingId, - metadataClient, - url: mediaUrls.audioUrl, - fileName: 'audio.mp3', - fieldMetadataUniversalIdentifier: - CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER, - maxMediaFileSizeBytes: CALL_RECORDER_MAX_MEDIA_FILE_SIZE_BYTES, - }); - - if (audio.outcome === 'imported') { - updateFields.audio = audio.files; - } - - if (audio.outcome === 'too-large') { - tooLargeFailureReasons.push(AUDIO_FILE_TOO_LARGE_FAILURE_REASON); + if (importResult.outcome === 'too-large') { + tooLargeFailureReasons.push(descriptor.tooLargeFailureReason); } } diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/persist-call-recording-progress.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/persist-call-recording-progress.util.ts index b7ba7041bc..74d5301398 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/persist-call-recording-progress.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/persist-call-recording-progress.util.ts @@ -1,42 +1,27 @@ import { type CoreApiClient } from 'twenty-client-sdk/core'; -import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type'; import { completeAndChargeCallRecording } from 'src/logic-functions/flows/complete-and-charge-call-recording.util'; -import { shouldCompleteCallRecordingImport } from 'src/logic-functions/domain/should-complete-call-recording-import.util'; import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util'; import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type'; -type PersistCallRecordingProgressCurrent = { - status?: string; - startedAt?: string; - endedAt?: string; - transcript?: unknown; - audio?: FilesFieldValue; - video?: FilesFieldValue; - callRecorderFailureReason?: string | null; -}; - export const persistCallRecordingProgress = async ( client: CoreApiClient, { id, current, updateData, + completesImport, }: { id: string; - current: PersistCallRecordingProgressCurrent; + current: { startedAt?: string; endedAt?: string }; updateData: CallRecordingUpdateFields; + completesImport: boolean; }, -): Promise<{ completesImport: boolean }> => { - const completesImport = shouldCompleteCallRecordingImport({ - current, - updateData, - }); - +): Promise => { if (!completesImport) { await updateCallRecording(client, { id, data: updateData }); - return { completesImport: false }; + return; } // Strip status so COMPLETED is written only by the atomic claim — its single winner bills once. @@ -53,6 +38,4 @@ export const persistCallRecordingProgress = async ( startedAt: updateData.startedAt ?? current.startedAt, endedAt: updateData.endedAt ?? current.endedAt, }); - - return { completesImport: true }; }; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/retry-failed-recall-cancellations.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/retry-failed-recall-cancellations.util.ts new file mode 100644 index 0000000000..7451718fcc --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/retry-failed-recall-cancellations.util.ts @@ -0,0 +1,189 @@ +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 { hasMeetingEnded } from 'src/logic-functions/domain/has-meeting-ended.util'; +import { NON_TERMINAL_CALL_RECORDING_STATUSES } from 'src/logic-functions/constants/non-terminal-call-recording-statuses'; +import { fetchCalendarEventsByIds } from 'src/logic-functions/data/fetch-calendar-events-by-ids.util'; +import { findCallRecordingsByFilter } from 'src/logic-functions/data/find-call-recordings-by-filter.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 { replaceCanceledCallRecordingExternalBotId } from 'src/logic-functions/data/replace-canceled-call-recording-external-bot-id.util'; +import { cancelOrEjectRecallBot } from 'src/logic-functions/recall-api/cancel-or-eject-recall-bot.util'; +import { findScheduledRecallBotIdForCallRecording } from 'src/logic-functions/recall-api/find-scheduled-recall-bot-id-for-call-recording.util'; +import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type'; +import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type'; +import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util'; + +export type RetryFailedRecallCancellationsResult = { + canceledExternalBotCallRecordingIds: string[]; +}; + +const CANCELED_BOT_RECOVERY_AFTER_START_HOURS = 24; +const CANCELED_BOT_RECOVERY_MAX_AGE_HOURS = 24; + +// Retries the Recall half of cancelCallRecordingRequest when its bot cancel failed; the recording keeps its bot id until the bot is confirmed gone. +export const retryFailedRecallCancellations = async ({ + client, + now, +}: { + client: CoreApiClient; + now: Date; +}): Promise => { + const canceledCallRecordings = await findCallRecordingsByFilter(client, { + recordingRequestStatus: { eq: CallRecordingRequestStatus.CANCELED }, + status: { in: NON_TERMINAL_CALL_RECORDING_STATUSES }, + }); + const botlessCanceledCallRecordings = canceledCallRecordings.filter( + (callRecording) => isUndefined(callRecording.externalBotId), + ); + const calendarEventsById = new Map( + ( + await fetchCalendarEventsByIds( + client, + getUniqueSortedIds( + botlessCanceledCallRecordings.map( + (callRecording) => callRecording.calendarEventId, + ), + ), + ) + ).map((calendarEvent) => [calendarEvent.id, calendarEvent]), + ); + const canceledExternalBotCallRecordingIds: string[] = []; + + for (const callRecording of canceledCallRecordings) { + const calendarEvent = isUndefined(callRecording.calendarEventId) + ? undefined + : calendarEventsById.get(callRecording.calendarEventId); + const externalBotId = await recoverRecallBotIdForCanceledCallRecording({ + client, + callRecording, + calendarEvent, + now, + }); + + if (isUndefined(externalBotId)) { + continue; + } + + // Calendar reconciliation can reactivate the request while this job is running. + const latestCallRecording = ( + await findCallRecordingsByIds(client, [callRecording.id]) + )[0]; + + if ( + latestCallRecording?.recordingRequestStatus !== + CallRecordingRequestStatus.CANCELED || + (!isUndefined(latestCallRecording.externalBotId) && + latestCallRecording.externalBotId !== externalBotId) + ) { + continue; + } + + if (!(await cancelOrEjectRecallBot(externalBotId))) { + continue; + } + + if (latestCallRecording.externalBotId === externalBotId) { + await replaceCanceledCallRecordingExternalBotId(client, { + id: callRecording.id, + expectedExternalBotId: externalBotId, + nextExternalBotId: null, + }); + } + + canceledExternalBotCallRecordingIds.push(callRecording.id); + } + + return { canceledExternalBotCallRecordingIds }; +}; + +const recoverRecallBotIdForCanceledCallRecording = async ({ + client, + callRecording, + calendarEvent, + now, +}: { + client: CoreApiClient; + callRecording: CallRecordingRecord; + calendarEvent: CalendarEventRecord | undefined; + now: Date; +}): Promise => { + if (!isUndefined(callRecording.externalBotId)) { + return callRecording.externalBotId; + } + + if ( + !isUndefined(calendarEvent) && + hasMeetingEnded({ + startsAt: calendarEvent.startsAt, + endsAt: calendarEvent.endsAt, + now, + startGraceHours: CANCELED_BOT_RECOVERY_AFTER_START_HOURS, + }) + ) { + return undefined; + } + + // Recovery only closes the crash window right after cancellation; once a row ages out the daily cleanup sweep owns it, so stop the per-run Recall lookup instead of listing forever (notably for rows whose calendar event was deleted and can no longer bound the retry). + // updatedAt tracks the cancellation write, so a request scheduled far ahead but canceled recently still gets its window; createdAt would age it out from scheduling time. + if ( + hasCanceledRecoveryWindowElapsed({ + canceledAt: callRecording.updatedAt ?? callRecording.createdAt, + now, + }) + ) { + return undefined; + } + + const currentWorkspaceId = getCurrentWorkspaceId(); + + if (isUndefined(currentWorkspaceId)) { + return undefined; + } + + const scheduledRecallBotLookupResult = + await findScheduledRecallBotIdForCallRecording({ + callRecordingId: callRecording.id, + workspaceId: currentWorkspaceId, + }); + + if ( + !scheduledRecallBotLookupResult.ok || + isUndefined(scheduledRecallBotLookupResult.externalBotId) + ) { + return undefined; + } + + const externalBotId = scheduledRecallBotLookupResult.externalBotId; + const didClaimRecoveredBot = await replaceCanceledCallRecordingExternalBotId( + client, + { + id: callRecording.id, + expectedExternalBotId: null, + nextExternalBotId: externalBotId, + }, + ); + + return didClaimRecoveredBot ? externalBotId : undefined; +}; + +const hasCanceledRecoveryWindowElapsed = ({ + canceledAt, + now, +}: { + canceledAt: string | undefined; + now: Date; +}): boolean => { + if (isUndefined(canceledAt)) { + return false; + } + + const canceledTime = new Date(canceledAt).getTime(); + + return ( + !Number.isNaN(canceledTime) && + canceledTime + CANCELED_BOT_RECOVERY_MAX_AGE_HOURS * 60 * 60 * 1000 <= + now.getTime() + ); +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/schedule-recall-bots-for-pending-call-recordings.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/schedule-recall-bots-for-pending-call-recordings.util.ts index abb93485bb..bf2bada651 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/schedule-recall-bots-for-pending-call-recordings.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/schedule-recall-bots-for-pending-call-recordings.util.ts @@ -1,17 +1,19 @@ import { isUndefined } from '@sniptt/guards'; import { type CoreApiClient } from 'twenty-client-sdk/core'; -import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type'; +import { hasMeetingEnded } from 'src/logic-functions/domain/has-meeting-ended.util'; +import { attachExistingRecallBotToCallRecording } from 'src/logic-functions/flows/attach-existing-recall-bot-to-call-recording.util'; import { scheduleRecallBotForCallRecording } from 'src/logic-functions/flows/schedule-recall-bot-for-call-recording.util'; import { fetchCalendarEventsByIds } from 'src/logic-functions/data/fetch-calendar-events-by-ids.util'; import { findOpenScheduledCallRecordings } from 'src/logic-functions/data/find-open-scheduled-call-recordings.util'; import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util'; export type ScheduleRecallBotsForPendingCallRecordingsResult = { + attachedCallRecordingIds: string[]; scheduledCallRecordingIds: string[]; }; -// Closes the create-winner crash gap: a run that inserted the row but died before POSTing leaves a botless recording, and the cron is the single writer that re-POSTs it. +// Resumes a CallRecording inserted before its Recall bot was scheduled. export const scheduleRecallBotsForPendingCallRecordings = async ({ client, now, @@ -24,7 +26,7 @@ export const scheduleRecallBotsForPendingCallRecordings = async ({ ).filter((callRecording) => isUndefined(callRecording.externalBotId)); if (pendingCallRecordings.length === 0) { - return { scheduledCallRecordingIds: [] }; + return { attachedCallRecordingIds: [], scheduledCallRecordingIds: [] }; } const calendarEventsById = new Map( @@ -39,6 +41,7 @@ export const scheduleRecallBotsForPendingCallRecordings = async ({ ) ).map((calendarEvent) => [calendarEvent.id, calendarEvent]), ); + const attachedCallRecordingIds: string[] = []; const scheduledCallRecordingIds: string[] = []; for (const callRecording of pendingCallRecordings) { @@ -46,37 +49,43 @@ export const scheduleRecallBotsForPendingCallRecordings = async ({ ? undefined : calendarEventsById.get(callRecording.calendarEventId); - if (isUndefined(calendarEvent) || hasMeetingEnded({ calendarEvent, now })) { + if ( + isUndefined(calendarEvent) || + hasMeetingEnded({ + startsAt: calendarEvent.startsAt, + endsAt: calendarEvent.endsAt, + now, + }) + ) { continue; } - const didScheduleCallRecorder = await scheduleRecallBotForCallRecording(client, { + const attachResult = await attachExistingRecallBotToCallRecording(client, { callRecording, - calendarEvent, }); - if (didScheduleCallRecorder) { + if (attachResult.status === 'attached') { + attachedCallRecordingIds.push(callRecording.id); + continue; + } + + // A failed lookup can hide an existing bot; creating one now could duplicate it, so defer to the next run. + if (attachResult.status === 'lookup-failed') { + continue; + } + + const didScheduleRecallBot = await scheduleRecallBotForCallRecording( + client, + { + callRecording, + calendarEvent, + }, + ); + + if (didScheduleRecallBot) { scheduledCallRecordingIds.push(callRecording.id); } } - return { scheduledCallRecordingIds }; -}; - -const hasMeetingEnded = ({ - calendarEvent, - now, -}: { - calendarEvent: CalendarEventRecord; - now: Date; -}): boolean => { - const reference = calendarEvent.endsAt ?? calendarEvent.startsAt; - - if (isUndefined(reference)) { - return false; - } - - const referenceTime = new Date(reference).getTime(); - - return !Number.isNaN(referenceTime) && referenceTime <= now.getTime(); + return { attachedCallRecordingIds, scheduledCallRecordingIds }; }; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/sync-call-recording.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/sync-call-recording.util.ts new file mode 100644 index 0000000000..91bd1113d4 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/sync-call-recording.util.ts @@ -0,0 +1,248 @@ +import { isNonEmptyArray, isUndefined } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; +import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util'; +import { shouldCompleteCallRecordingImport } from 'src/logic-functions/domain/should-complete-call-recording-import.util'; +import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util'; +import { importCallRecordingMedia } from 'src/logic-functions/flows/import-call-recording-media.util'; +import { persistCallRecordingProgress } from 'src/logic-functions/flows/persist-call-recording-progress.util'; +import { importCallRecordingTranscript } from 'src/logic-functions/flows/import-call-recording-transcript.util'; +import { + extractRecallBotSyncState, + type RecallBotSyncState, +} 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'; +import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type'; +import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type'; + +export type SyncableCallRecording = { + id: string; + status: string | undefined; + startedAt: string | undefined; + endedAt: string | undefined; + externalRecordingId: string | undefined; + callRecorderFailureReason: string | undefined; + transcript: unknown; + audio: FilesFieldValue | undefined; + video: FilesFieldValue | undefined; +}; + +export type SyncCallRecordingResult = { + updated: boolean; + requestedTranscript: boolean; +}; + +// The single-record sync shared by webhook-driven imports and the scheduled +// stale-recording sync. It trusts persisted Twenty data and a parsed Recall bot +// snapshot, never provider ids supplied by a route caller. +export const syncCallRecording = async ({ + client, + callRecording, + bot, + treatRecordingAsDone, + requestedAt, +}: { + client: CoreApiClient; + callRecording: SyncableCallRecording; + bot: RecallBotSnapshot | undefined; + // Webhook-driven imports run only for recording-done signals, so completion + // need not be re-derived from a bot snapshot they may not have. + treatRecordingAsDone: boolean; + requestedAt: string; +}): Promise => { + const syncState = isUndefined(bot) + ? undefined + : extractRecallBotSyncState(bot); + const externalRecordingId = + callRecording.externalRecordingId ?? syncState?.externalRecordingId; + const isRecordingDone = + treatRecordingAsDone || syncState?.isRecallRecordingDone === true; + + let updateData: CallRecordingUpdateFields = isUndefined(syncState) + ? {} + : buildSyncStateFieldUpdates({ callRecording, syncState }); + + if ( + syncState?.isRecallRecordingDone === true && + isUndefined(externalRecordingId) && + !hasRecordingArtifactPath({ callRecording, updateData }) + ) { + updateData = { + ...updateData, + ...buildMissingArtifactsFailureUpdate({ + currentStatus: callRecording.status, + pendingStatus: updateData.status, + recallFailureReason: syncState.failureReason, + }), + }; + } + + let requestedTranscript = false; + + if (isRecordingDone && !isUndefined(externalRecordingId)) { + const transcriptImportResult = await importCallRecordingTranscript({ + callRecordingId: callRecording.id, + currentStatus: callRecording.status, + externalRecordingId, + requestedAt, + transcript: callRecording.transcript, + }); + + requestedTranscript = transcriptImportResult.requestedTranscript; + updateData = { ...updateData, ...transcriptImportResult.updateData }; + + const mediaImportUpdate = await importCallRecordingMedia({ + callRecordingId: callRecording.id, + externalRecordingId, + hasAudio: isNonEmptyArray(callRecording.audio), + hasVideo: isNonEmptyArray(callRecording.video), + }); + + updateData = { + ...updateData, + ...resolveMediaImportUpdate({ + mediaImportUpdate, + currentStatus: callRecording.status, + pendingStatus: updateData.status, + }), + }; + } + + const completesImport = shouldCompleteCallRecordingImport({ + current: callRecording, + updateData, + }); + + if (Object.keys(updateData).length === 0 && !completesImport) { + return { updated: false, requestedTranscript }; + } + + await persistCallRecordingProgress(client, { + id: callRecording.id, + current: callRecording, + updateData, + completesImport, + }); + + return { updated: true, requestedTranscript }; +}; + +const buildSyncStateFieldUpdates = ({ + callRecording, + syncState, +}: { + callRecording: SyncableCallRecording; + syncState: RecallBotSyncState; +}): CallRecordingUpdateFields => { + const updateData: CallRecordingUpdateFields = {}; + + if ( + !isUndefined(syncState.status) && + syncState.status !== callRecording.status && + !isCallRecordingStatusDowngrade({ + fromStatus: callRecording.status, + toStatus: syncState.status, + }) + ) { + updateData.status = syncState.status; + + if (syncState.status === CallRecordingStatus.FAILED) { + updateData.callRecorderFailureReason = + syncState.failureReason ?? 'recall_bot_failed'; + } + } + + if ( + isUndefined(callRecording.startedAt) && + !isUndefined(syncState.startedAt) + ) { + updateData.startedAt = syncState.startedAt; + } + + if (isUndefined(callRecording.endedAt) && !isUndefined(syncState.endedAt)) { + updateData.endedAt = syncState.endedAt; + } + + if ( + isUndefined(callRecording.externalRecordingId) && + !isUndefined(syncState.externalRecordingId) + ) { + updateData.externalRecordingId = syncState.externalRecordingId; + } + + return updateData; +}; + +// The bot completed without ever recording; FAILED rather than COMPLETED because completion bills. +const buildMissingArtifactsFailureUpdate = ({ + currentStatus, + pendingStatus, + recallFailureReason, +}: { + currentStatus: string | undefined; + pendingStatus: string | undefined; + recallFailureReason: string | undefined; +}): CallRecordingUpdateFields => { + if ( + pendingStatus === CallRecordingStatus.FAILED || + isCallRecordingStatusDowngrade({ + fromStatus: currentStatus, + toStatus: CallRecordingStatus.FAILED, + }) + ) { + return {}; + } + + return { + status: CallRecordingStatus.FAILED, + callRecorderFailureReason: + recallFailureReason ?? 'recording_artifacts_unavailable', + }; +}; + +const hasRecordingArtifactPath = ({ + callRecording, + updateData, +}: { + callRecording: SyncableCallRecording; + updateData: CallRecordingUpdateFields; +}): boolean => + isNonEmptyArray(updateData.audio ?? callRecording.audio) || + isNonEmptyArray(updateData.video ?? callRecording.video) || + hasReachableTranscript(updateData.transcript ?? callRecording.transcript); + +const hasReachableTranscript = (transcript: unknown): boolean => { + if (isUndefined(transcript)) { + return false; + } + + const transcriptMarker = parseTranscriptMarker(transcript); + + return isUndefined(transcriptMarker) || transcriptMarker.status === 'PENDING'; +}; + +// A media size marker must not overwrite the failure reason of a FAILED recording. +const resolveMediaImportUpdate = ({ + mediaImportUpdate, + currentStatus, + pendingStatus, +}: { + mediaImportUpdate: CallRecordingUpdateFields; + currentStatus: string | undefined; + pendingStatus: string | undefined; +}): CallRecordingUpdateFields => { + const isRecordingFailed = + currentStatus === CallRecordingStatus.FAILED || + pendingStatus === CallRecordingStatus.FAILED; + + if (!isRecordingFailed) { + return mediaImportUpdate; + } + + const scrubbedUpdate = { ...mediaImportUpdate }; + + delete scrubbedUpdate.callRecorderFailureReason; + + return scrubbedUpdate; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/import-call-recording-artifacts.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/import-call-recording-artifacts.ts new file mode 100644 index 0000000000..be447e406f --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/import-call-recording-artifacts.ts @@ -0,0 +1,63 @@ +import { isNull, isUndefined } from '@sniptt/guards'; +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; + +import { IMPORT_CALL_RECORDING_ARTIFACTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/import-call-recording-artifacts-logic-function-universal-identifier'; +import { IMPORT_CALL_RECORDING_ARTIFACTS_ROUTE_PATH } from 'src/constants/import-call-recording-artifacts-route-path'; +import { + importCallRecordingArtifacts, + type ImportCallRecordingArtifactsResult, +} from 'src/logic-functions/flows/import-call-recording-artifacts.util'; +import { type CallRecordingArtifactsImportRequest } from 'src/logic-functions/types/call-recording-artifacts-import-request.type'; +import { getString } from 'src/logic-functions/utils/get-string.util'; + +export const importCallRecordingArtifactsHandler = async ( + payload: RoutePayload>, +): Promise => { + const request = parseCallRecordingArtifactsImportRequest(payload.body); + + if (isUndefined(request)) { + return { + status: 'skipped', + callRecordingId: getString(payload.body?.callRecordingId) ?? 'unknown', + reason: 'invalid call recording artifacts import request', + }; + } + + return importCallRecordingArtifacts({ + client: new CoreApiClient(), + request, + }); +}; + +const parseCallRecordingArtifactsImportRequest = ( + body: Partial | null | undefined, +): CallRecordingArtifactsImportRequest | undefined => { + if (isNull(body) || isUndefined(body)) { + return undefined; + } + + const callRecordingId = getString(body.callRecordingId); + const requestedAt = getString(body.requestedAt); + + if (isUndefined(callRecordingId) || isUndefined(requestedAt)) { + return undefined; + } + + return { callRecordingId, requestedAt }; +}; + +export default defineLogicFunction({ + universalIdentifier: + IMPORT_CALL_RECORDING_ARTIFACTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'import-call-recording-artifacts', + description: + 'Imports recording media and transcript artifacts after a verified Recall webhook resolves the owning CallRecording.', + timeoutSeconds: 250, + handler: importCallRecordingArtifactsHandler, + httpRouteTriggerSettings: { + path: IMPORT_CALL_RECORDING_ARTIFACTS_ROUTE_PATH, + httpMethod: 'POST', + isAuthRequired: true, + }, +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/process-pending-call-recording-requests.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/process-pending-call-recording-requests.ts new file mode 100644 index 0000000000..87c635814a --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/process-pending-call-recording-requests.ts @@ -0,0 +1,68 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { defineLogicFunction } from 'twenty-sdk/define'; + +import { PENDING_CALL_RECORDING_REQUESTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/pending-call-recording-requests-logic-function-universal-identifier'; +import { PENDING_CALL_RECORDING_REQUESTS_CRON_PATTERN } from 'src/logic-functions/constants/pending-call-recording-requests-cron-pattern'; +import { + retryFailedRecallCancellations, + type RetryFailedRecallCancellationsResult, +} from 'src/logic-functions/flows/retry-failed-recall-cancellations.util'; +import { + scheduleRecallBotsForPendingCallRecordings, + type ScheduleRecallBotsForPendingCallRecordingsResult, +} from 'src/logic-functions/flows/schedule-recall-bots-for-pending-call-recordings.util'; +import { + buildStepFailure, + type StepFailure, +} from 'src/logic-functions/utils/build-step-failure.util'; + +const processPendingCallRecordingRequestsHandler = + async (): Promise => { + const now = new Date(); + const client = new CoreApiClient(); + + const pendingCallRecordingScheduleResult = + await scheduleRecallBotsForPendingCallRecordingsSafely(client, now); + const failedCancellationResult = + await retryFailedRecallCancellationsSafely(client, now); + + return { + pendingCallRecordingScheduleResult, + failedCancellationResult, + }; + }; + +const scheduleRecallBotsForPendingCallRecordingsSafely = async ( + client: CoreApiClient, + now: Date, +): Promise => { + try { + return await scheduleRecallBotsForPendingCallRecordings({ client, now }); + } catch (error) { + return buildStepFailure('pending Recall bot scheduling', error); + } +}; + +const retryFailedRecallCancellationsSafely = async ( + client: CoreApiClient, + now: Date, +): Promise => { + try { + return await retryFailedRecallCancellations({ client, now }); + } catch (error) { + return buildStepFailure('failed cancellation retry', error); + } +}; + +export default defineLogicFunction({ + universalIdentifier: + PENDING_CALL_RECORDING_REQUESTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'process-pending-call-recording-requests', + description: + 'Processes pending CallRecording requests by attaching or scheduling missing Recall bots and retrying incomplete cancellations.', + timeoutSeconds: 250, + handler: processPendingCallRecordingRequestsHandler, + cronTriggerSettings: { + pattern: PENDING_CALL_RECORDING_REQUESTS_CRON_PATTERN, + }, +}); 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 c62bc31632..e3432ec352 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 @@ -221,6 +221,7 @@ describe('recall bot api', () => { const result = await listScheduledRecallBots({ joinAtAfter: '2026-01-01T08:00:00.000Z', joinAtBefore: '2026-01-02T12:00:00.000Z', + statuses: ['ready', 'joining_call'], }); expect(result).toEqual({ @@ -238,7 +239,7 @@ describe('recall bot api', () => { }); expect(fetchMock).toHaveBeenNthCalledWith( 1, - 'https://ap-northeast-1.recall.ai/api/v1/bot/?join_at_after=2026-01-01T08%3A00%3A00.000Z&join_at_before=2026-01-02T12%3A00%3A00.000Z', + 'https://ap-northeast-1.recall.ai/api/v1/bot/?join_at_after=2026-01-01T08%3A00%3A00.000Z&join_at_before=2026-01-02T12%3A00%3A00.000Z&status=ready&status=joining_call', expect.objectContaining({ method: 'GET' }), ); expect(fetchMock).toHaveBeenNthCalledWith( @@ -248,6 +249,27 @@ describe('recall bot api', () => { ); }); + it('omits join-at bounds for metadata-only lookups', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ next: null, results: [{ id: 'bot-1' }] }), + }); + + await listScheduledRecallBots({ + metadata: { twentyCallRecordingId: 'recording-1' }, + }); + + const requestUrl = fetchMock.mock.calls[0][0]; + const requestParameters = new URL(requestUrl).searchParams; + + expect(requestParameters.has('join_at_after')).toBe(false); + expect(requestParameters.has('join_at_before')).toBe(false); + expect(requestParameters.get('metadata__twentyCallRecordingId')).toBe( + 'recording-1', + ); + }); + it('flags the result as truncated when the pagination cap leaves more pages', async () => { for (let pageIndex = 1; pageIndex <= 10; pageIndex++) { fetchMock.mockResolvedValueOnce({ @@ -663,28 +685,48 @@ describe('recall bot api', () => { vi.useRealTimers(); }); - it('retries a network failure and succeeds on the next attempt', async () => { + it('reuses the idempotency key for the same bot creation operation', async () => { fetchMock.mockRejectedValueOnce(new Error('socket hang up')); fetchMock.mockResolvedValueOnce({ ok: true, - status: 200, + status: 201, json: async () => ({ id: 'recall-bot-id' }), }); - - const resultPromise = getRecallBot({ externalBotId: 'recall-bot-id' }); + const scheduleArguments = { + meetingUrl: 'https://meet.google.com/abc-defg-hij', + joinAt: '2026-01-01T13:00:00.000Z', + metadata: RECALL_ROUTING_METADATA, + }; + const resultPromise = scheduleRecallBot(scheduleArguments); await vi.runAllTimersAsync(); expect(await resultPromise).toEqual({ ok: true, - bot: { - id: 'recall-bot-id', - metadata: {}, - statusChanges: [], - recordings: [], - }, + externalBotId: 'recall-bot-id', }); expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0][1].headers['Idempotency-Key']).toEqual( + expect.stringMatching(/^[a-f0-9]{64}$/), + ); + expect(fetchMock.mock.calls[1][1].headers['Idempotency-Key']).toBe( + fetchMock.mock.calls[0][1].headers['Idempotency-Key'], + ); + + await scheduleRecallBot(scheduleArguments); + + expect(fetchMock.mock.calls[2][1].headers['Idempotency-Key']).toBe( + fetchMock.mock.calls[0][1].headers['Idempotency-Key'], + ); + + await scheduleRecallBot({ + ...scheduleArguments, + joinAt: '2026-01-01T14:00:00.000Z', + }); + + expect(fetchMock.mock.calls[3][1].headers['Idempotency-Key']).not.toBe( + fetchMock.mock.calls[0][1].headers['Idempotency-Key'], + ); }); it('retries a 503 response and succeeds on the next attempt', async () => { diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/fetch-recall-list-pages.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/fetch-recall-list-pages.util.ts new file mode 100644 index 0000000000..98206e32e0 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/fetch-recall-list-pages.util.ts @@ -0,0 +1,75 @@ +import { isString, isUndefined } from '@sniptt/guards'; + +import { type RecallBotOperationFailure } from 'src/logic-functions/types/recall-bot-operation-result.type'; +import { type RecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util'; +import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util'; + +export type RecallListResponse = { + next?: unknown; + results?: unknown; +}; + +export const fetchRecallListPages = async ({ + config, + initialPath, + maxPages, + extractPageItems, + malformedErrorMessage, +}: { + config: RecallApiConfig; + initialPath: string; + maxPages: number; + extractPageItems: ( + response: RecallListResponse | undefined, + ) => TItem[] | undefined; + malformedErrorMessage: string; +}): Promise< + { ok: true; items: TItem[]; truncated: boolean } | RecallBotOperationFailure +> => { + const items: TItem[] = []; + let path: string | undefined = initialPath; + + for ( + let pageIndex = 0; + !isUndefined(path) && pageIndex < maxPages; + pageIndex++ + ) { + const result = await recallBotApiRequest({ + config, + path, + method: 'GET', + }); + + if (!result.ok) { + return result; + } + + const pageItems = extractPageItems(result.data); + + if (isUndefined(pageItems)) { + return { + ok: false, + status: result.status, + errorMessage: malformedErrorMessage, + }; + } + + items.push(...pageItems); + path = extractNextPath(result.data, config.baseUrl); + } + + return { ok: true, items, truncated: !isUndefined(path) }; +}; + +const extractNextPath = ( + response: RecallListResponse | undefined, + baseUrl: string, +): string | undefined => { + const next = response?.next; + + if (!isString(next) || !next.startsWith(baseUrl)) { + return undefined; + } + + return next.slice(baseUrl.length); +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/find-scheduled-recall-bot-id-for-call-recording.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/find-scheduled-recall-bot-id-for-call-recording.util.ts new file mode 100644 index 0000000000..99bd41ea83 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/find-scheduled-recall-bot-id-for-call-recording.util.ts @@ -0,0 +1,41 @@ +import { listScheduledRecallBots } from 'src/logic-functions/recall-api/list-scheduled-recall-bots.util'; + +const ACTIVE_RECALL_BOT_STATUSES = [ + 'ready', + 'joining_call', + 'in_waiting_room', + 'in_call_not_recording', + 'recording_permission_allowed', + 'recording_permission_denied', + 'in_call_recording', +]; + +export type FindScheduledRecallBotIdResult = + | { ok: true; externalBotId: string | undefined } + | { ok: false }; + +export const findScheduledRecallBotIdForCallRecording = async ({ + callRecordingId, + workspaceId, +}: { + callRecordingId: string; + workspaceId: string; +}): Promise => { + const listResult = await listScheduledRecallBots({ + metadata: { + twentyWorkspaceId: workspaceId, + twentyCallRecordingId: callRecordingId, + }, + statuses: ACTIVE_RECALL_BOT_STATUSES, + }); + + if (!listResult.ok) { + console.warn( + `[call-recorder] failed to look up existing Recall bot for call recording ${callRecordingId}: ${listResult.errorMessage}`, + ); + + return { ok: false }; + } + + return { ok: true, externalBotId: listResult.bots[0]?.id }; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/get-recall-api-config.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/get-recall-api-config.util.ts index d30749a6e1..7400c400d2 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/get-recall-api-config.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/get-recall-api-config.util.ts @@ -6,7 +6,7 @@ import { DEFAULT_RECALL_REGION } from 'src/logic-functions/constants/default-rec 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'; import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util'; -import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; +import { normalizeOptionalString } from 'src/logic-functions/utils/normalize-optional-string.util'; export type RecallApiConfig = { apiKey: string; @@ -24,7 +24,7 @@ export const getRecallApiConfig = (): error: string; } => { const apiKey = normalizeOptionalString( - getApplicationVariableValue(RECALL_API_KEY_ENV_VAR_NAME), + getApplicationVariableValue(RECALL_API_KEY_ENV_VAR_NAME)?.trim(), ); if (isUndefined(apiKey)) { @@ -37,11 +37,11 @@ export const getRecallApiConfig = (): const region = normalizeOptionalString( - getApplicationVariableValue(RECALL_REGION_ENV_VAR_NAME), + getApplicationVariableValue(RECALL_REGION_ENV_VAR_NAME)?.trim(), ) ?? DEFAULT_RECALL_REGION; const botName = normalizeOptionalString( - getApplicationVariableValue(CALL_RECORDER_NAME_ENV_VAR_NAME), + getApplicationVariableValue(CALL_RECORDER_NAME_ENV_VAR_NAME)?.trim(), ) ?? DEFAULT_CALL_RECORDER_NAME; return { @@ -53,7 +53,3 @@ export const getRecallApiConfig = (): }, }; }; - -const normalizeOptionalString = ( - value: string | undefined, -): string | undefined => (isNonEmptyString(value) ? value.trim() : undefined); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/list-recall-transcripts.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/list-recall-transcripts.util.ts index 21c6d925f2..5c2ae2da6a 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/list-recall-transcripts.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/list-recall-transcripts.util.ts @@ -3,19 +3,17 @@ import { isArray, 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 { getString } from 'src/logic-functions/utils/get-string.util'; +import { + fetchRecallListPages, + type RecallListResponse, +} from 'src/logic-functions/recall-api/fetch-recall-list-pages.util'; import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util'; -import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util'; import { type RecallTranscriptSummary } from 'src/logic-functions/recall-api/recall-transcript-summary.type'; type ListRecallTranscriptsResult = | { ok: true; transcripts: RecallTranscriptSummary[] } | RecallBotOperationFailure; -type RecallTranscriptListResponse = { - next?: unknown; - results?: unknown; -}; - const RECALL_TRANSCRIPT_LIST_MAX_PAGES = 10; export const listRecallTranscripts = async ({ @@ -29,41 +27,22 @@ export const listRecallTranscripts = async ({ return { ok: false, status: null, errorMessage: configResult.error }; } - const transcripts: RecallTranscriptSummary[] = []; - let path: string | undefined = buildListRecallTranscriptsPath({ - externalRecordingId, + const searchParams = new URLSearchParams({ + recording_id: externalRecordingId, + }); + const result = await fetchRecallListPages({ + config: configResult.config, + initialPath: `/transcript/?${searchParams.toString()}`, + maxPages: RECALL_TRANSCRIPT_LIST_MAX_PAGES, + extractPageItems: extractRecallTranscriptSummaries, + malformedErrorMessage: 'Recall API returned malformed transcript list', }); - for ( - let pageIndex = 0; - !isUndefined(path) && pageIndex < RECALL_TRANSCRIPT_LIST_MAX_PAGES; - pageIndex++ - ) { - const result = await recallBotApiRequest({ - config: configResult.config, - path, - method: 'GET', - }); - - if (!result.ok) { - return result; - } - - const pageTranscripts = extractRecallTranscriptSummaries(result.data); - - if (isUndefined(pageTranscripts)) { - return { - ok: false, - status: result.status, - errorMessage: 'Recall API returned malformed transcript list', - }; - } - - transcripts.push(...pageTranscripts); - path = extractNextPath(result.data, configResult.config.baseUrl); + if (!result.ok) { + return result; } - if (!isUndefined(path)) { + if (result.truncated) { return { ok: false, status: null, @@ -71,23 +50,11 @@ export const listRecallTranscripts = async ({ }; } - return { ok: true, transcripts }; -}; - -const buildListRecallTranscriptsPath = ({ - externalRecordingId, -}: { - externalRecordingId: string; -}): string => { - const searchParams = new URLSearchParams({ - recording_id: externalRecordingId, - }); - - return `/transcript/?${searchParams.toString()}`; + return { ok: true, transcripts: result.items }; }; const extractRecallTranscriptSummaries = ( - response: RecallTranscriptListResponse | undefined, + response: RecallListResponse | undefined, ): RecallTranscriptSummary[] | undefined => { if (!isArray(response?.results)) { return undefined; @@ -126,16 +93,3 @@ const extractRecallTranscriptSummary = ( statusSubCode: getString(status?.sub_code), }; }; - -const extractNextPath = ( - response: RecallTranscriptListResponse | undefined, - baseUrl: string, -): string | undefined => { - const nextPage = getString(response?.next); - - if (isUndefined(nextPage) || !nextPage.startsWith(baseUrl)) { - return undefined; - } - - return nextPage.slice(baseUrl.length); -}; 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 56bdc56511..7b7a8fb100 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 @@ -1,21 +1,19 @@ -import { isString, isUndefined } from '@sniptt/guards'; +import { 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 { + fetchRecallListPages, + type RecallListResponse, +} from 'src/logic-functions/recall-api/fetch-recall-list-pages.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 = RecallBotSnapshot & { id: string; }; -type RecallBotListResponse = { - next?: unknown; - results?: unknown; -}; - type ListScheduledRecallBotsResult = | { ok: true; bots: RecallScheduledBot[]; truncated: boolean } | RecallBotOperationFailure; @@ -26,10 +24,12 @@ export const listScheduledRecallBots = async ({ joinAtAfter, joinAtBefore, metadata, + statuses, }: { - joinAtAfter: string; - joinAtBefore: string; + joinAtAfter?: string; + joinAtBefore?: string; metadata?: Record; + statuses?: string[]; }): Promise => { const configResult = getRecallApiConfig(); @@ -37,50 +37,47 @@ export const listScheduledRecallBots = async ({ return { ok: false, status: null, errorMessage: configResult.error }; } - const bots: RecallScheduledBot[] = []; - const searchParams = new URLSearchParams({ - join_at_after: joinAtAfter, - join_at_before: joinAtBefore, - }); + const searchParameters = new URLSearchParams(); - Object.entries(metadata ?? {}).forEach(([key, value]) => { - searchParams.set(`metadata__${key}`, value); - }); - - let path: string | undefined = `/bot/?${searchParams.toString()}`; - - for ( - let pageIndex = 0; - !isUndefined(path) && pageIndex < RECALL_BOT_LIST_MAX_PAGES; - pageIndex++ - ) { - const result = await recallBotApiRequest({ - config: configResult.config, - path, - method: 'GET', - }); - - if (!result.ok) { - return result; - } - - bots.push(...extractRecallBots(result.data)); - path = extractNextPath(result.data, configResult.config.baseUrl); + if (!isUndefined(joinAtAfter)) { + searchParameters.set('join_at_after', joinAtAfter); } - const truncated = !isUndefined(path); + if (!isUndefined(joinAtBefore)) { + searchParameters.set('join_at_before', joinAtBefore); + } - if (truncated && process.env.NODE_ENV !== 'test') { + Object.entries(metadata ?? {}).forEach(([key, value]) => { + searchParameters.set(`metadata__${key}`, value); + }); + + statuses?.forEach((status) => { + searchParameters.append('status', status); + }); + + const result = await fetchRecallListPages({ + config: configResult.config, + initialPath: `/bot/?${searchParameters.toString()}`, + maxPages: RECALL_BOT_LIST_MAX_PAGES, + extractPageItems: extractRecallBots, + malformedErrorMessage: 'Recall API returned malformed bot list', + }); + + if (!result.ok) { + return result; + } + + if (result.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`, + `[call-recorder] Recall bot list exceeded ${RECALL_BOT_LIST_MAX_PAGES} pages; continuing with ${result.items.length} fetched bots`, ); } - return { ok: true, bots, truncated }; + return { ok: true, bots: result.items, truncated: result.truncated }; }; const extractRecallBots = ( - response: RecallBotListResponse | undefined, + response: RecallListResponse | undefined, ): RecallScheduledBot[] => { if (!Array.isArray(response?.results)) { return []; @@ -102,16 +99,3 @@ const extractRecallBots = ( return [{ ...snapshot, id: snapshot.id }]; }); }; - -const extractNextPath = ( - response: RecallBotListResponse | undefined, - baseUrl: string, -): string | undefined => { - const next = response?.next; - - if (!isString(next) || !next.startsWith(baseUrl)) { - return undefined; - } - - return next.slice(baseUrl.length); -}; 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 5deb889faa..f244480da0 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 @@ -14,6 +14,7 @@ type RecallBotApiRequestArgs = { path: string; method: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: unknown; + idempotencyKey?: string; allowNotFound?: boolean; maxAttempts?: number; }; @@ -30,8 +31,8 @@ type RecallBotApiRequestResult = errorMessage: string; }; -// Bot creates tolerate retries because duplicates stay unclaimed and get canceled. -// Callers that cannot retry idempotently can lower maxAttempts. +// Retried creates provide an idempotency key so ambiguous attempts cannot +// create duplicates. export const recallBotApiRequest = async ( requestArgs: RecallBotApiRequestArgs, ): Promise> => { @@ -70,6 +71,7 @@ const performRecallBotApiRequestAttempt = async ({ path, method, body, + idempotencyKey, allowNotFound = false, }: RecallBotApiRequestArgs): Promise<{ result: RecallBotApiRequestResult; @@ -83,6 +85,9 @@ const performRecallBotApiRequestAttempt = async ({ method, headers: { Authorization: buildRecallApiAuthorizationHeader(config.apiKey), + ...(isUndefined(idempotencyKey) + ? {} + : { 'Idempotency-Key': idempotencyKey }), ...(isUndefined(body) ? {} : { 'Content-Type': 'application/json' }), }, ...(isUndefined(body) ? {} : { body: JSON.stringify(body) }), diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/schedule-recall-bot.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/schedule-recall-bot.util.ts index 2fd7971b12..f2f71f42e4 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/schedule-recall-bot.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/schedule-recall-bot.util.ts @@ -1,3 +1,5 @@ +import { createHash } from 'crypto'; + import { isUndefined } from '@sniptt/guards'; import { getRecallBotAutomaticLeave } from 'src/logic-functions/constants/recall-bot-automatic-leave'; @@ -32,11 +34,17 @@ export const scheduleRecallBot = async ({ } const automaticLeave = getRecallBotAutomaticLeave(); + const idempotencyKey = computeRecallBotCreationIdempotencyKey({ + meetingUrl, + joinAt, + metadata, + }); const result = await recallBotApiRequest({ config: configResult.config, path: '/bot/', method: 'POST', + idempotencyKey, body: { meeting_url: meetingUrl, join_at: joinAt, @@ -72,3 +80,19 @@ export const scheduleRecallBot = async ({ externalBotId, }; }; + +const computeRecallBotCreationIdempotencyKey = ({ + meetingUrl, + joinAt, + metadata, +}: Pick): string => + createHash('sha256') + .update( + JSON.stringify({ + workspaceId: metadata.twentyWorkspaceId, + callRecordingId: metadata.twentyCallRecordingId, + meetingUrl, + joinAt, + }), + ) + .digest('hex'); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/reconcile-stale-bot-state.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/reconcile-stale-bot-state.ts index 8c77e997b8..25814f5d07 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/reconcile-stale-bot-state.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/reconcile-stale-bot-state.ts @@ -6,70 +6,20 @@ import { STALE_BOT_STATE_CRON_PATTERN } from 'src/logic-functions/constants/stal import { convergeDivergedCallRecordings } from 'src/logic-functions/flows/converge-diverged-call-recordings.util'; import { type ConvergeDivergedCallRecordingsResult } from 'src/logic-functions/flows/converge-diverged-call-recordings-result.type'; import { - scheduleRecallBotsForPendingCallRecordings, - type ScheduleRecallBotsForPendingCallRecordingsResult, -} from 'src/logic-functions/flows/schedule-recall-bots-for-pending-call-recordings.util'; -import { - cleanupOrphanedRecallBots, - type CleanupOrphanedRecallBotsResult, -} from 'src/logic-functions/flows/cleanup-orphaned-recall-bots.util'; - -// Every unwanted bot passes through this join_at window before it can attend. -const CLEANUP_JOIN_AT_LOOKBACK_HOURS = 4; -const CLEANUP_JOIN_AT_LOOKAHEAD_HOURS = 24; - -type StepFailure = { error: string }; + buildStepFailure, + type StepFailure, +} from 'src/logic-functions/utils/build-step-failure.util'; const reconcileStaleBotStateHandler = async (): Promise => { const now = new Date(); const client = new CoreApiClient(); - const pendingScheduleResult = await scheduleRecallBotsForPendingCallRecordingsSafely( - client, - now, - ); - const orphanedBotCleanupResult = - await cleanupOrphanedRecallBotsInJoinAtWindow(client, now); const statusConvergenceResult = await convergeDivergedCallRecordingsSafely( client, now, ); - return { - pendingScheduleResult, - orphanedBotCleanupResult, - statusConvergenceResult, - }; -}; - -const scheduleRecallBotsForPendingCallRecordingsSafely = async ( - client: CoreApiClient, - now: Date, -): Promise => { - try { - return await scheduleRecallBotsForPendingCallRecordings({ client, now }); - } catch (error) { - return buildStepFailure('pending Recall bot scheduling', error); - } -}; - -const cleanupOrphanedRecallBotsInJoinAtWindow = async ( - client: CoreApiClient, - now: Date, -): Promise => { - try { - return await cleanupOrphanedRecallBots({ - client, - joinAtAfter: new Date( - now.getTime() - CLEANUP_JOIN_AT_LOOKBACK_HOURS * 60 * 60 * 1000, - ).toISOString(), - joinAtBefore: new Date( - now.getTime() + CLEANUP_JOIN_AT_LOOKAHEAD_HOURS * 60 * 60 * 1000, - ).toISOString(), - }); - } catch (error) { - return buildStepFailure('orphaned bot cancellation', error); - } + return { statusConvergenceResult }; }; const convergeDivergedCallRecordingsSafely = async ( @@ -83,21 +33,11 @@ const convergeDivergedCallRecordingsSafely = async ( } }; -const buildStepFailure = (stepLabel: string, error: unknown): StepFailure => { - const errorMessage = error instanceof Error ? error.message : String(error); - - if (process.env.NODE_ENV !== 'test') { - console.error(`[call-recorder] ${stepLabel} failed: ${errorMessage}`); - } - - return { error: `${stepLabel} failed` }; -}; - export default defineLogicFunction({ universalIdentifier: STALE_BOT_STATE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, name: 'reconcile-stale-bot-state', description: - 'Converges call recordings with Recall on a schedule: pulls stale bot statuses and overdue transcripts, finishes failed cancellations, schedules bots for recordings still missing one, and cancels unclaimed bots. Reads calendar events only to repair already-decided recordings, never to discover meetings.', + 'Converges stale Call Recording status and artifacts with Recall when webhook delivery is missed.', timeoutSeconds: 250, handler: reconcileStaleBotStateHandler, cronTriggerSettings: { diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-artifacts-import-request.type.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-artifacts-import-request.type.ts new file mode 100644 index 0000000000..2c0669135a --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-artifacts-import-request.type.ts @@ -0,0 +1,6 @@ +// Only the local record id crosses the continuation boundary; provider ids are +// re-resolved from the recording's own persisted state so they cannot be forged. +export type CallRecordingArtifactsImportRequest = { + callRecordingId: string; + requestedAt: string; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-record.type.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-record.type.ts index 350d5210d7..0cf11c58fd 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-record.type.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-record.type.ts @@ -6,6 +6,8 @@ export type CallRecordingRecord = { title?: string; status?: string; recordingRequestStatus?: CallRecordingRequestStatus; + createdAt?: string; + updatedAt?: string; startedAt?: string; endedAt?: string; calendarEventId?: string; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-update-fields.type.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-update-fields.type.ts index 2e67fd277f..a465f3e824 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-update-fields.type.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-update-fields.type.ts @@ -19,4 +19,6 @@ export type CallRecordingUpdateFields = Partial<{ audio: CallRecordingMediaFile[]; video: CallRecordingMediaFile[]; summary: CallRecordingSummary; + // null releases the concurrent-import lease. + artifactsImportClaimedAt: string | null; }>; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/build-step-failure.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/build-step-failure.util.ts new file mode 100644 index 0000000000..b467a838de --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/build-step-failure.util.ts @@ -0,0 +1,14 @@ +export type StepFailure = { error: string }; + +export const buildStepFailure = ( + stepLabel: string, + error: unknown, +): StepFailure => { + const errorMessage = error instanceof Error ? error.message : String(error); + + if (process.env.NODE_ENV !== 'test') { + console.error(`[call-recorder] ${stepLabel} failed: ${errorMessage}`); + } + + return { error: `${stepLabel} failed` }; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/normalize-optional-string.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/normalize-optional-string.util.ts new file mode 100644 index 0000000000..1698ea5125 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/utils/normalize-optional-string.util.ts @@ -0,0 +1,5 @@ +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +export const normalizeOptionalString = ( + value: string | null | undefined, +): string | undefined => (isNonEmptyString(value) ? value : undefined);