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:
@@ -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"
|
||||
|
||||
+37
@@ -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();
|
||||
|
||||
+1
@@ -6,4 +6,5 @@ export enum CallRecordingStatus {
|
||||
PROCESSING = 'PROCESSING',
|
||||
COMPLETED = 'COMPLETED',
|
||||
FAILED = 'FAILED',
|
||||
NOT_RECORDED = 'NOT_RECORDED',
|
||||
}
|
||||
|
||||
+10
@@ -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',
|
||||
];
|
||||
+6
@@ -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,
|
||||
];
|
||||
+4
@@ -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,
|
||||
|
||||
+32
@@ -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
-1
@@ -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);
|
||||
};
|
||||
|
||||
+8
@@ -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);
|
||||
+8
@@ -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);
|
||||
+16
-3
@@ -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':
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+3
-3
@@ -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',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
+140
@@ -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([
|
||||
{
|
||||
|
||||
+40
@@ -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: [
|
||||
|
||||
+56
-12
@@ -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;
|
||||
|
||||
+2
-1
@@ -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[],
|
||||
|
||||
+11
-10
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+63
@@ -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
@@ -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: [
|
||||
{
|
||||
|
||||
+45
-7
@@ -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 =>
|
||||
|
||||
+7
-1
@@ -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),
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+5
-4
@@ -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
@@ -1,5 +1,6 @@
|
||||
export type RecallBotStatusChange = {
|
||||
code: string;
|
||||
subCode?: string | undefined;
|
||||
createdAt: string | undefined;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user