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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23693?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:
nitin
2026-08-03 23:49:34 +05:30
committed by GitHub
parent 713fae189d
commit b28fc54b44
24 changed files with 513 additions and 46 deletions
@@ -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"
@@ -230,11 +230,13 @@ const buildBotStatusChangeWebhook = ({
botId,
metadata,
statusCode,
statusSubCode,
statusTimestamp,
}: {
botId: string;
metadata: Record<string, string>;
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();
@@ -6,4 +6,5 @@ export enum CallRecordingStatus {
PROCESSING = 'PROCESSING',
COMPLETED = 'COMPLETED',
FAILED = 'FAILED',
NOT_RECORDED = 'NOT_RECORDED',
}
@@ -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',
];
@@ -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,
];
@@ -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,
@@ -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);
});
});
@@ -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, number> = {
[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);
};
@@ -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);
@@ -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);
@@ -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':
@@ -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,
@@ -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',
},
},
]);
@@ -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([
{
@@ -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: [
@@ -67,13 +67,14 @@ const handleRecallStatusEvent = async ({
client: CoreApiClient;
webhookEvent: RecallWebhookEvent;
}): Promise<RecallWebhookHandlerResult> => {
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<CallRecordingStatus, UnavailableCallRecordingStatus>;
}
| {
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;
@@ -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[],
@@ -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;
}
@@ -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({
@@ -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: [
{
@@ -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 =>
@@ -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),
},
];
});
};
@@ -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']),
),
};
};
@@ -1,5 +1,6 @@
export type RecallBotStatusChange = {
code: string;
subCode?: string | undefined;
createdAt: string | undefined;
};