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; };