Harden call recorder Recall API boundary (#22832)
Part 2/5 of splitting #22739. Stacked on #22831. - `RecallBotSnapshot`: Recall bot payloads are parsed once at the API boundary (`parseRecallBotSnapshot`); `getRecallBot`/`listScheduledRecallBots` return typed snapshots, flows never touch raw provider records - Retry policy: honors `Retry-After` (seconds or HTTP-date, capped at 60s), treats 409 and 507 (ad-hoc pool exhausted) as retryable with tailored delays, adds equal jitter to the linear backoff, and returns instead of sleeping past 10s in-process so invocations never sleep into their timeout - `listScheduledRecallBots` accepts a server-side `metadata__` filter and reports `truncated` instead of failing beyond 10 pages - Extracts `cancelOrEjectRecallBot` into the recall-api layer - Replaces the `CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB` server variable with a fixed 500 MB constant: uploads stream since #22652, so the cap no longer guards function memory and does not need to be operator-tunable Next: billing charge verification, divergence-scoped sync crons, webhook artifact continuation. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22832?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -16,7 +16,6 @@ import { CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME } from 'src/logic-funct
|
||||
import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds';
|
||||
import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds-env-var-name';
|
||||
import { CALL_RECORDER_JOIN_EARLY_MINUTES_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-join-early-minutes-env-var-name';
|
||||
import { CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-max-media-file-size-mb-env-var-name';
|
||||
import { CALL_RECORDER_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-name-env-var-name';
|
||||
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds';
|
||||
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds-env-var-name';
|
||||
@@ -28,7 +27,6 @@ import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS } from 'src/logic-functions/
|
||||
import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds-env-var-name';
|
||||
import { DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND } from 'src/logic-functions/constants/default-call-recorder-bot-image-background';
|
||||
import { DEFAULT_CALL_RECORDER_JOIN_EARLY_MINUTES } from 'src/logic-functions/constants/default-call-recorder-join-early-minutes';
|
||||
import { DEFAULT_CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB } from 'src/logic-functions/constants/default-call-recorder-max-media-file-size-mb';
|
||||
import { DEFAULT_CALL_RECORDER_NAME } from 'src/logic-functions/constants/default-call-recorder-name';
|
||||
import { DEFAULT_CALL_RECORDER_RECORDING_RETENTION_HOURS } from 'src/logic-functions/constants/default-call-recorder-recording-retention-hours';
|
||||
import { DEFAULT_CALL_RECORDER_SUMMARY_ENABLED } from 'src/logic-functions/constants/default-call-recorder-summary-enabled';
|
||||
@@ -150,11 +148,6 @@ export default defineApplication({
|
||||
isSecret: false,
|
||||
type: FieldType.NUMBER,
|
||||
},
|
||||
[CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME]: {
|
||||
description: `Maximum size in megabytes for a single recording media file (video or audio) ingested from Recall.ai. Larger files are skipped and noted in the call recording failure reason; the recording still completes with its remaining artifacts. Defaults to ${DEFAULT_CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB} MB to keep media ingestion within the logic function memory limit.`,
|
||||
isSecret: false,
|
||||
type: FieldType.NUMBER,
|
||||
},
|
||||
[RECALL_WEBHOOK_SECRET_ENV_VAR_NAME]: {
|
||||
description:
|
||||
'Recall.ai webhook signing secret (whsec_...). Set by the server admin from the Recall webhook endpoint settings; used to verify the Svix signature of incoming Recall webhook deliveries.',
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const CALL_RECORDER_MAX_MEDIA_FILE_SIZE_BYTES = 500 * 1024 * 1024;
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
export const CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME =
|
||||
'CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB';
|
||||
-1
@@ -1 +0,0 @@
|
||||
export const DEFAULT_CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB = 80;
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
import { CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-max-media-file-size-mb-env-var-name';
|
||||
import { DEFAULT_CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB } from 'src/logic-functions/constants/default-call-recorder-max-media-file-size-mb';
|
||||
import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util';
|
||||
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
|
||||
|
||||
const BYTES_PER_MEGABYTE = 1024 * 1024;
|
||||
|
||||
export const getMaxMediaFileSizeBytes = (): number => {
|
||||
const configuredMaxMediaFileSizeMb = getApplicationVariableValue(
|
||||
CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME,
|
||||
);
|
||||
|
||||
const maxMediaFileSizeMb = isNonEmptyString(configuredMaxMediaFileSizeMb)
|
||||
? Number(configuredMaxMediaFileSizeMb.trim())
|
||||
: NaN;
|
||||
|
||||
const resolvedMaxMediaFileSizeMb =
|
||||
Number.isFinite(maxMediaFileSizeMb) && maxMediaFileSizeMb > 0
|
||||
? maxMediaFileSizeMb
|
||||
: DEFAULT_CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB;
|
||||
|
||||
return resolvedMaxMediaFileSizeMb * BYTES_PER_MEGABYTE;
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const RECALL_API_ADHOC_POOL_RETRY_DELAY_MS = 10_000;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const RECALL_API_MAX_IN_PROCESS_RETRY_WAIT_MS = 10_000;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const RECALL_API_MAX_RETRY_AFTER_MS = 60_000;
|
||||
+176
-85
@@ -1,36 +1,21 @@
|
||||
import { type CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { cleanupOrphanedRecallBots } from 'src/logic-functions/flows/cleanup-orphaned-recall-bots.util';
|
||||
|
||||
const listScheduledRecallBotsMock = vi.hoisted(() => vi.fn());
|
||||
const cancelRecallBotMock = vi.hoisted(() => vi.fn());
|
||||
const ejectRecallBotMock = vi.hoisted(() => vi.fn());
|
||||
const getCurrentWorkspaceIdMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('src/logic-functions/data/get-current-workspace-id.util', () => ({
|
||||
getCurrentWorkspaceId: getCurrentWorkspaceIdMock,
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
'src/logic-functions/recall-api/list-scheduled-recall-bots.util',
|
||||
() => ({
|
||||
listScheduledRecallBots: listScheduledRecallBotsMock,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock('src/logic-functions/recall-api/cancel-recall-bot.util', () => ({
|
||||
cancelRecallBot: cancelRecallBotMock,
|
||||
}));
|
||||
|
||||
vi.mock('src/logic-functions/recall-api/eject-recall-bot.util', () => ({
|
||||
ejectRecallBot: ejectRecallBotMock,
|
||||
}));
|
||||
|
||||
const JOIN_AT_AFTER = '2026-01-01T08:00:00.000Z';
|
||||
const JOIN_AT_BEFORE = '2026-01-02T12:00:00.000Z';
|
||||
const CURRENT_WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const OTHER_WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174999';
|
||||
const BASE_URL = 'https://us-east-1.recall.ai/api/v1';
|
||||
const ENV_VAR_NAMES = [
|
||||
'RECALL_API_KEY',
|
||||
'RECALL_REGION',
|
||||
'TWENTY_APP_ACCESS_TOKEN',
|
||||
] as const;
|
||||
const ORIGINAL_ENV_VALUES = ENV_VAR_NAMES.map(
|
||||
(envVarName) => [envVarName, process.env[envVarName]] as const,
|
||||
);
|
||||
|
||||
type CallRecordingNode = {
|
||||
id: string;
|
||||
@@ -60,6 +45,13 @@ class FakeCoreApiClient {
|
||||
const buildClient = (callRecordings: CallRecordingNode[]): CoreApiClient =>
|
||||
new FakeCoreApiClient(callRecordings) as unknown as CoreApiClient;
|
||||
|
||||
const buildAccessToken = (payload: Record<string, unknown>): string =>
|
||||
[
|
||||
Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'),
|
||||
Buffer.from(JSON.stringify(payload)).toString('base64url'),
|
||||
'signature',
|
||||
].join('.');
|
||||
|
||||
const buildBot = ({
|
||||
id,
|
||||
twentyCallRecordingId,
|
||||
@@ -90,20 +82,67 @@ const buildCurrentWorkspaceBot = ({
|
||||
});
|
||||
|
||||
describe('cleanupOrphanedRecallBots', () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
const buildJsonResponse = (status: number) => ({
|
||||
ok: status < 400,
|
||||
status,
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
const stubRecallApi = ({
|
||||
bots,
|
||||
cancelStatus = 204,
|
||||
}: {
|
||||
bots: unknown[];
|
||||
cancelStatus?: number;
|
||||
}) => {
|
||||
fetchMock.mockImplementation(async (_url: string, init: RequestInit) => {
|
||||
if (init.method === 'DELETE') {
|
||||
return buildJsonResponse(cancelStatus);
|
||||
}
|
||||
|
||||
if (init.method === 'POST') {
|
||||
return buildJsonResponse(200);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ next: null, results: bots }),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getDeleteCalls = () =>
|
||||
fetchMock.mock.calls.filter(([, init]) => init.method === 'DELETE');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
getCurrentWorkspaceIdMock.mockReset();
|
||||
getCurrentWorkspaceIdMock.mockReturnValue(CURRENT_WORKSPACE_ID);
|
||||
listScheduledRecallBotsMock.mockReset();
|
||||
cancelRecallBotMock.mockReset();
|
||||
cancelRecallBotMock.mockResolvedValue({ ok: true });
|
||||
ejectRecallBotMock.mockReset();
|
||||
ejectRecallBotMock.mockResolvedValue({ ok: true });
|
||||
process.env.RECALL_API_KEY = 'recall-api-key';
|
||||
process.env.RECALL_REGION = 'us-east-1';
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN = buildAccessToken({
|
||||
workspaceId: CURRENT_WORKSPACE_ID,
|
||||
});
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ORIGINAL_ENV_VALUES.forEach(([envVarName, originalValue]) => {
|
||||
if (originalValue === undefined) {
|
||||
delete process.env[envVarName];
|
||||
} else {
|
||||
process.env[envVarName] = originalValue;
|
||||
}
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps bots that their call recording still references', async () => {
|
||||
listScheduledRecallBotsMock.mockResolvedValue({
|
||||
ok: true,
|
||||
stubRecallApi({
|
||||
bots: [
|
||||
buildCurrentWorkspaceBot({
|
||||
id: 'claimed-bot',
|
||||
@@ -126,14 +165,14 @@ describe('cleanupOrphanedRecallBots', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
scannedBotCount: 1,
|
||||
truncatedScan: false,
|
||||
canceledExternalBotIds: [],
|
||||
});
|
||||
expect(cancelRecallBotMock).not.toHaveBeenCalled();
|
||||
expect(getDeleteCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('cancels bots whose call recording request was canceled locally', async () => {
|
||||
listScheduledRecallBotsMock.mockResolvedValue({
|
||||
ok: true,
|
||||
stubRecallApi({
|
||||
bots: [
|
||||
buildCurrentWorkspaceBot({
|
||||
id: 'stale-cancel-bot',
|
||||
@@ -156,16 +195,17 @@ describe('cleanupOrphanedRecallBots', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
scannedBotCount: 1,
|
||||
truncatedScan: false,
|
||||
canceledExternalBotIds: ['stale-cancel-bot'],
|
||||
});
|
||||
expect(cancelRecallBotMock).toHaveBeenCalledWith({
|
||||
externalBotId: 'stale-cancel-bot',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/bot/stale-cancel-bot/`,
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels bots whose call recording references another bot', async () => {
|
||||
listScheduledRecallBotsMock.mockResolvedValue({
|
||||
ok: true,
|
||||
stubRecallApi({
|
||||
bots: [
|
||||
buildCurrentWorkspaceBot({
|
||||
id: 'superseded-bot',
|
||||
@@ -192,17 +232,18 @@ describe('cleanupOrphanedRecallBots', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
scannedBotCount: 2,
|
||||
truncatedScan: false,
|
||||
canceledExternalBotIds: ['superseded-bot'],
|
||||
});
|
||||
expect(cancelRecallBotMock).toHaveBeenCalledTimes(1);
|
||||
expect(cancelRecallBotMock).toHaveBeenCalledWith({
|
||||
externalBotId: 'superseded-bot',
|
||||
});
|
||||
expect(getDeleteCalls()).toHaveLength(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/bot/superseded-bot/`,
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels bots whose call recording no longer exists', async () => {
|
||||
listScheduledRecallBotsMock.mockResolvedValue({
|
||||
ok: true,
|
||||
stubRecallApi({
|
||||
bots: [
|
||||
buildCurrentWorkspaceBot({
|
||||
id: 'orphan-bot',
|
||||
@@ -219,13 +260,13 @@ describe('cleanupOrphanedRecallBots', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
scannedBotCount: 1,
|
||||
truncatedScan: false,
|
||||
canceledExternalBotIds: ['orphan-bot'],
|
||||
});
|
||||
});
|
||||
|
||||
it('grants a grace round to requested recordings without a bot id yet', async () => {
|
||||
listScheduledRecallBotsMock.mockResolvedValue({
|
||||
ok: true,
|
||||
stubRecallApi({
|
||||
bots: [
|
||||
buildCurrentWorkspaceBot({
|
||||
id: 'pending-bot',
|
||||
@@ -248,16 +289,14 @@ describe('cleanupOrphanedRecallBots', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
scannedBotCount: 1,
|
||||
truncatedScan: false,
|
||||
canceledExternalBotIds: [],
|
||||
});
|
||||
expect(cancelRecallBotMock).not.toHaveBeenCalled();
|
||||
expect(getDeleteCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores bots that were not created by this app', async () => {
|
||||
listScheduledRecallBotsMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bots: [buildBot({ id: 'unrelated-bot' })],
|
||||
});
|
||||
stubRecallApi({ bots: [buildBot({ id: 'unrelated-bot' })] });
|
||||
|
||||
const result = await cleanupOrphanedRecallBots({
|
||||
client: buildClient([]),
|
||||
@@ -267,14 +306,14 @@ describe('cleanupOrphanedRecallBots', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
scannedBotCount: 1,
|
||||
truncatedScan: false,
|
||||
canceledExternalBotIds: [],
|
||||
});
|
||||
expect(cancelRecallBotMock).not.toHaveBeenCalled();
|
||||
expect(getDeleteCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores untagged bots even when they carry call recording metadata', async () => {
|
||||
listScheduledRecallBotsMock.mockResolvedValue({
|
||||
ok: true,
|
||||
stubRecallApi({
|
||||
bots: [
|
||||
buildBot({
|
||||
id: 'untagged-bot',
|
||||
@@ -291,14 +330,14 @@ describe('cleanupOrphanedRecallBots', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
scannedBotCount: 1,
|
||||
truncatedScan: false,
|
||||
canceledExternalBotIds: [],
|
||||
});
|
||||
expect(cancelRecallBotMock).not.toHaveBeenCalled();
|
||||
expect(getDeleteCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores bots claimed by another workspace', async () => {
|
||||
listScheduledRecallBotsMock.mockResolvedValue({
|
||||
ok: true,
|
||||
stubRecallApi({
|
||||
bots: [
|
||||
buildBot({
|
||||
id: 'other-workspace-bot',
|
||||
@@ -316,14 +355,14 @@ describe('cleanupOrphanedRecallBots', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
scannedBotCount: 1,
|
||||
truncatedScan: false,
|
||||
canceledExternalBotIds: [],
|
||||
});
|
||||
expect(cancelRecallBotMock).not.toHaveBeenCalled();
|
||||
expect(getDeleteCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('cancels orphaned bots claimed by this workspace', async () => {
|
||||
listScheduledRecallBotsMock.mockResolvedValue({
|
||||
ok: true,
|
||||
stubRecallApi({
|
||||
bots: [
|
||||
buildCurrentWorkspaceBot({
|
||||
id: 'same-workspace-bot',
|
||||
@@ -340,27 +379,73 @@ describe('cleanupOrphanedRecallBots', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
scannedBotCount: 1,
|
||||
truncatedScan: false,
|
||||
canceledExternalBotIds: ['same-workspace-bot'],
|
||||
});
|
||||
expect(cancelRecallBotMock).toHaveBeenCalledWith({
|
||||
externalBotId: 'same-workspace-bot',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/bot/same-workspace-bot/`,
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ejects an orphaned bot that already joined when deletion is rejected', async () => {
|
||||
listScheduledRecallBotsMock.mockResolvedValue({
|
||||
ok: true,
|
||||
// 409s are retryable, so fake timers skip the cancel backoff sleeps.
|
||||
vi.useFakeTimers();
|
||||
stubRecallApi({
|
||||
bots: [
|
||||
buildCurrentWorkspaceBot({
|
||||
id: 'in-call-orphan',
|
||||
twentyCallRecordingId: 'call-recording-gone',
|
||||
}),
|
||||
],
|
||||
cancelStatus: 409,
|
||||
});
|
||||
cancelRecallBotMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 409,
|
||||
errorMessage: 'Recall API responded with HTTP 409',
|
||||
|
||||
const resultPromise = cleanupOrphanedRecallBots({
|
||||
client: buildClient([]),
|
||||
joinAtAfter: JOIN_AT_AFTER,
|
||||
joinAtBefore: JOIN_AT_BEFORE,
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(await resultPromise).toEqual({
|
||||
scannedBotCount: 1,
|
||||
truncatedScan: false,
|
||||
canceledExternalBotIds: ['in-call-orphan'],
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/bot/in-call-orphan/leave_call/`,
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels fetched orphans and flags the sweep as partial when pages remain', async () => {
|
||||
let listCallCount = 0;
|
||||
|
||||
fetchMock.mockImplementation(async (_url: string, init: RequestInit) => {
|
||||
if (init.method === 'DELETE') {
|
||||
return buildJsonResponse(204);
|
||||
}
|
||||
|
||||
listCallCount += 1;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
next: `${BASE_URL}/bot/?cursor=page-${listCallCount + 1}`,
|
||||
results:
|
||||
listCallCount === 1
|
||||
? [
|
||||
buildCurrentWorkspaceBot({
|
||||
id: 'orphan-bot',
|
||||
twentyCallRecordingId: 'call-recording-gone',
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const result = await cleanupOrphanedRecallBots({
|
||||
@@ -371,18 +456,23 @@ describe('cleanupOrphanedRecallBots', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
scannedBotCount: 1,
|
||||
canceledExternalBotIds: ['in-call-orphan'],
|
||||
});
|
||||
expect(ejectRecallBotMock).toHaveBeenCalledWith({
|
||||
externalBotId: 'in-call-orphan',
|
||||
truncatedScan: true,
|
||||
canceledExternalBotIds: ['orphan-bot'],
|
||||
});
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([, init]) => init.method === 'GET'),
|
||||
).toHaveLength(10);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/bot/orphan-bot/`,
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports nothing canceled when listing Recall bots fails', async () => {
|
||||
listScheduledRecallBotsMock.mockResolvedValue({
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
errorMessage: 'Recall API responded with HTTP 500',
|
||||
status: 400,
|
||||
json: async () => ({ detail: 'bad request' }),
|
||||
});
|
||||
|
||||
const result = await cleanupOrphanedRecallBots({
|
||||
@@ -393,15 +483,15 @@ describe('cleanupOrphanedRecallBots', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
scannedBotCount: 0,
|
||||
truncatedScan: false,
|
||||
canceledExternalBotIds: [],
|
||||
});
|
||||
expect(cancelRecallBotMock).not.toHaveBeenCalled();
|
||||
expect(getDeleteCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips cancellation when the current workspace cannot be resolved', async () => {
|
||||
getCurrentWorkspaceIdMock.mockReturnValue(undefined);
|
||||
listScheduledRecallBotsMock.mockResolvedValue({
|
||||
ok: true,
|
||||
delete process.env.TWENTY_APP_ACCESS_TOKEN;
|
||||
stubRecallApi({
|
||||
bots: [
|
||||
buildCurrentWorkspaceBot({
|
||||
id: 'same-workspace-bot',
|
||||
@@ -418,8 +508,9 @@ describe('cleanupOrphanedRecallBots', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
scannedBotCount: 1,
|
||||
truncatedScan: false,
|
||||
canceledExternalBotIds: [],
|
||||
});
|
||||
expect(cancelRecallBotMock).not.toHaveBeenCalled();
|
||||
expect(getDeleteCalls()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
+53
-51
@@ -125,16 +125,16 @@ describe('convergeDivergedCallRecordings', () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: {
|
||||
status_changes: [
|
||||
{ code: 'in_call_recording', created_at: '2026-06-09T13:02:30.000Z' },
|
||||
{ code: 'call_ended', created_at: '2026-06-09T14:00:30.000Z' },
|
||||
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
|
||||
statusChanges: [
|
||||
{ code: 'in_call_recording', createdAt: '2026-06-09T13:02:30.000Z' },
|
||||
{ code: 'call_ended', createdAt: '2026-06-09T14:00:30.000Z' },
|
||||
{ code: 'done', createdAt: '2026-06-09T14:05:00.000Z' },
|
||||
],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-1',
|
||||
started_at: '2026-06-09T13:02:00.000Z',
|
||||
completed_at: '2026-06-09T14:00:00.000Z',
|
||||
startedAt: '2026-06-09T13:02:00.000Z',
|
||||
completedAt: '2026-06-09T14:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -187,8 +187,8 @@ describe('convergeDivergedCallRecordings', () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: {
|
||||
status_changes: [
|
||||
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
|
||||
statusChanges: [
|
||||
{ code: 'done', createdAt: '2026-06-09T14:05:00.000Z' },
|
||||
],
|
||||
recordings: [],
|
||||
},
|
||||
@@ -218,14 +218,14 @@ describe('convergeDivergedCallRecordings', () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: {
|
||||
status_changes: [
|
||||
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
|
||||
statusChanges: [
|
||||
{ code: 'done', createdAt: '2026-06-09T14:05:00.000Z' },
|
||||
],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-1',
|
||||
started_at: '2026-06-09T13:02:00.000Z',
|
||||
completed_at: '2026-06-09T14:00:00.000Z',
|
||||
startedAt: '2026-06-09T13:02:00.000Z',
|
||||
completedAt: '2026-06-09T14:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -283,14 +283,14 @@ describe('convergeDivergedCallRecordings', () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: {
|
||||
status_changes: [
|
||||
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
|
||||
statusChanges: [
|
||||
{ code: 'done', createdAt: '2026-06-09T14:05:00.000Z' },
|
||||
],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-1',
|
||||
started_at: '2026-06-09T13:02:00.000Z',
|
||||
completed_at: '2026-06-09T14:00:00.000Z',
|
||||
startedAt: '2026-06-09T13:02:00.000Z',
|
||||
completedAt: '2026-06-09T14:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -339,14 +339,14 @@ describe('convergeDivergedCallRecordings', () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: {
|
||||
status_changes: [
|
||||
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
|
||||
statusChanges: [
|
||||
{ code: 'done', createdAt: '2026-06-09T14:05:00.000Z' },
|
||||
],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-1',
|
||||
started_at: '2026-06-09T13:02:00.000Z',
|
||||
completed_at: '2026-06-09T14:00:00.000Z',
|
||||
startedAt: '2026-06-09T13:02:00.000Z',
|
||||
completedAt: '2026-06-09T14:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -392,14 +392,14 @@ describe('convergeDivergedCallRecordings', () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: {
|
||||
status_changes: [
|
||||
{ code: 'fatal', created_at: '2026-06-09T14:05:00.000Z' },
|
||||
statusChanges: [
|
||||
{ code: 'fatal', createdAt: '2026-06-09T14:05:00.000Z' },
|
||||
],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-1',
|
||||
started_at: '2026-06-09T13:02:00.000Z',
|
||||
completed_at: '2026-06-09T14:00:00.000Z',
|
||||
startedAt: '2026-06-09T13:02:00.000Z',
|
||||
completedAt: '2026-06-09T14:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -462,14 +462,14 @@ describe('convergeDivergedCallRecordings', () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: {
|
||||
status_changes: [
|
||||
{ code: 'done', created_at: '2026-06-10T11:30:00.000Z' },
|
||||
statusChanges: [
|
||||
{ code: 'done', createdAt: '2026-06-10T11:30:00.000Z' },
|
||||
],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-1',
|
||||
started_at: '2026-06-10T11:05:00.000Z',
|
||||
completed_at: '2026-06-10T11:25:00.000Z',
|
||||
startedAt: '2026-06-10T11:05:00.000Z',
|
||||
completedAt: '2026-06-10T11:25:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -566,9 +566,10 @@ describe('convergeDivergedCallRecordings', () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: {
|
||||
status_changes: [
|
||||
{ code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' },
|
||||
statusChanges: [
|
||||
{ code: 'in_call_recording', createdAt: '2026-06-09T13:02:00.000Z' },
|
||||
],
|
||||
recordings: [],
|
||||
},
|
||||
});
|
||||
const client = buildClient([
|
||||
@@ -593,11 +594,11 @@ describe('convergeDivergedCallRecordings', () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: {
|
||||
status_changes: [
|
||||
{ code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' },
|
||||
statusChanges: [
|
||||
{ code: 'in_call_recording', createdAt: '2026-06-09T13:02:00.000Z' },
|
||||
],
|
||||
recordings: [
|
||||
{ id: 'recall-recording-1', started_at: '2026-06-09T13:02:00.000Z' },
|
||||
{ id: 'recall-recording-1', startedAt: '2026-06-09T13:02:00.000Z' },
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -625,14 +626,14 @@ describe('convergeDivergedCallRecordings', () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: {
|
||||
status_changes: [
|
||||
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
|
||||
statusChanges: [
|
||||
{ code: 'done', createdAt: '2026-06-09T14:05:00.000Z' },
|
||||
],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-1',
|
||||
started_at: '2026-06-09T13:02:00.000Z',
|
||||
completed_at: '2026-06-09T14:00:00.000Z',
|
||||
startedAt: '2026-06-09T13:02:00.000Z',
|
||||
completedAt: '2026-06-09T14:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -676,14 +677,14 @@ describe('convergeDivergedCallRecordings', () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: {
|
||||
status_changes: [
|
||||
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
|
||||
statusChanges: [
|
||||
{ code: 'done', createdAt: '2026-06-09T14:05:00.000Z' },
|
||||
],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-1',
|
||||
started_at: '2026-06-09T13:02:00.000Z',
|
||||
completed_at: '2026-06-09T14:00:00.000Z',
|
||||
startedAt: '2026-06-09T13:02:00.000Z',
|
||||
completedAt: '2026-06-09T14:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -732,14 +733,14 @@ describe('convergeDivergedCallRecordings', () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: {
|
||||
status_changes: [
|
||||
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
|
||||
statusChanges: [
|
||||
{ code: 'done', createdAt: '2026-06-09T14:05:00.000Z' },
|
||||
],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-1',
|
||||
started_at: '2026-06-09T13:02:00.000Z',
|
||||
completed_at: '2026-06-09T14:00:00.000Z',
|
||||
startedAt: '2026-06-09T13:02:00.000Z',
|
||||
completedAt: '2026-06-09T14:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -805,14 +806,14 @@ describe('convergeDivergedCallRecordings', () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: {
|
||||
status_changes: [
|
||||
{ code: 'done', created_at: '2026-06-09T14:05:00.000Z' },
|
||||
statusChanges: [
|
||||
{ code: 'done', createdAt: '2026-06-09T14:05:00.000Z' },
|
||||
],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-1',
|
||||
started_at: '2026-06-09T13:02:00.000Z',
|
||||
completed_at: '2026-06-09T14:00:00.000Z',
|
||||
startedAt: '2026-06-09T13:02:00.000Z',
|
||||
completedAt: '2026-06-09T14:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -864,9 +865,10 @@ describe('convergeDivergedCallRecordings', () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: {
|
||||
status_changes: [
|
||||
{ code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' },
|
||||
statusChanges: [
|
||||
{ code: 'in_call_recording', createdAt: '2026-06-09T13:02:00.000Z' },
|
||||
],
|
||||
recordings: [],
|
||||
},
|
||||
});
|
||||
const client = buildClient([
|
||||
|
||||
+8
-5
@@ -829,6 +829,9 @@ describe('handleRecallWebhook', () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: {
|
||||
id: 'recall-bot-1',
|
||||
metadata: {},
|
||||
statusChanges: [],
|
||||
recordings: [{ id: 'recall-recording-9' }],
|
||||
},
|
||||
});
|
||||
@@ -885,7 +888,7 @@ describe('handleRecallWebhook', () => {
|
||||
it('imports media on recording.done and completes once all artifacts are present', async () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: { id: 'recall-bot-1' },
|
||||
bot: { id: 'recall-bot-1', metadata: {}, statusChanges: [], recordings: [] },
|
||||
});
|
||||
importCallRecordingMediaMock.mockResolvedValue({
|
||||
audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }],
|
||||
@@ -939,7 +942,7 @@ describe('handleRecallWebhook', () => {
|
||||
it('completes and keeps the size marker when a media file is too large', async () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: { id: 'recall-bot-1' },
|
||||
bot: { id: 'recall-bot-1', metadata: {}, statusChanges: [], recordings: [] },
|
||||
});
|
||||
importCallRecordingMediaMock.mockResolvedValue({
|
||||
audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }],
|
||||
@@ -993,7 +996,7 @@ describe('handleRecallWebhook', () => {
|
||||
it('keeps the real failure reason over the size marker on recording.failed', async () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: { id: 'recall-bot-1' },
|
||||
bot: { id: 'recall-bot-1', metadata: {}, statusChanges: [], recordings: [] },
|
||||
});
|
||||
importCallRecordingMediaMock.mockResolvedValue({
|
||||
audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }],
|
||||
@@ -1043,7 +1046,7 @@ describe('handleRecallWebhook', () => {
|
||||
it('stays PROCESSING on recording.done while artifacts are missing', async () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: { id: 'recall-bot-1' },
|
||||
bot: { id: 'recall-bot-1', metadata: {}, statusChanges: [], recordings: [] },
|
||||
});
|
||||
importCallRecordingMediaMock.mockResolvedValue({
|
||||
audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }],
|
||||
@@ -1088,7 +1091,7 @@ describe('handleRecallWebhook', () => {
|
||||
it('marks FAILED on recording.done when no recording artifact path exists', async () => {
|
||||
getRecallBotMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: { id: 'recall-bot-1', recordings: [] },
|
||||
bot: { id: 'recall-bot-1', metadata: {}, statusChanges: [], recordings: [] },
|
||||
});
|
||||
const client = new FakeCoreApiClient([
|
||||
{
|
||||
|
||||
+15
-47
@@ -1,7 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-video-field-universal-identifier';
|
||||
import { CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-max-media-file-size-mb-env-var-name';
|
||||
import { importCallRecordingMedia } from 'src/logic-functions/flows/import-call-recording-media.util';
|
||||
|
||||
const mutationMock = vi.hoisted(() => vi.fn());
|
||||
@@ -84,21 +83,19 @@ const stubFetch = ({
|
||||
downloadsByUrl: Record<string, unknown>;
|
||||
}) => {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockImplementation(
|
||||
(url: string, init?: { method?: string }) => {
|
||||
if (init?.method === 'PUT') {
|
||||
throw new Error('Upload requests should go through the upload bridge');
|
||||
}
|
||||
fetchMock.mockImplementation((url: string, init?: { method?: string }) => {
|
||||
if (init?.method === 'PUT') {
|
||||
throw new Error('Upload requests should go through the upload bridge');
|
||||
}
|
||||
|
||||
const downloadResponse = downloadsByUrl[url];
|
||||
const downloadResponse = downloadsByUrl[url];
|
||||
|
||||
if (downloadResponse === undefined) {
|
||||
throw new Error(`Unhandled fetch url in test: ${url}`);
|
||||
}
|
||||
if (downloadResponse === undefined) {
|
||||
throw new Error(`Unhandled fetch url in test: ${url}`);
|
||||
}
|
||||
|
||||
return Promise.resolve(downloadResponse);
|
||||
},
|
||||
);
|
||||
return Promise.resolve(downloadResponse);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
};
|
||||
@@ -175,7 +172,6 @@ describe('importCallRecordingMedia', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('streams and uploads every missing artifact', async () => {
|
||||
@@ -381,7 +377,7 @@ describe('importCallRecordingMedia', () => {
|
||||
stubFetch({
|
||||
downloadsByUrl: {
|
||||
[VIDEO_URL]: buildDownloadResponse({
|
||||
contentLengthBytes: 200 * 1024 * 1024,
|
||||
contentLengthBytes: 500 * 1024 * 1024 + 1,
|
||||
body: { cancel: cancelMock },
|
||||
}),
|
||||
[AUDIO_URL]: buildDownloadResponse({ contentLengthBytes: 8 }),
|
||||
@@ -413,10 +409,10 @@ describe('importCallRecordingMedia', () => {
|
||||
stubFetch({
|
||||
downloadsByUrl: {
|
||||
[VIDEO_URL]: buildDownloadResponse({
|
||||
contentLengthBytes: 200 * 1024 * 1024,
|
||||
contentLengthBytes: 500 * 1024 * 1024 + 1,
|
||||
}),
|
||||
[AUDIO_URL]: buildDownloadResponse({
|
||||
contentLengthBytes: 120 * 1024 * 1024,
|
||||
contentLengthBytes: 500 * 1024 * 1024 + 1,
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -434,39 +430,11 @@ describe('importCallRecordingMedia', () => {
|
||||
expect(mutationMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('honors the cap configured through the environment', async () => {
|
||||
vi.stubEnv(CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME, '1');
|
||||
it('accepts a file at the 500 MB cap', async () => {
|
||||
stubFetch({
|
||||
downloadsByUrl: {
|
||||
[VIDEO_URL]: buildDownloadResponse({
|
||||
contentLengthBytes: 2 * 1024 * 1024,
|
||||
}),
|
||||
[AUDIO_URL]: buildDownloadResponse({ contentLengthBytes: 8 }),
|
||||
},
|
||||
});
|
||||
|
||||
const updateFields = await importCallRecordingMedia({
|
||||
callRecordingId: 'call-recording-1',
|
||||
externalRecordingId: 'recall-recording-1',
|
||||
hasAudio: false,
|
||||
hasVideo: false,
|
||||
});
|
||||
|
||||
expect(updateFields).toEqual({
|
||||
audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }],
|
||||
callRecorderFailureReason: 'video_file_too_large',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the default cap when the configured value is invalid', async () => {
|
||||
vi.stubEnv(
|
||||
CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME,
|
||||
'not-a-number',
|
||||
);
|
||||
stubFetch({
|
||||
downloadsByUrl: {
|
||||
[VIDEO_URL]: buildDownloadResponse({
|
||||
contentLengthBytes: 2 * 1024 * 1024,
|
||||
contentLengthBytes: 500 * 1024 * 1024,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
+11
-29
@@ -1,10 +1,9 @@
|
||||
import { isNull, isUndefined } from '@sniptt/guards';
|
||||
import { isUndefined } from '@sniptt/guards';
|
||||
import { type CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
|
||||
import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type';
|
||||
import { cancelRecallBot } from 'src/logic-functions/recall-api/cancel-recall-bot.util';
|
||||
import { ejectRecallBot } from 'src/logic-functions/recall-api/eject-recall-bot.util';
|
||||
import { cancelOrEjectRecallBot } from 'src/logic-functions/recall-api/cancel-or-eject-recall-bot.util';
|
||||
import { findCallRecordingsByIds } from 'src/logic-functions/data/find-call-recordings-by-ids.util';
|
||||
import { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.util';
|
||||
import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util';
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
export type CleanupOrphanedRecallBotsResult = {
|
||||
scannedBotCount: number;
|
||||
canceledExternalBotIds: string[];
|
||||
truncatedScan: boolean;
|
||||
};
|
||||
|
||||
// Bots no open CallRecording request claims would still join; cancel them on Recall.
|
||||
@@ -39,7 +39,11 @@ export const cleanupOrphanedRecallBots = async ({
|
||||
`[call-recorder] failed to list Recall bots for orphan cancellation: ${listResult.errorMessage}`,
|
||||
);
|
||||
|
||||
return { scannedBotCount: 0, canceledExternalBotIds: [] };
|
||||
return {
|
||||
scannedBotCount: 0,
|
||||
canceledExternalBotIds: [],
|
||||
truncatedScan: false,
|
||||
};
|
||||
}
|
||||
|
||||
const currentWorkspaceId = getCurrentWorkspaceId();
|
||||
@@ -52,6 +56,7 @@ export const cleanupOrphanedRecallBots = async ({
|
||||
return {
|
||||
scannedBotCount: listResult.bots.length,
|
||||
canceledExternalBotIds: [],
|
||||
truncatedScan: listResult.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,6 +68,7 @@ export const cleanupOrphanedRecallBots = async ({
|
||||
return {
|
||||
scannedBotCount: listResult.bots.length,
|
||||
canceledExternalBotIds: [],
|
||||
truncatedScan: listResult.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,6 +105,7 @@ export const cleanupOrphanedRecallBots = async ({
|
||||
return {
|
||||
scannedBotCount: listResult.bots.length,
|
||||
canceledExternalBotIds,
|
||||
truncatedScan: listResult.truncated,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -154,30 +161,5 @@ const isBotClaimed = ({
|
||||
return isUndefined(callRecording.externalBotId);
|
||||
};
|
||||
|
||||
const cancelOrEjectRecallBot = async (
|
||||
externalBotId: string,
|
||||
): Promise<boolean> => {
|
||||
const cancelResult = await cancelRecallBot({ externalBotId });
|
||||
|
||||
if (cancelResult.ok) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Deleting only works for not-yet-joined bots; eject the ones already in a call.
|
||||
if (!isNull(cancelResult.status)) {
|
||||
const ejectResult = await ejectRecallBot({ externalBotId });
|
||||
|
||||
if (ejectResult.ok) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[call-recorder] failed to cancel orphaned Recall bot ${externalBotId}: ${cancelResult.errorMessage}`,
|
||||
);
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const normalizeOptionalString = (value: unknown): string | undefined =>
|
||||
isNonEmptyString(value) ? value.trim() : undefined;
|
||||
|
||||
+11
-7
@@ -3,7 +3,7 @@ import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||||
|
||||
import { CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-audio-field-universal-identifier';
|
||||
import { CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-video-field-universal-identifier';
|
||||
import { getMaxMediaFileSizeBytes } from 'src/logic-functions/constants/get-max-media-file-size-bytes';
|
||||
import { CALL_RECORDER_MAX_MEDIA_FILE_SIZE_BYTES } from 'src/logic-functions/constants/call-recorder-max-media-file-size-bytes';
|
||||
import {
|
||||
AUDIO_FILE_TOO_LARGE_FAILURE_REASON,
|
||||
VIDEO_FILE_TOO_LARGE_FAILURE_REASON,
|
||||
@@ -65,8 +65,6 @@ export const importCallRecordingMedia = async ({
|
||||
|
||||
const mediaUrls = extractRecallMediaUrls(recordingResult.recording);
|
||||
const metadataClient = new MetadataApiClient();
|
||||
// TODO: raise this cap via config, monitor streamed uploads in prod, then remove the cap once verified.
|
||||
const maxMediaFileSizeBytes = getMaxMediaFileSizeBytes();
|
||||
const updateFields: CallRecordingMediaUpdateFields = {};
|
||||
const tooLargeFailureReasons: string[] = [];
|
||||
|
||||
@@ -78,7 +76,7 @@ export const importCallRecordingMedia = async ({
|
||||
fileName: 'video.mp4',
|
||||
fieldMetadataUniversalIdentifier:
|
||||
CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
maxMediaFileSizeBytes,
|
||||
maxMediaFileSizeBytes: CALL_RECORDER_MAX_MEDIA_FILE_SIZE_BYTES,
|
||||
});
|
||||
|
||||
if (video.outcome === 'imported') {
|
||||
@@ -98,7 +96,7 @@ export const importCallRecordingMedia = async ({
|
||||
fileName: 'audio.mp3',
|
||||
fieldMetadataUniversalIdentifier:
|
||||
CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
maxMediaFileSizeBytes,
|
||||
maxMediaFileSizeBytes: CALL_RECORDER_MAX_MEDIA_FILE_SIZE_BYTES,
|
||||
});
|
||||
|
||||
if (audio.outcome === 'imported') {
|
||||
@@ -226,7 +224,11 @@ const openMediaDownload = async ({
|
||||
throw new Error('download returned no body');
|
||||
}
|
||||
|
||||
return { outcome: 'opened', body: response.body, sizeBytes: contentLengthBytes };
|
||||
return {
|
||||
outcome: 'opened',
|
||||
body: response.body,
|
||||
sizeBytes: contentLengthBytes,
|
||||
};
|
||||
};
|
||||
|
||||
const uploadMediaStreamToStorage = async ({
|
||||
@@ -320,7 +322,9 @@ const createFileUploadTarget = async ({
|
||||
const uploadTarget = mutationResult.createFileUpload;
|
||||
|
||||
if (isUndefined(uploadTarget)) {
|
||||
throw new Error('createFileUpload mutation did not return an upload target');
|
||||
throw new Error(
|
||||
'createFileUpload mutation did not return an upload target',
|
||||
);
|
||||
}
|
||||
|
||||
return uploadTarget;
|
||||
|
||||
+96
-86
@@ -1,49 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { extractRecallBotSyncState } from 'src/logic-functions/recall-api/extract-recall-bot-sync-state.util';
|
||||
import { type RecallBotSnapshot } from 'src/logic-functions/recall-api/recall-bot-snapshot.type';
|
||||
|
||||
const buildRecallBotSnapshot = (
|
||||
overrides: Partial<RecallBotSnapshot> = {},
|
||||
): RecallBotSnapshot => ({
|
||||
id: 'recall-bot-1',
|
||||
metadata: {},
|
||||
statusChanges: [],
|
||||
recordings: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('extractRecallBotSyncState', () => {
|
||||
it('maps the latest status change code to a call recording status', () => {
|
||||
const syncState = extractRecallBotSyncState({
|
||||
status_changes: [
|
||||
{ code: 'joining_call', created_at: '2026-01-01T12:58:00.000Z' },
|
||||
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
|
||||
{ code: 'call_ended', created_at: '2026-01-01T14:00:00.000Z' },
|
||||
{ code: 'done', created_at: '2026-01-01T14:05:00.000Z' },
|
||||
],
|
||||
});
|
||||
const syncState = extractRecallBotSyncState(
|
||||
buildRecallBotSnapshot({
|
||||
statusChanges: [
|
||||
{ code: 'joining_call', createdAt: '2026-01-01T12:58:00.000Z' },
|
||||
{ code: 'in_call_recording', createdAt: '2026-01-01T13:02:00.000Z' },
|
||||
{ code: 'call_ended', createdAt: '2026-01-01T14:00:00.000Z' },
|
||||
{ code: 'done', createdAt: '2026-01-01T14:05:00.000Z' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// COMPLETED is reserved for full artifact import, never bot state.
|
||||
expect(syncState.status).toBe('PROCESSING');
|
||||
expect(syncState.isRecallRecordingDone).toBe(true);
|
||||
});
|
||||
|
||||
it('uses created_at to find the latest status when Recall returns status changes out of order', () => {
|
||||
const syncState = extractRecallBotSyncState({
|
||||
status_changes: [
|
||||
{ code: 'done', created_at: '2026-01-01T14:05:00.000Z' },
|
||||
{ code: 'joining_call', created_at: '2026-01-01T12:58:00.000Z' },
|
||||
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
|
||||
],
|
||||
});
|
||||
it('uses createdAt to find the latest status when Recall returns status changes out of order', () => {
|
||||
const syncState = extractRecallBotSyncState(
|
||||
buildRecallBotSnapshot({
|
||||
statusChanges: [
|
||||
{ code: 'done', createdAt: '2026-01-01T14:05:00.000Z' },
|
||||
{ code: 'joining_call', createdAt: '2026-01-01T12:58:00.000Z' },
|
||||
{ code: 'in_call_recording', createdAt: '2026-01-01T13:02:00.000Z' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(syncState.status).toBe('PROCESSING');
|
||||
});
|
||||
|
||||
it('prefers recording-object timestamps over status change entries', () => {
|
||||
const syncState = extractRecallBotSyncState({
|
||||
status_changes: [
|
||||
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:30.000Z' },
|
||||
{ code: 'call_ended', created_at: '2026-01-01T14:00:30.000Z' },
|
||||
],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-1',
|
||||
started_at: '2026-01-01T13:02:00.000Z',
|
||||
completed_at: '2026-01-01T14:00:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
const syncState = extractRecallBotSyncState(
|
||||
buildRecallBotSnapshot({
|
||||
statusChanges: [
|
||||
{ code: 'in_call_recording', createdAt: '2026-01-01T13:02:30.000Z' },
|
||||
{ code: 'call_ended', createdAt: '2026-01-01T14:00:30.000Z' },
|
||||
],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-1',
|
||||
startedAt: '2026-01-01T13:02:00.000Z',
|
||||
completedAt: '2026-01-01T14:00:00.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(syncState).toEqual({
|
||||
status: 'PROCESSING',
|
||||
@@ -56,13 +73,21 @@ describe('extractRecallBotSyncState', () => {
|
||||
});
|
||||
|
||||
it('falls back to status change timestamps when recordings carry none', () => {
|
||||
const syncState = extractRecallBotSyncState({
|
||||
status_changes: [
|
||||
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
|
||||
{ code: 'call_ended', created_at: '2026-01-01T14:00:00.000Z' },
|
||||
],
|
||||
recordings: [{ id: 'recall-recording-1' }],
|
||||
});
|
||||
const syncState = extractRecallBotSyncState(
|
||||
buildRecallBotSnapshot({
|
||||
statusChanges: [
|
||||
{ code: 'in_call_recording', createdAt: '2026-01-01T13:02:00.000Z' },
|
||||
{ code: 'call_ended', createdAt: '2026-01-01T14:00:00.000Z' },
|
||||
],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-1',
|
||||
startedAt: undefined,
|
||||
completedAt: undefined,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(syncState).toEqual({
|
||||
status: 'PROCESSING',
|
||||
@@ -75,25 +100,27 @@ describe('extractRecallBotSyncState', () => {
|
||||
});
|
||||
|
||||
it('normalizes microsecond-precision Recall timestamps to millisecond ISO', () => {
|
||||
const syncState = extractRecallBotSyncState({
|
||||
status_changes: [
|
||||
{ code: 'done', created_at: '2026-06-10T12:20:00.123456+00:00' },
|
||||
],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-1',
|
||||
started_at: '2026-06-10T11:02:28.281597+00:00',
|
||||
completed_at: '2026-06-10T12:17:28.281597+00:00',
|
||||
},
|
||||
],
|
||||
});
|
||||
const syncState = extractRecallBotSyncState(
|
||||
buildRecallBotSnapshot({
|
||||
statusChanges: [
|
||||
{ code: 'done', createdAt: '2026-06-10T12:20:00.123456+00:00' },
|
||||
],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-1',
|
||||
startedAt: '2026-06-10T11:02:28.281597+00:00',
|
||||
completedAt: '2026-06-10T12:17:28.281597+00:00',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(syncState.startedAt).toBe('2026-06-10T11:02:28.281Z');
|
||||
expect(syncState.endedAt).toBe('2026-06-10T12:17:28.281Z');
|
||||
});
|
||||
|
||||
it('returns nothing derivable from an empty bot response', () => {
|
||||
expect(extractRecallBotSyncState({})).toEqual({
|
||||
it('returns nothing derivable from an empty bot snapshot', () => {
|
||||
expect(extractRecallBotSyncState(buildRecallBotSnapshot())).toEqual({
|
||||
status: undefined,
|
||||
failureReason: undefined,
|
||||
startedAt: undefined,
|
||||
@@ -103,49 +130,32 @@ describe('extractRecallBotSyncState', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('skips malformed status change entries', () => {
|
||||
const syncState = extractRecallBotSyncState({
|
||||
status_changes: [
|
||||
null,
|
||||
'not-an-object',
|
||||
{ created_at: '2026-01-01T13:00:00.000Z' },
|
||||
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
|
||||
],
|
||||
recordings: 'not-an-array',
|
||||
});
|
||||
|
||||
expect(syncState).toEqual({
|
||||
status: 'RECORDING',
|
||||
failureReason: undefined,
|
||||
startedAt: '2026-01-01T13:02:00.000Z',
|
||||
endedAt: undefined,
|
||||
externalRecordingId: undefined,
|
||||
isRecallRecordingDone: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries the failing Recall status code as the failure reason', () => {
|
||||
const syncState = extractRecallBotSyncState({
|
||||
status_changes: [
|
||||
{ code: 'joining_call', created_at: '2026-01-01T12:58:00.000Z' },
|
||||
{
|
||||
code: 'recording_permission_denied',
|
||||
created_at: '2026-01-01T13:02:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
const syncState = extractRecallBotSyncState(
|
||||
buildRecallBotSnapshot({
|
||||
statusChanges: [
|
||||
{ code: 'joining_call', createdAt: '2026-01-01T12:58:00.000Z' },
|
||||
{
|
||||
code: 'recording_permission_denied',
|
||||
createdAt: '2026-01-01T13:02:00.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(syncState.status).toBe('FAILED');
|
||||
expect(syncState.failureReason).toBe('recording_permission_denied');
|
||||
});
|
||||
|
||||
it('leaves the status undefined for unknown latest codes', () => {
|
||||
const syncState = extractRecallBotSyncState({
|
||||
status_changes: [
|
||||
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
|
||||
{ code: 'some_future_code', created_at: '2026-01-01T13:30:00.000Z' },
|
||||
],
|
||||
});
|
||||
const syncState = extractRecallBotSyncState(
|
||||
buildRecallBotSnapshot({
|
||||
statusChanges: [
|
||||
{ code: 'in_call_recording', createdAt: '2026-01-01T13:02:00.000Z' },
|
||||
{ code: 'some_future_code', createdAt: '2026-01-01T13:30:00.000Z' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(syncState.status).toBeUndefined();
|
||||
expect(syncState.startedAt).toBe('2026-01-01T13:02:00.000Z');
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseRecallBotSnapshot } from 'src/logic-functions/recall-api/parse-recall-bot-snapshot.util';
|
||||
|
||||
describe('parseRecallBotSnapshot', () => {
|
||||
it('parses id, metadata, status changes and recordings from a bot payload', () => {
|
||||
expect(
|
||||
parseRecallBotSnapshot({
|
||||
id: 'recall-bot-1',
|
||||
metadata: { twentyWorkspaceId: 'workspace-1' },
|
||||
status_changes: [
|
||||
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
|
||||
],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-1',
|
||||
started_at: '2026-01-01T13:02:00.000Z',
|
||||
completed_at: '2026-01-01T14:00:00.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'recall-bot-1',
|
||||
metadata: { twentyWorkspaceId: 'workspace-1' },
|
||||
statusChanges: [
|
||||
{ code: 'in_call_recording', createdAt: '2026-01-01T13:02:00.000Z' },
|
||||
],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-1',
|
||||
startedAt: '2026-01-01T13:02:00.000Z',
|
||||
completedAt: '2026-01-01T14:00:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty snapshot shell from an empty payload', () => {
|
||||
expect(parseRecallBotSnapshot({})).toEqual({
|
||||
id: undefined,
|
||||
metadata: {},
|
||||
statusChanges: [],
|
||||
recordings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('skips malformed recording entries and keeps recordings without timestamps', () => {
|
||||
expect(
|
||||
parseRecallBotSnapshot({
|
||||
recordings: [null, 'not-a-recording', { id: 'recall-recording-1' }],
|
||||
}),
|
||||
).toEqual({
|
||||
id: undefined,
|
||||
metadata: {},
|
||||
statusChanges: [],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-1',
|
||||
startedAt: undefined,
|
||||
completedAt: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('skips malformed status change entries and non-array recordings', () => {
|
||||
expect(
|
||||
parseRecallBotSnapshot({
|
||||
status_changes: [
|
||||
null,
|
||||
'not-an-object',
|
||||
{ created_at: '2026-01-01T13:00:00.000Z' },
|
||||
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
|
||||
],
|
||||
recordings: 'not-an-array',
|
||||
}),
|
||||
).toEqual({
|
||||
id: undefined,
|
||||
metadata: {},
|
||||
statusChanges: [
|
||||
{ code: 'in_call_recording', createdAt: '2026-01-01T13:02:00.000Z' },
|
||||
],
|
||||
recordings: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseRecallRetryAfterMs } from 'src/logic-functions/recall-api/parse-recall-retry-after.util';
|
||||
|
||||
const NOW_MS = Date.parse('2026-01-01T13:00:00.000Z');
|
||||
|
||||
describe('parseRecallRetryAfterMs', () => {
|
||||
it('parses delay-seconds into milliseconds', () => {
|
||||
expect(parseRecallRetryAfterMs('9', NOW_MS)).toBe(9_000);
|
||||
});
|
||||
|
||||
it('caps delay-seconds at the retry-after ceiling', () => {
|
||||
expect(parseRecallRetryAfterMs('120', NOW_MS)).toBe(60_000);
|
||||
});
|
||||
|
||||
it('parses an HTTP-date into a delay from now', () => {
|
||||
expect(
|
||||
parseRecallRetryAfterMs('Thu, 01 Jan 2026 13:00:05 GMT', NOW_MS),
|
||||
).toBe(5_000);
|
||||
});
|
||||
|
||||
it('clamps an HTTP-date in the past to zero', () => {
|
||||
expect(
|
||||
parseRecallRetryAfterMs('Thu, 01 Jan 2026 12:59:00 GMT', NOW_MS),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it.each(['0x10', '1e2', '1.5', '-5', '+9', 'Infinity'])(
|
||||
'rejects the malformed delay-seconds form %s',
|
||||
(retryAfterHeader) => {
|
||||
expect(parseRecallRetryAfterMs(retryAfterHeader, NOW_MS)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects values that are neither delay-seconds nor a date', () => {
|
||||
expect(parseRecallRetryAfterMs('soon', NOW_MS)).toBeUndefined();
|
||||
expect(parseRecallRetryAfterMs(' ', NOW_MS)).toBeUndefined();
|
||||
expect(parseRecallRetryAfterMs(null, NOW_MS)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+169
-33
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { cancelOrEjectRecallBot } from 'src/logic-functions/recall-api/cancel-or-eject-recall-bot.util';
|
||||
import { cancelRecallBot } from 'src/logic-functions/recall-api/cancel-recall-bot.util';
|
||||
import { createAsyncRecallTranscript } from 'src/logic-functions/recall-api/create-async-recall-transcript.util';
|
||||
import { ejectRecallBot } from 'src/logic-functions/recall-api/eject-recall-bot.util';
|
||||
@@ -9,33 +10,34 @@ import { listScheduledRecallBots } from 'src/logic-functions/recall-api/list-sch
|
||||
import { rescheduleRecallBot } from 'src/logic-functions/recall-api/reschedule-recall-bot.util';
|
||||
import { retrieveRecallTranscript } from 'src/logic-functions/recall-api/retrieve-recall-transcript.util';
|
||||
import { scheduleRecallBot } from 'src/logic-functions/recall-api/schedule-recall-bot.util';
|
||||
import { CALL_RECORDER_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-name-env-var-name';
|
||||
import { CALL_RECORDER_RECORDING_RETENTION_HOURS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-recording-retention-hours-env-var-name';
|
||||
import { RECALL_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-api-key-env-var-name';
|
||||
import { RECALL_REGION_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-region-env-var-name';
|
||||
|
||||
const getRecallApiConfigMock = vi.hoisted(() => vi.fn());
|
||||
const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const RECALL_ROUTING_METADATA = {
|
||||
twentyWorkspaceId: WORKSPACE_ID,
|
||||
twentyCallRecordingId: 'call-recording-id',
|
||||
};
|
||||
|
||||
vi.mock('src/logic-functions/recall-api/get-recall-api-config.util', () => ({
|
||||
getRecallApiConfig: getRecallApiConfigMock,
|
||||
}));
|
||||
const ENV_VAR_NAMES = [
|
||||
RECALL_API_KEY_ENV_VAR_NAME,
|
||||
RECALL_REGION_ENV_VAR_NAME,
|
||||
CALL_RECORDER_NAME_ENV_VAR_NAME,
|
||||
CALL_RECORDER_RECORDING_RETENTION_HOURS_ENV_VAR_NAME,
|
||||
] as const;
|
||||
const ORIGINAL_ENV_VALUES = ENV_VAR_NAMES.map(
|
||||
(envVarName) => [envVarName, process.env[envVarName]] as const,
|
||||
);
|
||||
|
||||
describe('recall bot api', () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env[CALL_RECORDER_RECORDING_RETENTION_HOURS_ENV_VAR_NAME];
|
||||
getRecallApiConfigMock.mockReset();
|
||||
getRecallApiConfigMock.mockReturnValue({
|
||||
success: true,
|
||||
config: {
|
||||
apiKey: 'recall-api-key',
|
||||
baseUrl: 'https://ap-northeast-1.recall.ai/api/v1',
|
||||
botName: 'Call Recorder',
|
||||
},
|
||||
});
|
||||
process.env[RECALL_API_KEY_ENV_VAR_NAME] = 'recall-api-key';
|
||||
process.env[RECALL_REGION_ENV_VAR_NAME] = 'ap-northeast-1';
|
||||
process.env[CALL_RECORDER_NAME_ENV_VAR_NAME] = 'Call Recorder';
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -45,6 +47,17 @@ describe('recall bot api', () => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ORIGINAL_ENV_VALUES.forEach(([envVarName, originalValue]) => {
|
||||
if (originalValue === undefined) {
|
||||
delete process.env[envVarName];
|
||||
} else {
|
||||
process.env[envVarName] = originalValue;
|
||||
}
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('creates Recall bot requests with the Token authorization scheme', async () => {
|
||||
const result = await scheduleRecallBot({
|
||||
meetingUrl: 'https://meet.google.com/abc-defg-hij',
|
||||
@@ -166,14 +179,7 @@ describe('recall bot api', () => {
|
||||
});
|
||||
|
||||
it('does not duplicate an existing Token authorization prefix', async () => {
|
||||
getRecallApiConfigMock.mockReturnValue({
|
||||
success: true,
|
||||
config: {
|
||||
apiKey: 'Token recall-api-key',
|
||||
baseUrl: 'https://ap-northeast-1.recall.ai/api/v1',
|
||||
botName: 'Call Recorder',
|
||||
},
|
||||
});
|
||||
process.env[RECALL_API_KEY_ENV_VAR_NAME] = 'Token recall-api-key';
|
||||
|
||||
await scheduleRecallBot({
|
||||
meetingUrl: 'https://meet.google.com/abc-defg-hij',
|
||||
@@ -220,9 +226,15 @@ describe('recall bot api', () => {
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
bots: [
|
||||
{ id: 'bot-1', metadata: { twentyCallRecordingId: 'recording-1' } },
|
||||
{ id: 'bot-2', metadata: {} },
|
||||
{
|
||||
id: 'bot-1',
|
||||
metadata: { twentyCallRecordingId: 'recording-1' },
|
||||
statusChanges: [],
|
||||
recordings: [],
|
||||
},
|
||||
{ id: 'bot-2', metadata: {}, statusChanges: [], recordings: [] },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
@@ -236,7 +248,7 @@ describe('recall bot api', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('fails the scheduled bot list when the pagination cap would truncate results', async () => {
|
||||
it('flags the result as truncated when the pagination cap leaves more pages', async () => {
|
||||
for (let pageIndex = 1; pageIndex <= 10; pageIndex++) {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@@ -254,9 +266,14 @@ describe('recall bot api', () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
status: null,
|
||||
errorMessage: 'Recall bot list exceeded 10 pages',
|
||||
ok: true,
|
||||
bots: Array.from({ length: 10 }, (_, index) => ({
|
||||
id: `bot-${index + 1}`,
|
||||
metadata: {},
|
||||
statusChanges: [],
|
||||
recordings: [],
|
||||
})),
|
||||
truncated: true,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
@@ -278,7 +295,8 @@ describe('recall bot api', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
bots: [{ id: 'bot-1', metadata: {} }],
|
||||
bots: [{ id: 'bot-1', metadata: {}, statusChanges: [], recordings: [] }],
|
||||
truncated: false,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -319,7 +337,7 @@ describe('recall bot api', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches a single bot and returns the raw response', async () => {
|
||||
it('fetches a single bot and returns its parsed snapshot', async () => {
|
||||
const botResponse = {
|
||||
id: 'recall-bot-id',
|
||||
status_changes: [{ code: 'done' }],
|
||||
@@ -334,7 +352,21 @@ describe('recall bot api', () => {
|
||||
|
||||
const result = await getRecallBot({ externalBotId: 'recall-bot-id' });
|
||||
|
||||
expect(result).toEqual({ ok: true, bot: botResponse });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
bot: {
|
||||
id: 'recall-bot-id',
|
||||
metadata: {},
|
||||
statusChanges: [{ code: 'done', createdAt: undefined }],
|
||||
recordings: [
|
||||
{
|
||||
id: 'recall-recording-id',
|
||||
startedAt: undefined,
|
||||
completedAt: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://ap-northeast-1.recall.ai/api/v1/bot/recall-bot-id/',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
@@ -645,7 +677,12 @@ describe('recall bot api', () => {
|
||||
|
||||
expect(await resultPromise).toEqual({
|
||||
ok: true,
|
||||
bot: { id: 'recall-bot-id' },
|
||||
bot: {
|
||||
id: 'recall-bot-id',
|
||||
metadata: {},
|
||||
statusChanges: [],
|
||||
recordings: [],
|
||||
},
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -668,7 +705,12 @@ describe('recall bot api', () => {
|
||||
|
||||
expect(await resultPromise).toEqual({
|
||||
ok: true,
|
||||
bot: { id: 'recall-bot-id' },
|
||||
bot: {
|
||||
id: 'recall-bot-id',
|
||||
metadata: {},
|
||||
statusChanges: [],
|
||||
recordings: [],
|
||||
},
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -693,6 +735,100 @@ describe('recall bot api', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('defers instead of retrying in-process when Retry-After exceeds the invocation budget', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: {
|
||||
get: (headerName: string) =>
|
||||
headerName.toLowerCase() === 'retry-after' ? '120' : null,
|
||||
},
|
||||
json: async () => ({ detail: 'rate limited' }),
|
||||
});
|
||||
|
||||
const result = await getRecallBot({ externalBotId: 'recall-bot-id' });
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
status: 429,
|
||||
errorMessage:
|
||||
'Recall API responded with HTTP 429: {"detail":"rate limited"}',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops retrying once accumulated retry waits exhaust the invocation budget', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: {
|
||||
get: (headerName: string) =>
|
||||
headerName.toLowerCase() === 'retry-after' ? '9' : null,
|
||||
},
|
||||
json: async () => ({ detail: 'rate limited' }),
|
||||
});
|
||||
|
||||
const resultPromise = getRecallBot({ externalBotId: 'recall-bot-id' });
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(await resultPromise).toEqual({
|
||||
ok: false,
|
||||
status: 429,
|
||||
errorMessage:
|
||||
'Recall API responded with HTTP 429: {"detail":"rate limited"}',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reports the eject failure when both cancel and eject fail', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
fetchMock.mockImplementation(async (_url: string, init: RequestInit) => ({
|
||||
ok: false,
|
||||
status: init.method === 'DELETE' ? 409 : 500,
|
||||
json: async () => ({
|
||||
detail:
|
||||
init.method === 'DELETE'
|
||||
? 'cannot delete a joined bot'
|
||||
: 'leave call failed',
|
||||
}),
|
||||
}));
|
||||
|
||||
const resultPromise = cancelOrEjectRecallBot('recall-bot-id');
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(await resultPromise).toBe(false);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'failed to cancel or eject Recall bot recall-bot-id',
|
||||
),
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('HTTP 500: {"detail":"leave call failed"}'),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('defers 507 adhoc pool exhaustion instead of sleeping in-process', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 507,
|
||||
json: async () => ({ detail: 'adhoc pool exhausted' }),
|
||||
});
|
||||
|
||||
const result = await getRecallBot({ externalBotId: 'recall-bot-id' });
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
status: 507,
|
||||
errorMessage:
|
||||
'Recall API responded with HTTP 507: {"detail":"adhoc pool exhausted"}',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not retry client errors', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { isNull } from '@sniptt/guards';
|
||||
|
||||
import { cancelRecallBot } from 'src/logic-functions/recall-api/cancel-recall-bot.util';
|
||||
import { ejectRecallBot } from 'src/logic-functions/recall-api/eject-recall-bot.util';
|
||||
|
||||
export const cancelOrEjectRecallBot = async (
|
||||
externalBotId: string,
|
||||
): Promise<boolean> => {
|
||||
const cancelResult = await cancelRecallBot({ externalBotId });
|
||||
|
||||
if (cancelResult.ok) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let failureMessage = cancelResult.errorMessage;
|
||||
|
||||
// Deleting only works for not-yet-joined bots; eject the ones already in a call.
|
||||
if (!isNull(cancelResult.status)) {
|
||||
const ejectResult = await ejectRecallBot({ externalBotId });
|
||||
|
||||
if (ejectResult.ok) {
|
||||
return true;
|
||||
}
|
||||
|
||||
failureMessage = ejectResult.errorMessage;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[call-recorder] failed to cancel or eject Recall bot ${externalBotId}: ${failureMessage}`,
|
||||
);
|
||||
|
||||
return false;
|
||||
};
|
||||
+8
-55
@@ -1,10 +1,12 @@
|
||||
import { isArray, isUndefined } from '@sniptt/guards';
|
||||
import { isUndefined } from '@sniptt/guards';
|
||||
|
||||
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
|
||||
import { asRecord } from 'src/logic-functions/utils/as-record.util';
|
||||
import { getString } from 'src/logic-functions/utils/get-string.util';
|
||||
import { mapRecallStatusCodeToCallRecordingStatus } from 'src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util';
|
||||
import { normalizeRecallTimestamp } from 'src/logic-functions/recall-api/normalize-recall-timestamp.util';
|
||||
import {
|
||||
type RecallBotSnapshot,
|
||||
type RecallBotStatusChange,
|
||||
} from 'src/logic-functions/recall-api/recall-bot-snapshot.type';
|
||||
|
||||
export type RecallBotSyncState = {
|
||||
status: CallRecordingStatus | undefined;
|
||||
@@ -15,21 +17,16 @@ export type RecallBotSyncState = {
|
||||
isRecallRecordingDone: boolean;
|
||||
};
|
||||
|
||||
type RecallBotStatusChange = {
|
||||
code: string;
|
||||
createdAt: string | undefined;
|
||||
};
|
||||
|
||||
// Derives the state a full webhook history would have produced from GET /bot.
|
||||
export const extractRecallBotSyncState = (
|
||||
bot: Record<string, unknown>,
|
||||
bot: RecallBotSnapshot,
|
||||
): RecallBotSyncState => {
|
||||
const statusChanges = extractStatusChanges(bot);
|
||||
const { statusChanges } = bot;
|
||||
const latestStatusChange = getLatestStatusChange(statusChanges);
|
||||
const status = mapRecallStatusCodeToCallRecordingStatus(
|
||||
latestStatusChange?.code,
|
||||
);
|
||||
const recording = extractFirstRecording(bot);
|
||||
const recording = bot.recordings[0];
|
||||
|
||||
return {
|
||||
status,
|
||||
@@ -52,24 +49,6 @@ export const extractRecallBotSyncState = (
|
||||
};
|
||||
};
|
||||
|
||||
const extractStatusChanges = (
|
||||
bot: Record<string, unknown>,
|
||||
): RecallBotStatusChange[] => {
|
||||
if (!isArray(bot.status_changes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return bot.status_changes.flatMap((statusChange: unknown) => {
|
||||
const code = getString(asRecord(statusChange)?.code);
|
||||
|
||||
if (isUndefined(code)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ code, createdAt: getString(asRecord(statusChange)?.created_at) }];
|
||||
});
|
||||
};
|
||||
|
||||
const getLatestStatusChange = (
|
||||
statusChanges: RecallBotStatusChange[],
|
||||
): RecallBotStatusChange | undefined =>
|
||||
@@ -116,32 +95,6 @@ const getStatusChangeTime = (
|
||||
return new Date(normalizedTimestamp).getTime();
|
||||
};
|
||||
|
||||
const extractFirstRecording = (
|
||||
bot: Record<string, unknown>,
|
||||
):
|
||||
| {
|
||||
id: string | undefined;
|
||||
startedAt: string | undefined;
|
||||
completedAt: string | undefined;
|
||||
}
|
||||
| undefined => {
|
||||
if (!isArray(bot.recordings)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const recording = asRecord(bot.recordings[0]);
|
||||
|
||||
if (isUndefined(recording)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: getString(recording.id),
|
||||
startedAt: getString(recording.started_at),
|
||||
completedAt: getString(recording.completed_at),
|
||||
};
|
||||
};
|
||||
|
||||
const findStatusChangeTimestamp = (
|
||||
statusChanges: RecallBotStatusChange[],
|
||||
code: string,
|
||||
|
||||
+4
-2
@@ -1,10 +1,12 @@
|
||||
import { type RecallBotOperationFailure } from 'src/logic-functions/types/recall-bot-operation-result.type';
|
||||
import { asRecord } from 'src/logic-functions/utils/as-record.util';
|
||||
import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util';
|
||||
import { parseRecallBotSnapshot } from 'src/logic-functions/recall-api/parse-recall-bot-snapshot.util';
|
||||
import { type RecallBotSnapshot } from 'src/logic-functions/recall-api/recall-bot-snapshot.type';
|
||||
import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util';
|
||||
|
||||
type GetRecallBotResult =
|
||||
| { ok: true; bot: Record<string, unknown> }
|
||||
| { ok: true; bot: RecallBotSnapshot }
|
||||
| RecallBotOperationFailure;
|
||||
|
||||
export const getRecallBot = async ({
|
||||
@@ -38,5 +40,5 @@ export const getRecallBot = async ({
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, bot };
|
||||
return { ok: true, bot: parseRecallBotSnapshot(bot) };
|
||||
};
|
||||
|
||||
+31
-20
@@ -3,11 +3,12 @@ import { isString, isUndefined } from '@sniptt/guards';
|
||||
import { type RecallBotOperationFailure } from 'src/logic-functions/types/recall-bot-operation-result.type';
|
||||
import { asRecord } from 'src/logic-functions/utils/as-record.util';
|
||||
import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util';
|
||||
import { parseRecallBotSnapshot } from 'src/logic-functions/recall-api/parse-recall-bot-snapshot.util';
|
||||
import { type RecallBotSnapshot } from 'src/logic-functions/recall-api/recall-bot-snapshot.type';
|
||||
import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util';
|
||||
|
||||
export type RecallScheduledBot = {
|
||||
export type RecallScheduledBot = RecallBotSnapshot & {
|
||||
id: string;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type RecallBotListResponse = {
|
||||
@@ -16,7 +17,7 @@ type RecallBotListResponse = {
|
||||
};
|
||||
|
||||
type ListScheduledRecallBotsResult =
|
||||
| { ok: true; bots: RecallScheduledBot[] }
|
||||
| { ok: true; bots: RecallScheduledBot[]; truncated: boolean }
|
||||
| RecallBotOperationFailure;
|
||||
|
||||
const RECALL_BOT_LIST_MAX_PAGES = 10;
|
||||
@@ -24,9 +25,11 @@ const RECALL_BOT_LIST_MAX_PAGES = 10;
|
||||
export const listScheduledRecallBots = async ({
|
||||
joinAtAfter,
|
||||
joinAtBefore,
|
||||
metadata,
|
||||
}: {
|
||||
joinAtAfter: string;
|
||||
joinAtBefore: string;
|
||||
metadata?: Record<string, string>;
|
||||
}): Promise<ListScheduledRecallBotsResult> => {
|
||||
const configResult = getRecallApiConfig();
|
||||
|
||||
@@ -35,9 +38,16 @@ export const listScheduledRecallBots = async ({
|
||||
}
|
||||
|
||||
const bots: RecallScheduledBot[] = [];
|
||||
let path: string | undefined = `/bot/?join_at_after=${encodeURIComponent(
|
||||
joinAtAfter,
|
||||
)}&join_at_before=${encodeURIComponent(joinAtBefore)}`;
|
||||
const searchParams = new URLSearchParams({
|
||||
join_at_after: joinAtAfter,
|
||||
join_at_before: joinAtBefore,
|
||||
});
|
||||
|
||||
Object.entries(metadata ?? {}).forEach(([key, value]) => {
|
||||
searchParams.set(`metadata__${key}`, value);
|
||||
});
|
||||
|
||||
let path: string | undefined = `/bot/?${searchParams.toString()}`;
|
||||
|
||||
for (
|
||||
let pageIndex = 0;
|
||||
@@ -58,15 +68,15 @@ export const listScheduledRecallBots = async ({
|
||||
path = extractNextPath(result.data, configResult.config.baseUrl);
|
||||
}
|
||||
|
||||
if (!isUndefined(path)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: null,
|
||||
errorMessage: `Recall bot list exceeded ${RECALL_BOT_LIST_MAX_PAGES} pages`,
|
||||
};
|
||||
const truncated = !isUndefined(path);
|
||||
|
||||
if (truncated && process.env.NODE_ENV !== 'test') {
|
||||
console.warn(
|
||||
`[call-recorder] Recall bot list exceeded ${RECALL_BOT_LIST_MAX_PAGES} pages; continuing with ${bots.length} fetched bots`,
|
||||
);
|
||||
}
|
||||
|
||||
return { ok: true, bots };
|
||||
return { ok: true, bots, truncated };
|
||||
};
|
||||
|
||||
const extractRecallBots = (
|
||||
@@ -79,16 +89,17 @@ const extractRecallBots = (
|
||||
return response.results.flatMap((candidate: unknown) => {
|
||||
const bot = asRecord(candidate);
|
||||
|
||||
if (isUndefined(bot) || !isString(bot.id)) {
|
||||
if (isUndefined(bot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: bot.id,
|
||||
metadata: asRecord(bot.metadata) ?? {},
|
||||
},
|
||||
];
|
||||
const snapshot = parseRecallBotSnapshot(bot);
|
||||
|
||||
if (isUndefined(snapshot.id)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ ...snapshot, id: snapshot.id }];
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { isArray, isUndefined } from '@sniptt/guards';
|
||||
|
||||
import {
|
||||
type RecallBotRecording,
|
||||
type RecallBotSnapshot,
|
||||
type RecallBotStatusChange,
|
||||
} from 'src/logic-functions/recall-api/recall-bot-snapshot.type';
|
||||
import { asRecord } from 'src/logic-functions/utils/as-record.util';
|
||||
import { getString } from 'src/logic-functions/utils/get-string.util';
|
||||
|
||||
export const parseRecallBotSnapshot = (
|
||||
payload: Record<string, unknown>,
|
||||
): RecallBotSnapshot => ({
|
||||
id: getString(payload.id),
|
||||
metadata: asRecord(payload.metadata) ?? {},
|
||||
statusChanges: parseStatusChanges(payload.status_changes),
|
||||
recordings: parseRecordings(payload.recordings),
|
||||
});
|
||||
|
||||
const parseStatusChanges = (value: unknown): RecallBotStatusChange[] => {
|
||||
if (!isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.flatMap((statusChange: unknown) => {
|
||||
const code = getString(asRecord(statusChange)?.code);
|
||||
|
||||
if (isUndefined(code)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ code, createdAt: getString(asRecord(statusChange)?.created_at) }];
|
||||
});
|
||||
};
|
||||
|
||||
const parseRecordings = (value: unknown): RecallBotRecording[] => {
|
||||
if (!isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.flatMap((recording: unknown) => {
|
||||
const recordingRecord = asRecord(recording);
|
||||
|
||||
if (isUndefined(recordingRecord)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: getString(recordingRecord.id),
|
||||
startedAt: getString(recordingRecord.started_at),
|
||||
completedAt: getString(recordingRecord.completed_at),
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { RECALL_API_MAX_RETRY_AFTER_MS } from 'src/logic-functions/constants/recall-api-max-retry-after-ms';
|
||||
|
||||
export const parseRecallRetryAfterMs = (
|
||||
retryAfterHeader: string | null,
|
||||
nowMs: number,
|
||||
): number | undefined => {
|
||||
if (retryAfterHeader === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmedRetryAfterHeader = retryAfterHeader.trim();
|
||||
|
||||
if (trimmedRetryAfterHeader.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(trimmedRetryAfterHeader)) {
|
||||
return capRecallRetryAfterMs(Number(trimmedRetryAfterHeader) * 1000);
|
||||
}
|
||||
|
||||
// Malformed numeric forms (1.5, 1e2, 0x10) would be misread as dates by Date.parse.
|
||||
if (!Number.isNaN(Number(trimmedRetryAfterHeader))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const retryAfterDateMs = Date.parse(trimmedRetryAfterHeader);
|
||||
|
||||
if (Number.isNaN(retryAfterDateMs)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return capRecallRetryAfterMs(Math.max(0, retryAfterDateMs - nowMs));
|
||||
};
|
||||
|
||||
const capRecallRetryAfterMs = (retryAfterMs: number): number =>
|
||||
Math.min(retryAfterMs, RECALL_API_MAX_RETRY_AFTER_MS);
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { RECALL_API_ADHOC_POOL_RETRY_DELAY_MS } from 'src/logic-functions/constants/recall-api-adhoc-pool-retry-delay-ms';
|
||||
import { RECALL_API_RETRY_DELAY_MS } from 'src/logic-functions/constants/recall-api-retry-delay-ms';
|
||||
|
||||
const RECALL_STATUS_RATE_LIMITED = 429;
|
||||
const RECALL_STATUS_CONFLICT = 409;
|
||||
const RECALL_STATUS_ADHOC_POOL_EXHAUSTED = 507;
|
||||
|
||||
const isRecallServerError = (status: number): boolean => status >= 500;
|
||||
|
||||
export const isRetryableRecallApiStatus = (status: number): boolean =>
|
||||
status === RECALL_STATUS_RATE_LIMITED ||
|
||||
status === RECALL_STATUS_CONFLICT ||
|
||||
isRecallServerError(status);
|
||||
|
||||
export const resolveRecallApiRetryDelayMs = ({
|
||||
retryAfterMs,
|
||||
status,
|
||||
attemptNumber,
|
||||
random = Math.random,
|
||||
}: {
|
||||
retryAfterMs: number | undefined;
|
||||
status: number | null;
|
||||
attemptNumber: number;
|
||||
random?: () => number;
|
||||
}): number => {
|
||||
if (retryAfterMs !== undefined) {
|
||||
return retryAfterMs;
|
||||
}
|
||||
|
||||
if (status === RECALL_STATUS_ADHOC_POOL_EXHAUSTED) {
|
||||
return RECALL_API_ADHOC_POOL_RETRY_DELAY_MS;
|
||||
}
|
||||
|
||||
// Equal jitter so retries sharing a failure instant do not re-collide.
|
||||
const baseDelayMs = RECALL_API_RETRY_DELAY_MS * attemptNumber;
|
||||
|
||||
return Math.round((baseDelayMs * (1 + random())) / 2);
|
||||
};
|
||||
+29
-6
@@ -1,8 +1,13 @@
|
||||
import { isUndefined } from '@sniptt/guards';
|
||||
|
||||
import { RECALL_API_MAX_IN_PROCESS_RETRY_WAIT_MS } from 'src/logic-functions/constants/recall-api-max-in-process-retry-wait-ms';
|
||||
import { RECALL_API_MAX_ATTEMPTS } from 'src/logic-functions/constants/recall-api-max-attempts';
|
||||
import { RECALL_API_RETRY_DELAY_MS } from 'src/logic-functions/constants/recall-api-retry-delay-ms';
|
||||
import { type RecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util';
|
||||
import { parseRecallRetryAfterMs } from 'src/logic-functions/recall-api/parse-recall-retry-after.util';
|
||||
import {
|
||||
isRetryableRecallApiStatus,
|
||||
resolveRecallApiRetryDelayMs,
|
||||
} from 'src/logic-functions/recall-api/recall-api-retry-policy.util';
|
||||
|
||||
type RecallBotApiRequestArgs = {
|
||||
config: RecallApiConfig;
|
||||
@@ -31,16 +36,32 @@ export const recallBotApiRequest = async <TData>(
|
||||
requestArgs: RecallBotApiRequestArgs,
|
||||
): Promise<RecallBotApiRequestResult<TData>> => {
|
||||
const maxAttempts = requestArgs.maxAttempts ?? RECALL_API_MAX_ATTEMPTS;
|
||||
let totalRetryWaitMs = 0;
|
||||
|
||||
for (let attemptNumber = 1; ; attemptNumber++) {
|
||||
const { result, isRetryable } =
|
||||
const { result, isRetryable, retryAfterMs } =
|
||||
await performRecallBotApiRequestAttempt<TData>(requestArgs);
|
||||
|
||||
if (!isRetryable || attemptNumber >= maxAttempts) {
|
||||
return result;
|
||||
}
|
||||
|
||||
await sleep(RECALL_API_RETRY_DELAY_MS * attemptNumber);
|
||||
const retryDelayMs = resolveRecallApiRetryDelayMs({
|
||||
retryAfterMs,
|
||||
status: result.status,
|
||||
attemptNumber,
|
||||
});
|
||||
|
||||
// Sleeping past the invocation budget would hit the timeout kill; defer to the reconcilers.
|
||||
if (
|
||||
totalRetryWaitMs + retryDelayMs >=
|
||||
RECALL_API_MAX_IN_PROCESS_RETRY_WAIT_MS
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
|
||||
totalRetryWaitMs += retryDelayMs;
|
||||
await sleep(retryDelayMs);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -53,6 +74,7 @@ const performRecallBotApiRequestAttempt = async <TData>({
|
||||
}: RecallBotApiRequestArgs): Promise<{
|
||||
result: RecallBotApiRequestResult<TData>;
|
||||
isRetryable: boolean;
|
||||
retryAfterMs?: number;
|
||||
}> => {
|
||||
let response: Response;
|
||||
|
||||
@@ -103,6 +125,10 @@ const performRecallBotApiRequestAttempt = async <TData>({
|
||||
if (!response.ok) {
|
||||
return {
|
||||
isRetryable: isRetryableRecallApiStatus(response.status),
|
||||
retryAfterMs: parseRecallRetryAfterMs(
|
||||
response.headers?.get('retry-after') ?? null,
|
||||
Date.now(),
|
||||
),
|
||||
result: {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
@@ -134,9 +160,6 @@ const performRecallBotApiRequestAttempt = async <TData>({
|
||||
}
|
||||
};
|
||||
|
||||
const isRetryableRecallApiStatus = (status: number): boolean =>
|
||||
status === 429 || status >= 500;
|
||||
|
||||
const sleep = (delayMs: number): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, delayMs);
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
export type RecallBotStatusChange = {
|
||||
code: string;
|
||||
createdAt: string | undefined;
|
||||
};
|
||||
|
||||
export type RecallBotRecording = {
|
||||
id: string | undefined;
|
||||
startedAt: string | undefined;
|
||||
completedAt: string | undefined;
|
||||
};
|
||||
|
||||
// Parsed once at the recall-api boundary so flows never handle raw provider records.
|
||||
export type RecallBotSnapshot = {
|
||||
id: string | undefined;
|
||||
metadata: Record<string, unknown>;
|
||||
statusChanges: RecallBotStatusChange[];
|
||||
recordings: RecallBotRecording[];
|
||||
};
|
||||
Reference in New Issue
Block a user