From b28fc54b44f88fa27c48cb1f6c53ece15bd3c178 Mon Sep 17 00:00:00 2001
From: nitin <142569587+ehconitin@users.noreply.github.com>
Date: Mon, 3 Aug 2026 23:49:34 +0530
Subject: [PATCH] Classify Recall no-capture sub codes as NOT_RECORDED in
call-recorder app (#23693)
Second part of twentyhq/core-team-issues#2706, following #23478 which
shipped the NOT_RECORDED status and workspace upgrade: the call-recorder
app now classifies benign no-capture outcomes (bot never admitted,
meeting not started, nobody joined) as NOT_RECORDED instead of FAILED.
- Parse status sub codes from Recall webhooks and bot snapshots, and map
no-capture sub codes to NOT_RECORDED with the sub code stored as the
failure reason
- Derive NOT_RECORDED from bot snapshots during sync when the bot
finished without a recording and a no-capture leave is in its history
- Treat NOT_RECORDED as terminal alongside FAILED: no artifact-import
completion, no late-event flips between the two; calendar reconciliation
may reset it to SCHEDULED for upcoming meetings
- Prefer the sub code over the status code in FAILED reasons
- Bump the app to 1.6.0 and require twenty >=2.26.0, where the status
exists
---
.../public/call-recorder/package.json | 4 +-
...all-recorder-lifecycle.integration-test.ts | 37 +++++
.../constants/call-recording-status.ts | 1 +
.../not-recorded-recall-sub-codes.ts | 10 ++
.../unavailable-call-recording-statuses.ts | 6 +
...is-call-recording-status-downgrade.test.ts | 4 +
...uld-complete-call-recording-import.test.ts | 32 ++++
...is-call-recording-status-downgrade.util.ts | 4 +-
.../is-not-recorded-recall-sub-code.util.ts | 8 +
...-unavailable-call-recording-status.util.ts | 8 +
...atus-code-to-call-recording-status.util.ts | 19 ++-
...uld-complete-call-recording-import.util.ts | 5 +-
.../converge-diverged-call-recordings.test.ts | 6 +-
.../__tests__/handle-recall-webhook.test.ts | 140 ++++++++++++++++++
.../__tests__/reconcile-call-recorder.test.ts | 40 +++++
.../flows/handle-recall-webhook.util.ts | 68 +++++++--
.../flows/reconcile-call-recorder.util.ts | 3 +-
.../flows/sync-call-recording.util.ts | 21 +--
.../extract-recall-bot-sync-state.test.ts | 63 ++++++++
.../parse-recall-bot-snapshot.test.ts | 10 ++
.../extract-recall-bot-sync-state.util.ts | 52 ++++++-
.../parse-recall-bot-snapshot.util.ts | 8 +-
.../parse-recall-webhook-event.util.ts | 9 +-
.../recall-api/recall-bot-snapshot.type.ts | 1 +
24 files changed, 513 insertions(+), 46 deletions(-)
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/not-recorded-recall-sub-codes.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/unavailable-call-recording-statuses.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-not-recorded-recall-sub-code.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-unavailable-call-recording-status.util.ts
diff --git a/packages/twenty-apps/public/call-recorder/package.json b/packages/twenty-apps/public/call-recorder/package.json
index 101036c290..a961186231 100644
--- a/packages/twenty-apps/public/call-recorder/package.json
+++ b/packages/twenty-apps/public/call-recorder/package.json
@@ -1,12 +1,12 @@
{
"name": "@twentyhq/call-recorder",
- "version": "1.5.1",
+ "version": "1.6.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2",
- "twenty": ">=2.23.0"
+ "twenty": ">=2.26.0"
},
"keywords": [
"twenty-app"
diff --git a/packages/twenty-apps/public/call-recorder/src/__tests__/call-recorder-lifecycle.integration-test.ts b/packages/twenty-apps/public/call-recorder/src/__tests__/call-recorder-lifecycle.integration-test.ts
index fd8a985ca7..1c2ad24022 100644
--- a/packages/twenty-apps/public/call-recorder/src/__tests__/call-recorder-lifecycle.integration-test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/__tests__/call-recorder-lifecycle.integration-test.ts
@@ -230,11 +230,13 @@ const buildBotStatusChangeWebhook = ({
botId,
metadata,
statusCode,
+ statusSubCode,
statusTimestamp,
}: {
botId: string;
metadata: Record;
statusCode: string;
+ statusSubCode?: string;
statusTimestamp?: string;
}) => ({
event: 'bot.status_change',
@@ -242,6 +244,7 @@ const buildBotStatusChangeWebhook = ({
bot_id: botId,
status: {
code: statusCode,
+ ...(statusSubCode === undefined ? {} : { sub_code: statusSubCode }),
created_at: statusTimestamp ?? new Date().toISOString(),
},
bot: { id: botId, metadata },
@@ -704,6 +707,40 @@ describe('call recorder app lifecycle (integration)', () => {
expect(callRecording.callRecorderFailureReason).toBe('fatal');
});
+ it('marks the recording NOT_RECORDED when nobody joined the meeting', async () => {
+ const { callRecordingId, botId, metadata } =
+ await scheduleRecordingThroughCalendarReconciliation();
+
+ await deliverRecallWebhook(
+ buildBotStatusChangeWebhook({
+ botId,
+ metadata,
+ statusCode: 'call_ended',
+ statusSubCode: 'timeout_exceeded_noone_joined',
+ }),
+ );
+
+ const callRecording = await fetchCallRecording(callRecordingId);
+
+ expect(callRecording.status).toBe('NOT_RECORDED');
+ expect(callRecording.callRecorderFailureReason).toBe(
+ 'timeout_exceeded_noone_joined',
+ );
+
+ const lateDoneResult = await deliverRecallWebhook(
+ buildBotStatusChangeWebhook({
+ botId,
+ metadata,
+ statusCode: 'done',
+ }),
+ );
+
+ expect(lateDoneResult.status).toBe('skipped');
+ expect((await fetchCallRecording(callRecordingId)).status).toBe(
+ 'NOT_RECORDED',
+ );
+ });
+
it('ignores webhooks that match no known recording', async () => {
const { callRecordingId } =
await scheduleRecordingThroughCalendarReconciliation();
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recording-status.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recording-status.ts
index 60b8b6f14f..4e141cdf27 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recording-status.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recording-status.ts
@@ -6,4 +6,5 @@ export enum CallRecordingStatus {
PROCESSING = 'PROCESSING',
COMPLETED = 'COMPLETED',
FAILED = 'FAILED',
+ NOT_RECORDED = 'NOT_RECORDED',
}
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/not-recorded-recall-sub-codes.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/not-recorded-recall-sub-codes.ts
new file mode 100644
index 0000000000..6acfaba101
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/not-recorded-recall-sub-codes.ts
@@ -0,0 +1,10 @@
+// Sub codes meaning nothing was captured: bot never admitted, meeting not started, nobody joined.
+export const NOT_RECORDED_RECALL_SUB_CODES: readonly string[] = [
+ 'meeting_not_started',
+ 'timeout_exceeded_noone_joined',
+ 'timeout_exceeded_only_bots_detected_using_participant_names',
+ 'timeout_exceeded_only_bots_detected_using_participant_events',
+ 'timeout_exceeded_waiting_room',
+ 'call_ended_by_platform_waiting_room_timeout',
+ 'bot_kicked_from_waiting_room',
+];
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/unavailable-call-recording-statuses.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/unavailable-call-recording-statuses.ts
new file mode 100644
index 0000000000..1bee6e8521
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/unavailable-call-recording-statuses.ts
@@ -0,0 +1,6 @@
+import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
+
+export const UNAVAILABLE_CALL_RECORDING_STATUSES: readonly string[] = [
+ CallRecordingStatus.FAILED,
+ CallRecordingStatus.NOT_RECORDED,
+];
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts
index 3e73245e19..d2cb7c099f 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts
@@ -14,6 +14,10 @@ describe('isCallRecordingStatusDowngrade', () => {
['PROCESSING', 'JOINING', true],
['FAILED', 'RECORDING', true],
['JOINING', 'SCHEDULED', true],
+ ['PROCESSING', 'NOT_RECORDED', false],
+ ['NOT_RECORDED', 'PROCESSING', true],
+ ['NOT_RECORDED', 'FAILED', true],
+ ['FAILED', 'NOT_RECORDED', true],
])('from %s to %s -> %s', (fromStatus, toStatus, expected) => {
expect(isCallRecordingStatusDowngrade({ fromStatus, toStatus })).toBe(
expected,
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/should-complete-call-recording-import.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/should-complete-call-recording-import.test.ts
index d95db419dc..208fcd4834 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/should-complete-call-recording-import.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/should-complete-call-recording-import.test.ts
@@ -130,4 +130,36 @@ describe('shouldCompleteCallRecordingImport', () => {
}),
).toBe(false);
});
+
+ it('does not complete a recording classified as NOT_RECORDED', () => {
+ expect(
+ shouldCompleteCallRecordingImport({
+ current: {
+ status: CallRecordingStatus.NOT_RECORDED,
+ startedAt: '2026-06-10T09:00:00.000Z',
+ endedAt: '2026-06-10T10:00:00.000Z',
+ transcript: filledTranscript,
+ audio: filledAudio,
+ video: filledVideo,
+ },
+ updateData: {},
+ }),
+ ).toBe(false);
+
+ expect(
+ shouldCompleteCallRecordingImport({
+ current: {
+ status: CallRecordingStatus.PROCESSING,
+ startedAt: '2026-06-10T09:00:00.000Z',
+ endedAt: '2026-06-10T10:00:00.000Z',
+ transcript: filledTranscript,
+ audio: filledAudio,
+ video: filledVideo,
+ },
+ updateData: {
+ status: CallRecordingStatus.NOT_RECORDED,
+ },
+ }),
+ ).toBe(false);
+ });
});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts
index 45843102bb..7b0b54e717 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts
@@ -3,12 +3,14 @@ import { isUndefined } from '@sniptt/guards';
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
// Deliveries are unordered; a late event must never move status backwards.
+// FAILED and NOT_RECORDED share a rank so a late event cannot flip one into the other.
const CALL_RECORDING_STATUS_PROGRESSION: Record = {
[CallRecordingStatus.SCHEDULED]: 0,
[CallRecordingStatus.JOINING]: 1,
[CallRecordingStatus.RECORDING]: 2,
[CallRecordingStatus.PROCESSING]: 3,
[CallRecordingStatus.FAILED]: 4,
+ [CallRecordingStatus.NOT_RECORDED]: 4,
[CallRecordingStatus.COMPLETED]: 5,
};
@@ -33,5 +35,5 @@ export const isCallRecordingStatusDowngrade = ({
return false;
}
- return toRank < fromRank;
+ return toRank < fromRank || (toRank === fromRank && toStatus !== fromStatus);
};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-not-recorded-recall-sub-code.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-not-recorded-recall-sub-code.util.ts
new file mode 100644
index 0000000000..0e89a0fe0f
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-not-recorded-recall-sub-code.util.ts
@@ -0,0 +1,8 @@
+import { isUndefined } from '@sniptt/guards';
+
+import { NOT_RECORDED_RECALL_SUB_CODES } from 'src/logic-functions/constants/not-recorded-recall-sub-codes';
+
+export const isNotRecordedRecallSubCode = (
+ subCode: string | undefined,
+): boolean =>
+ !isUndefined(subCode) && NOT_RECORDED_RECALL_SUB_CODES.includes(subCode);
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-unavailable-call-recording-status.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-unavailable-call-recording-status.util.ts
new file mode 100644
index 0000000000..abb3e89ef7
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-unavailable-call-recording-status.util.ts
@@ -0,0 +1,8 @@
+import { isUndefined } from '@sniptt/guards';
+
+import { UNAVAILABLE_CALL_RECORDING_STATUSES } from 'src/logic-functions/constants/unavailable-call-recording-statuses';
+
+export const isUnavailableCallRecordingStatus = (
+ status: string | undefined,
+): boolean =>
+ !isUndefined(status) && UNAVAILABLE_CALL_RECORDING_STATUSES.includes(status);
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts
index aee5e3b413..05296b8d03 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts
@@ -1,8 +1,21 @@
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
+import { isNotRecordedRecallSubCode } from 'src/logic-functions/domain/is-not-recorded-recall-sub-code.util';
+
+export const mapRecallStatusCodeToCallRecordingStatus = ({
+ statusCode,
+ statusSubCode,
+}: {
+ statusCode: string | undefined;
+ statusSubCode?: string | undefined;
+}): CallRecordingStatus | undefined => {
+ // Recall defines no-capture sub codes only on call_ended and fatal; other codes may carry a stale bot-level sub code.
+ if (
+ (statusCode === 'call_ended' || statusCode === 'fatal') &&
+ isNotRecordedRecallSubCode(statusSubCode)
+ ) {
+ return CallRecordingStatus.NOT_RECORDED;
+ }
-export const mapRecallStatusCodeToCallRecordingStatus = (
- statusCode: string | undefined,
-): CallRecordingStatus | undefined => {
switch (statusCode) {
case 'joining_call':
case 'in_waiting_room':
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/should-complete-call-recording-import.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/should-complete-call-recording-import.util.ts
index c6a25f94e3..d894dd92ab 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/should-complete-call-recording-import.util.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/should-complete-call-recording-import.util.ts
@@ -1,6 +1,7 @@
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type';
import { computeCallRecordingCharge } from 'src/logic-functions/domain/compute-call-recording-charge.util';
+import { isUnavailableCallRecordingStatus } from 'src/logic-functions/domain/is-unavailable-call-recording-status.util';
import { isCallRecordingImportComplete } from 'src/logic-functions/domain/is-call-recording-import-complete.util';
import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type';
@@ -20,8 +21,8 @@ export const shouldCompleteCallRecordingImport = ({
updateData: CallRecordingUpdateFields;
}): boolean =>
current.status !== CallRecordingStatus.COMPLETED &&
- current.status !== CallRecordingStatus.FAILED &&
- updateData.status !== CallRecordingStatus.FAILED &&
+ !isUnavailableCallRecordingStatus(current.status) &&
+ !isUnavailableCallRecordingStatus(updateData.status) &&
computeCallRecordingCharge({
startedAt: updateData.startedAt ?? current.startedAt,
endedAt: updateData.endedAt ?? current.endedAt,
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts
index 4a97b2df91..ccd9a5bc84 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts
@@ -306,7 +306,7 @@ describe('convergeDivergedCallRecordings', () => {
});
});
- it('marks FAILED when Recall is done but has no recording artifact path', async () => {
+ it('marks NOT_RECORDED when Recall is done but never produced a recording', async () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
@@ -329,8 +329,8 @@ describe('convergeDivergedCallRecordings', () => {
{
id: 'call-recording-1',
data: {
- status: 'FAILED',
- callRecorderFailureReason: 'recording_artifacts_unavailable',
+ status: 'NOT_RECORDED',
+ callRecorderFailureReason: 'recall_bot_did_not_record',
},
},
]);
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts
index 668f8db9ce..1735a4860b 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts
@@ -229,6 +229,146 @@ describe('handleRecallWebhook', () => {
]);
});
+ it('keeps FAILED with the sub code as reason for non-benign fatal events', async () => {
+ const client = new FakeCoreApiClient([
+ {
+ id: 'call-recording-1',
+ status: 'RECORDING',
+ externalBotId: 'recall-bot-1',
+ },
+ ]);
+
+ const result = await handleRecallWebhook({
+ client: client as unknown as CoreApiClient,
+ body: {
+ event: 'bot.status_change',
+ data: {
+ bot: {
+ id: 'recall-bot-1',
+ metadata: {
+ twentyWorkspaceId: WORKSPACE_ID,
+ twentyCallRecordingId: 'call-recording-1',
+ },
+ },
+ status: {
+ code: 'fatal',
+ sub_code: 'bot_errored',
+ },
+ },
+ },
+ });
+
+ expect(result).toEqual({
+ status: 'updated',
+ event: 'bot.status_change',
+ callRecordingId: 'call-recording-1',
+ callRecordingStatus: 'FAILED',
+ });
+ expect(client.mutations).toEqual([
+ {
+ id: 'call-recording-1',
+ data: {
+ status: 'FAILED',
+ externalBotId: 'recall-bot-1',
+ callRecorderFailureReason: 'bot_errored',
+ },
+ },
+ ]);
+ });
+
+ it('keeps the plain code mapping for a no-capture sub code when a recording is already known', async () => {
+ const client = new FakeCoreApiClient([
+ {
+ id: 'call-recording-1',
+ status: 'RECORDING',
+ externalBotId: 'recall-bot-1',
+ externalRecordingId: 'recall-recording-1',
+ },
+ ]);
+
+ const result = await handleRecallWebhook({
+ client: client as unknown as CoreApiClient,
+ body: {
+ event: 'bot.status_change',
+ data: {
+ bot: {
+ id: 'recall-bot-1',
+ metadata: {
+ twentyWorkspaceId: WORKSPACE_ID,
+ twentyCallRecordingId: 'call-recording-1',
+ },
+ },
+ status: {
+ code: 'call_ended',
+ sub_code: 'timeout_exceeded_noone_joined',
+ },
+ },
+ },
+ });
+
+ expect(result).toEqual({
+ status: 'updated',
+ event: 'bot.status_change',
+ callRecordingId: 'call-recording-1',
+ callRecordingStatus: 'PROCESSING',
+ });
+ expect(client.mutations).toEqual([
+ {
+ id: 'call-recording-1',
+ data: {
+ status: 'PROCESSING',
+ externalBotId: 'recall-bot-1',
+ },
+ },
+ ]);
+ });
+
+ it('ignores a no-capture sub code on a non-terminal status code', async () => {
+ const client = new FakeCoreApiClient([
+ {
+ id: 'call-recording-1',
+ status: 'SCHEDULED',
+ externalBotId: 'recall-bot-1',
+ },
+ ]);
+
+ const result = await handleRecallWebhook({
+ client: client as unknown as CoreApiClient,
+ body: {
+ event: 'bot.status_change',
+ data: {
+ bot: {
+ id: 'recall-bot-1',
+ metadata: {
+ twentyWorkspaceId: WORKSPACE_ID,
+ twentyCallRecordingId: 'call-recording-1',
+ },
+ },
+ status: {
+ code: 'joining_call',
+ sub_code: 'timeout_exceeded_noone_joined',
+ },
+ },
+ },
+ });
+
+ expect(result).toEqual({
+ status: 'updated',
+ event: 'bot.status_change',
+ callRecordingId: 'call-recording-1',
+ callRecordingStatus: 'JOINING',
+ });
+ expect(client.mutations).toEqual([
+ {
+ id: 'call-recording-1',
+ data: {
+ status: 'JOINING',
+ externalBotId: 'recall-bot-1',
+ },
+ },
+ ]);
+ });
+
it('reads bot metadata nested under data when a top-level bot has none', async () => {
const client = new FakeCoreApiClient([
{
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts
index 9a90907af4..9eb077819e 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts
@@ -67,6 +67,7 @@ type CallRecordingNode = {
calendarEventId?: string | null;
externalBotId?: string | null;
externalRecordingId?: string | null;
+ callRecorderFailureReason?: string | null;
};
type FakeCoreApiClientFixture = {
@@ -659,6 +660,45 @@ describe('reconcileCallRecorderForCalendarEventIds', () => {
]);
});
+ it('resets a NOT_RECORDED recording to SCHEDULED and clears its reason for an upcoming meeting', async () => {
+ const client = buildFakeCoreApiClient({
+ calendarEvents: [buildCalendarEvent()],
+ callRecordings: [
+ {
+ id: buildCustomerSyncCallRecordingId(),
+ title: 'Customer Sync',
+ status: 'NOT_RECORDED',
+ recordingRequestStatus: 'REQUESTED',
+ startedAt: FUTURE_STARTS_AT,
+ endedAt: FUTURE_ENDS_AT,
+ calendarEventId: 'calendar-event-1',
+ externalBotId: 'recall-bot-1',
+ callRecorderFailureReason: 'timeout_exceeded_noone_joined',
+ },
+ ],
+ });
+
+ const result = await reconcileCallRecorderForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1'],
+ now: NOW,
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ action: 'UPDATED',
+ callRecordingId: buildCustomerSyncCallRecordingId(),
+ }),
+ ]);
+ expect(client.callRecordings).toEqual([
+ expect.objectContaining({
+ id: buildCustomerSyncCallRecordingId(),
+ status: 'SCHEDULED',
+ callRecorderFailureReason: null,
+ }),
+ ]);
+ });
+
it('creates a single recording when duplicate synced rows share the same real meeting', async () => {
const client = buildFakeCoreApiClient({
calendarEvents: [
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/handle-recall-webhook.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/handle-recall-webhook.util.ts
index 691cf1bc00..d7ce23e95a 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/handle-recall-webhook.util.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/handle-recall-webhook.util.ts
@@ -67,13 +67,14 @@ const handleRecallStatusEvent = async ({
client: CoreApiClient;
webhookEvent: RecallWebhookEvent;
}): Promise => {
- const { event, statusCode } = webhookEvent;
- const callRecordingStatus = mapRecallEventToCallRecordingStatus({
+ const { event, statusCode, statusSubCode } = webhookEvent;
+ const mappedCallRecordingStatus = mapRecallEventToCallRecordingStatus({
event,
statusCode,
+ statusSubCode,
});
- if (isUndefined(callRecordingStatus)) {
+ if (isUndefined(mappedCallRecordingStatus)) {
return {
status: 'skipped',
event,
@@ -94,6 +95,13 @@ const handleRecallStatusEvent = async ({
};
}
+ const callRecordingStatus = resolveStatusAgainstKnownRecording({
+ mappedStatus: mappedCallRecordingStatus,
+ statusCode,
+ callRecording,
+ webhookEvent,
+ });
+
if (
isCallRecordingStatusDowngrade({
fromStatus: callRecording.status,
@@ -221,12 +229,42 @@ const findMatchingCallRecording = async ({
)[0];
};
+// Mirrors the snapshot extractor: a known recording artifact rules out NOT_RECORDED.
+const resolveStatusAgainstKnownRecording = ({
+ mappedStatus,
+ statusCode,
+ callRecording,
+ webhookEvent,
+}: {
+ mappedStatus: CallRecordingStatus;
+ statusCode: string | undefined;
+ callRecording: CallRecordingRecord;
+ webhookEvent: RecallWebhookEvent;
+}): CallRecordingStatus => {
+ const hasKnownRecording =
+ !isUndefined(callRecording.externalRecordingId) ||
+ !isUndefined(webhookEvent.externalRecordingId);
+
+ if (
+ mappedStatus !== CallRecordingStatus.NOT_RECORDED ||
+ !hasKnownRecording
+ ) {
+ return mappedStatus;
+ }
+
+ return statusCode === 'fatal'
+ ? CallRecordingStatus.FAILED
+ : CallRecordingStatus.PROCESSING;
+};
+
const mapRecallEventToCallRecordingStatus = ({
event,
statusCode,
+ statusSubCode,
}: {
event: string;
statusCode: string | undefined;
+ statusSubCode: string | undefined;
}): CallRecordingStatus | undefined => {
if (event === 'recording.done') {
return CallRecordingStatus.PROCESSING;
@@ -236,7 +274,10 @@ const mapRecallEventToCallRecordingStatus = ({
return CallRecordingStatus.FAILED;
}
- return mapRecallStatusCodeToCallRecordingStatus(statusCode);
+ return mapRecallStatusCodeToCallRecordingStatus({
+ statusCode,
+ statusSubCode,
+ });
};
const buildRecordingTimestampsUpdate = ({
@@ -278,17 +319,16 @@ const buildExternalRecordingIdUpdate = (
? {}
: { externalRecordingId: webhookEvent.externalRecordingId };
-type NonFailedCallRecordingStatus = Exclude<
- CallRecordingStatus,
- CallRecordingStatus.FAILED
->;
+type UnavailableCallRecordingStatus =
+ | CallRecordingStatus.FAILED
+ | CallRecordingStatus.NOT_RECORDED;
type CallRecordingStatusUpdate =
| {
- status: NonFailedCallRecordingStatus;
+ status: Exclude;
}
| {
- status: CallRecordingStatus.FAILED;
+ status: UnavailableCallRecordingStatus;
callRecorderFailureReason: string;
};
@@ -299,7 +339,10 @@ const buildCallRecordingStatusUpdate = ({
reason: string;
status: CallRecordingStatus;
}): CallRecordingStatusUpdate => {
- if (status === CallRecordingStatus.FAILED) {
+ if (
+ status === CallRecordingStatus.FAILED ||
+ status === CallRecordingStatus.NOT_RECORDED
+ ) {
return { status, callRecorderFailureReason: reason };
}
@@ -309,4 +352,5 @@ const buildCallRecordingStatusUpdate = ({
const getRecallWebhookFailureReason = ({
event,
statusCode,
-}: RecallWebhookEvent): string => statusCode ?? event;
+ statusSubCode,
+}: RecallWebhookEvent): string => statusSubCode ?? statusCode ?? event;
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reconcile-call-recorder.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reconcile-call-recorder.util.ts
index 2f9302bb1e..afc0c81519 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reconcile-call-recorder.util.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reconcile-call-recorder.util.ts
@@ -12,6 +12,7 @@ import { aggregateCallRecorderPolicyResultsByMeeting } from 'src/logic-functions
import { buildCallRecorderPolicyResult } from 'src/logic-functions/domain/build-call-recorder-policy-result.util';
import { cancelCallRecordingRequest } from 'src/logic-functions/flows/cancel-call-recording-request.util';
import { computeCallRecordingIdForMeeting } from 'src/logic-functions/domain/compute-call-recording-id-for-meeting.util';
+import { isUnavailableCallRecordingStatus } from 'src/logic-functions/domain/is-unavailable-call-recording-status.util';
import {
createCallRecording,
type ScheduledCallRecordingFields,
@@ -473,7 +474,7 @@ const canResetCallRecordingStatusToScheduled = (
status: string | undefined,
): boolean =>
status === CallRecordingStatus.SCHEDULED ||
- status === CallRecordingStatus.FAILED;
+ isUnavailableCallRecordingStatus(status);
const buildRemovedCalendarEventIdsByMeetingKey = (
removedOccurrences: RemovedCallRecorderOccurrence[],
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/sync-call-recording.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/sync-call-recording.util.ts
index 91bd1113d4..bf3d978a28 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/sync-call-recording.util.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/sync-call-recording.util.ts
@@ -3,6 +3,7 @@ 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 { isUnavailableCallRecordingStatus } from 'src/logic-functions/domain/is-unavailable-call-recording-status.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';
@@ -147,7 +148,7 @@ const buildSyncStateFieldUpdates = ({
) {
updateData.status = syncState.status;
- if (syncState.status === CallRecordingStatus.FAILED) {
+ if (isUnavailableCallRecordingStatus(syncState.status)) {
updateData.callRecorderFailureReason =
syncState.failureReason ?? 'recall_bot_failed';
}
@@ -174,7 +175,7 @@ const buildSyncStateFieldUpdates = ({
return updateData;
};
-// The bot completed without ever recording; FAILED rather than COMPLETED because completion bills.
+// The bot completed without ever producing a recording, so nothing was captured.
const buildMissingArtifactsFailureUpdate = ({
currentStatus,
pendingStatus,
@@ -185,19 +186,19 @@ const buildMissingArtifactsFailureUpdate = ({
recallFailureReason: string | undefined;
}): CallRecordingUpdateFields => {
if (
- pendingStatus === CallRecordingStatus.FAILED ||
+ isUnavailableCallRecordingStatus(pendingStatus) ||
isCallRecordingStatusDowngrade({
fromStatus: currentStatus,
- toStatus: CallRecordingStatus.FAILED,
+ toStatus: CallRecordingStatus.NOT_RECORDED,
})
) {
return {};
}
return {
- status: CallRecordingStatus.FAILED,
+ status: CallRecordingStatus.NOT_RECORDED,
callRecorderFailureReason:
- recallFailureReason ?? 'recording_artifacts_unavailable',
+ recallFailureReason ?? 'recall_bot_did_not_record',
};
};
@@ -232,11 +233,11 @@ const resolveMediaImportUpdate = ({
currentStatus: string | undefined;
pendingStatus: string | undefined;
}): CallRecordingUpdateFields => {
- const isRecordingFailed =
- currentStatus === CallRecordingStatus.FAILED ||
- pendingStatus === CallRecordingStatus.FAILED;
+ const hasNoRecording =
+ isUnavailableCallRecordingStatus(currentStatus) ||
+ isUnavailableCallRecordingStatus(pendingStatus);
- if (!isRecordingFailed) {
+ if (!hasNoRecording) {
return mediaImportUpdate;
}
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/extract-recall-bot-sync-state.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/extract-recall-bot-sync-state.test.ts
index 34b2658fa2..973540c7fa 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/extract-recall-bot-sync-state.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/extract-recall-bot-sync-state.test.ts
@@ -147,6 +147,69 @@ describe('extractRecallBotSyncState', () => {
expect(syncState.failureReason).toBe('recording_permission_denied');
});
+ it('classifies a no-capture leave as NOT_RECORDED even when a later done change hides it', () => {
+ const syncState = extractRecallBotSyncState(
+ buildRecallBotSnapshot({
+ statusChanges: [
+ { code: 'joining_call', createdAt: '2026-01-01T12:58:00.000Z' },
+ {
+ code: 'call_ended',
+ subCode: 'timeout_exceeded_noone_joined',
+ createdAt: '2026-01-01T13:10:00.000Z',
+ },
+ { code: 'done', createdAt: '2026-01-01T13:11:00.000Z' },
+ ],
+ }),
+ );
+
+ expect(syncState.status).toBe('NOT_RECORDED');
+ expect(syncState.failureReason).toBe('timeout_exceeded_noone_joined');
+ });
+
+ it('keeps FAILED with the sub code as reason for non-benign fatal outcomes', () => {
+ const syncState = extractRecallBotSyncState(
+ buildRecallBotSnapshot({
+ statusChanges: [
+ { code: 'joining_call', createdAt: '2026-01-01T12:58:00.000Z' },
+ {
+ code: 'fatal',
+ subCode: 'bot_errored',
+ createdAt: '2026-01-01T13:10:00.000Z',
+ },
+ ],
+ }),
+ );
+
+ expect(syncState.status).toBe('FAILED');
+ expect(syncState.failureReason).toBe('bot_errored');
+ });
+
+ it('never classifies NOT_RECORDED when a recording artifact exists', () => {
+ const syncState = extractRecallBotSyncState(
+ buildRecallBotSnapshot({
+ statusChanges: [
+ { code: 'in_call_recording', createdAt: '2026-01-01T13:02:00.000Z' },
+ {
+ code: 'call_ended',
+ subCode: 'timeout_exceeded_noone_joined',
+ createdAt: '2026-01-01T14:00:00.000Z',
+ },
+ { code: 'done', createdAt: '2026-01-01T14:05:00.000Z' },
+ ],
+ recordings: [
+ {
+ id: 'recall-recording-1',
+ startedAt: '2026-01-01T13:02:00.000Z',
+ completedAt: '2026-01-01T14:00:00.000Z',
+ },
+ ],
+ }),
+ );
+
+ expect(syncState.status).toBe('PROCESSING');
+ expect(syncState.failureReason).toBeUndefined();
+ });
+
it('leaves the status undefined for unknown latest codes', () => {
const syncState = extractRecallBotSyncState(
buildRecallBotSnapshot({
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/parse-recall-bot-snapshot.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/parse-recall-bot-snapshot.test.ts
index be8592c42a..6397403a35 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/parse-recall-bot-snapshot.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/__tests__/parse-recall-bot-snapshot.test.ts
@@ -10,6 +10,11 @@ describe('parseRecallBotSnapshot', () => {
metadata: { twentyWorkspaceId: 'workspace-1' },
status_changes: [
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
+ {
+ code: 'call_ended',
+ sub_code: 'timeout_exceeded_everyone_left',
+ created_at: '2026-01-01T14:00:00.000Z',
+ },
],
recordings: [
{
@@ -24,6 +29,11 @@ describe('parseRecallBotSnapshot', () => {
metadata: { twentyWorkspaceId: 'workspace-1' },
statusChanges: [
{ code: 'in_call_recording', createdAt: '2026-01-01T13:02:00.000Z' },
+ {
+ code: 'call_ended',
+ subCode: 'timeout_exceeded_everyone_left',
+ createdAt: '2026-01-01T14:00:00.000Z',
+ },
],
recordings: [
{
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/extract-recall-bot-sync-state.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/extract-recall-bot-sync-state.util.ts
index f6bf088744..286640b564 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/extract-recall-bot-sync-state.util.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/extract-recall-bot-sync-state.util.ts
@@ -23,17 +23,24 @@ export const extractRecallBotSyncState = (
): RecallBotSyncState => {
const { statusChanges } = bot;
const latestStatusChange = getLatestStatusChange(statusChanges);
- const status = mapRecallStatusCodeToCallRecordingStatus(
- latestStatusChange?.code,
- );
const recording = bot.recordings[0];
+ // A later 'done' change hides the no-capture sub code, so scan the history.
+ const notRecordedStatusChange = isUndefined(recording)
+ ? findNotRecordedStatusChange(statusChanges)
+ : undefined;
+ const status = isUndefined(notRecordedStatusChange)
+ ? mapRecallStatusCodeToCallRecordingStatus({
+ statusCode: latestStatusChange?.code,
+ })
+ : CallRecordingStatus.NOT_RECORDED;
return {
status,
- failureReason:
- status === CallRecordingStatus.FAILED
- ? latestStatusChange?.code
- : undefined,
+ failureReason: getFailureReason({
+ status,
+ latestStatusChange,
+ notRecordedStatusChange,
+ }),
startedAt: normalizeRecallTimestamp(
recording?.startedAt ??
findStatusChangeTimestamp(statusChanges, 'in_call_recording'),
@@ -49,6 +56,37 @@ export const extractRecallBotSyncState = (
};
};
+const findNotRecordedStatusChange = (
+ statusChanges: RecallBotStatusChange[],
+): RecallBotStatusChange | undefined =>
+ statusChanges.find(
+ (statusChange) =>
+ mapRecallStatusCodeToCallRecordingStatus({
+ statusCode: statusChange.code,
+ statusSubCode: statusChange.subCode,
+ }) === CallRecordingStatus.NOT_RECORDED,
+ );
+
+const getFailureReason = ({
+ status,
+ latestStatusChange,
+ notRecordedStatusChange,
+}: {
+ status: CallRecordingStatus | undefined;
+ latestStatusChange: RecallBotStatusChange | undefined;
+ notRecordedStatusChange: RecallBotStatusChange | undefined;
+}): string | undefined => {
+ if (status === CallRecordingStatus.NOT_RECORDED) {
+ return notRecordedStatusChange?.subCode;
+ }
+
+ if (status === CallRecordingStatus.FAILED) {
+ return latestStatusChange?.subCode ?? latestStatusChange?.code;
+ }
+
+ return undefined;
+};
+
const getLatestStatusChange = (
statusChanges: RecallBotStatusChange[],
): RecallBotStatusChange | undefined =>
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/parse-recall-bot-snapshot.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/parse-recall-bot-snapshot.util.ts
index 9e23203c9c..2c8cad48d8 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/parse-recall-bot-snapshot.util.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/parse-recall-bot-snapshot.util.ts
@@ -29,7 +29,13 @@ const parseStatusChanges = (value: unknown): RecallBotStatusChange[] => {
return [];
}
- return [{ code, createdAt: getString(asRecord(statusChange)?.created_at) }];
+ return [
+ {
+ code,
+ subCode: getString(asRecord(statusChange)?.sub_code),
+ createdAt: getString(asRecord(statusChange)?.created_at),
+ },
+ ];
});
};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts
index 9c9fe9a1b1..c81e81b669 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts
@@ -16,6 +16,7 @@ export type RecallWebhookBody = {
export type RecallWebhookEvent = {
event: string;
statusCode: string | undefined;
+ statusSubCode: string | undefined;
statusTimestamp: string | undefined;
externalBotId: string | undefined;
externalRecordingId: string | undefined;
@@ -23,7 +24,6 @@ export type RecallWebhookEvent = {
recordingStartedAt: string | undefined;
recordingEndedAt: string | undefined;
transcriptId: string | undefined;
- transcriptFailureSubCode: string | undefined;
};
// The only reader of raw webhook payloads; Recall delivers several body shapes per event family.
@@ -46,6 +46,10 @@ export const parseRecallWebhookEvent = (
getString(getRecordAtPath(data, ['data', 'code'])) ??
getString(getRecordAtPath(bot, ['status', 'code'])) ??
getStatusCodeFromEventName(event),
+ statusSubCode:
+ getString(getRecordAtPath(data, ['status', 'sub_code'])) ??
+ getString(getRecordAtPath(data, ['data', 'sub_code'])) ??
+ getString(getRecordAtPath(bot, ['status', 'sub_code'])),
statusTimestamp: normalizeRecallTimestamp(
getString(getRecordAtPath(data, ['status', 'created_at'])) ??
getString(getRecordAtPath(data, ['data', 'updated_at'])) ??
@@ -71,9 +75,6 @@ export const parseRecallWebhookEvent = (
getString(getRecordAtPath(data, ['recording', 'completed_at'])),
),
transcriptId: getString(getRecordAtPath(data, ['transcript', 'id'])),
- transcriptFailureSubCode: getString(
- getRecordAtPath(data, ['status', 'sub_code']),
- ),
};
};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/recall-bot-snapshot.type.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/recall-bot-snapshot.type.ts
index 8e39c6cabb..601b833ac6 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/recall-bot-snapshot.type.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/recall-api/recall-bot-snapshot.type.ts
@@ -1,5 +1,6 @@
export type RecallBotStatusChange = {
code: string;
+ subCode?: string | undefined;
createdAt: string | undefined;
};