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);