Reduce Recall bot lifecycle reconciliation traffic (#22908)

## Summary

- split pending Call Recording request maintenance from stale recording
convergence
- recover Recall bots by workspace and Call Recording metadata before
creating a replacement
- retry failed cancellations, including the canceled-plus-botless
write-back race
- move the broad orphaned-bot list sweep from every five minutes to a
dedicated daily job
- filter Recall bot lists by workspace metadata at the provider boundary

## Why

This is stack 1/3 extracted from #22739. Healthy installed workspaces
currently list Recall bots every five minutes even when no local state
has diverged. This layer removes that unconditional list sweep while
keeping pending request recovery at five-minute latency.

The cancellation recovery also closes a crash window where Recall
accepted a bot creation but the local bot ID write-back failed before
the user canceled the request. The maintenance job now rediscovers and
cancels that bot before it can join.

## Stack

1. **Recall bot lifecycle reconciliation** — this PR
2. Divergence-scoped recording synchronization — #22909
3. Artifact import offloading — #22910

## Validation

- `npm run typecheck`
- `npm run lint`
- `npm run test:unit` — 71 files, 454 tests

---------

Co-authored-by: Claude <martmull@hotmail.fr>
This commit is contained in:
nitin
2026-07-17 19:19:28 +05:30
committed by GitHub
parent dc0bb7760f
commit 19f0e3cad5
52 changed files with 3698 additions and 2131 deletions
@@ -1,6 +1,6 @@
{
"name": "@twentyhq/call-recorder",
"version": "1.0.11",
"version": "1.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -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,
@@ -0,0 +1,2 @@
export const CALL_RECORDING_ARTIFACTS_IMPORT_CLAIMED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'bf8ebf7e-4d52-4a7b-8d41-337c53d478ab';
@@ -0,0 +1,2 @@
export const CLEANUP_ORPHANED_RECALL_BOTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'fa394597-dc73-4fee-9758-dea7401b0b8f';
@@ -0,0 +1,2 @@
export const IMPORT_CALL_RECORDING_ARTIFACTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'6b975429-7f0d-4a08-8e5e-5a830e6dc621';
@@ -0,0 +1,2 @@
export const IMPORT_CALL_RECORDING_ARTIFACTS_ROUTE_PATH =
'/call-recorder/import-call-recording-artifacts';
@@ -0,0 +1,2 @@
export const PENDING_CALL_RECORDING_REQUESTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'd7d1170f-abb1-4c9b-8258-13219a611b03';
@@ -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 recordings artifacts; prevents concurrent webhook retries from duplicating provider imports.',
icon: 'IconLock',
isNullable: true,
isUIEditable: false,
});
@@ -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<CallRecordingArtifactsImportRequest> | null,
): RoutePayload<Partial<CallRecordingArtifactsImportRequest>> =>
({
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',
});
});
});
@@ -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',
@@ -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,
},
});
@@ -0,0 +1 @@
export const CLEANUP_ORPHANED_RECALL_BOTS_CRON_PATTERN = '30 4 * * *';
@@ -0,0 +1 @@
export const PENDING_CALL_RECORDING_REQUESTS_CRON_PATTERN = '*/5 * * * *';
@@ -1 +1 @@
export const STALE_BOT_STATE_CRON_PATTERN = '*/5 * * * *';
export const STALE_BOT_STATE_CRON_PATTERN = '*/15 * * * *';
@@ -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,
},
});
});
});
@@ -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<boolean> => {
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<void> => {
await updateCallRecording(client, {
id: callRecordingId,
data: { artifactsImportClaimedAt: null },
});
};
@@ -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 => {
@@ -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<boolean> => {
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;
};
@@ -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<boolean> =>
postToOwnRoute({
path: IMPORT_CALL_RECORDING_ARTIFACTS_ROUTE_PATH,
body: request,
});
@@ -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()
);
};
@@ -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();
});
});
@@ -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<typeof import('node:https')>('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<string, () => 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<string, string> = {
'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([]);
});
@@ -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<string, unknown> }> = [];
constructor(private callRecordings: CallRecordingNode[]) {}
async query(query: any): Promise<any> {
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<any> {
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> = {},
): 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' },
);
});
});
@@ -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, unknown>): 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<Record<string, unknown>> = [];
conditionalMutationFilters: Array<Record<string, unknown>> = [];
mutations: Array<{ id: string; data: Record<string, unknown> }> = [];
constructor(
callRecordings: CallRecordingNode[],
calendarEvents: CalendarEventNode[] = [],
) {
this.callRecordings = callRecordings;
this.calendarEvents = calendarEvents;
}
async query(query: any): Promise<any> {
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<any> {
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 = <Node>(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([]);
});
});
@@ -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, unknown>): 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();
});
@@ -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<AttachExistingRecallBotToCallRecordingResult> => {
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 };
};
@@ -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<CleanupOrphanedRecallBotsResult> => {
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,
};
};
@@ -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<void> => {
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<Map<string, RecallBotSnapshot> | 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 ({
@@ -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<RecallWebhookHandlerResult> => {
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<void> => {
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<MatchedCallRecording | undefined> => {
}): Promise<CallRecordingRecord | undefined> => {
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<string, unknown>,
): Promise<MatchedCallRecording | undefined> => {
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<CallRecordingUpdateFields> => {
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<ExternalRecordingIdResolution> => {
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<ExternalRecordingIdResolution> => {
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<RecallWebhookHandlerResult> => {
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<string, unknown>,
...(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<RecallWebhookHandlerResult> => {
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',
};
};
@@ -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<ImportCallRecordingArtifactsResult> => {
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<RecallBotSnapshot | undefined> => {
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<CallRecordingForArtifactsImport | undefined> => {
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,
};
};
@@ -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);
}
}
@@ -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<void> => {
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 };
};
@@ -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<RetryFailedRecallCancellationsResult> => {
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<string | undefined> => {
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()
);
};
@@ -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 };
};
@@ -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<SyncCallRecordingResult> => {
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;
};
@@ -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<Partial<CallRecordingArtifactsImportRequest>>,
): Promise<ImportCallRecordingArtifactsResult> => {
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<CallRecordingArtifactsImportRequest> | 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,
},
});
@@ -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<object> => {
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<ScheduleRecallBotsForPendingCallRecordingsResult | StepFailure> => {
try {
return await scheduleRecallBotsForPendingCallRecordings({ client, now });
} catch (error) {
return buildStepFailure('pending Recall bot scheduling', error);
}
};
const retryFailedRecallCancellationsSafely = async (
client: CoreApiClient,
now: Date,
): Promise<RetryFailedRecallCancellationsResult | StepFailure> => {
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,
},
});
@@ -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 () => {
@@ -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 <TItem>({
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<RecallListResponse>({
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);
};
@@ -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<FindScheduledRecallBotIdResult> => {
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 };
};
@@ -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);
@@ -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<RecallTranscriptListResponse>({
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);
};
@@ -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<string, string>;
statuses?: string[];
}): Promise<ListScheduledRecallBotsResult> => {
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<RecallBotListResponse>({
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);
};
@@ -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<TData> =
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 <TData>(
requestArgs: RecallBotApiRequestArgs,
): Promise<RecallBotApiRequestResult<TData>> => {
@@ -70,6 +71,7 @@ const performRecallBotApiRequestAttempt = async <TData>({
path,
method,
body,
idempotencyKey,
allowNotFound = false,
}: RecallBotApiRequestArgs): Promise<{
result: RecallBotApiRequestResult<TData>;
@@ -83,6 +85,9 @@ const performRecallBotApiRequestAttempt = async <TData>({
method,
headers: {
Authorization: buildRecallApiAuthorizationHeader(config.apiKey),
...(isUndefined(idempotencyKey)
? {}
: { 'Idempotency-Key': idempotencyKey }),
...(isUndefined(body) ? {} : { 'Content-Type': 'application/json' }),
},
...(isUndefined(body) ? {} : { body: JSON.stringify(body) }),
@@ -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<RecallBotResponse>({
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<ScheduleRecallBotArgs, 'meetingUrl' | 'joinAt' | 'metadata'>): string =>
createHash('sha256')
.update(
JSON.stringify({
workspaceId: metadata.twentyWorkspaceId,
callRecordingId: metadata.twentyCallRecordingId,
meetingUrl,
joinAt,
}),
)
.digest('hex');
@@ -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<object> => {
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<ScheduleRecallBotsForPendingCallRecordingsResult | StepFailure> => {
try {
return await scheduleRecallBotsForPendingCallRecordings({ client, now });
} catch (error) {
return buildStepFailure('pending Recall bot scheduling', error);
}
};
const cleanupOrphanedRecallBotsInJoinAtWindow = async (
client: CoreApiClient,
now: Date,
): Promise<CleanupOrphanedRecallBotsResult | StepFailure> => {
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: {
@@ -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;
};
@@ -6,6 +6,8 @@ export type CallRecordingRecord = {
title?: string;
status?: string;
recordingRequestStatus?: CallRecordingRequestStatus;
createdAt?: string;
updatedAt?: string;
startedAt?: string;
endedAt?: string;
calendarEventId?: string;
@@ -19,4 +19,6 @@ export type CallRecordingUpdateFields = Partial<{
audio: CallRecordingMediaFile[];
video: CallRecordingMediaFile[];
summary: CallRecordingSummary;
// null releases the concurrent-import lease.
artifactsImportClaimedAt: string | null;
}>;
@@ -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` };
};
@@ -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);