diff --git a/packages/twenty-apps/public/call-recorder/package.json b/packages/twenty-apps/public/call-recorder/package.json index 73c85c7f5a..1f15fcf940 100644 --- a/packages/twenty-apps/public/call-recorder/package.json +++ b/packages/twenty-apps/public/call-recorder/package.json @@ -1,6 +1,6 @@ { "name": "@twentyhq/call-recorder", - "version": "1.0.4", + "version": "1.0.5", "license": "MIT", "engines": { "node": "^24.5.0", diff --git a/packages/twenty-apps/public/call-recorder/src/application-config.ts b/packages/twenty-apps/public/call-recorder/src/application-config.ts index e83b8228c1..2d6d2731bd 100644 --- a/packages/twenty-apps/public/call-recorder/src/application-config.ts +++ b/packages/twenty-apps/public/call-recorder/src/application-config.ts @@ -14,6 +14,7 @@ import { CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME } from 'src/logic-funct import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds'; import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds-env-var-name'; import { CALL_RECORDER_JOIN_EARLY_MINUTES_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-join-early-minutes-env-var-name'; +import { CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-max-media-file-size-mb-env-var-name'; import { CALL_RECORDER_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-name-env-var-name'; import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds'; import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds-env-var-name'; @@ -23,6 +24,7 @@ import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS } from 'src/logic-functions/ import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds-env-var-name'; import { DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND } from 'src/logic-functions/constants/default-call-recorder-bot-image-background'; import { DEFAULT_CALL_RECORDER_JOIN_EARLY_MINUTES } from 'src/logic-functions/constants/default-call-recorder-join-early-minutes'; +import { DEFAULT_CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB } from 'src/logic-functions/constants/default-call-recorder-max-media-file-size-mb'; import { DEFAULT_CALL_RECORDER_NAME } from 'src/logic-functions/constants/default-call-recorder-name'; import { DEFAULT_CALL_RECORDER_RECORDING_RETENTION_HOURS } from 'src/logic-functions/constants/default-call-recorder-recording-retention-hours'; import { DEFAULT_CALL_RECORDER_USE_WORKSPACE_LOGO } from 'src/logic-functions/constants/default-call-recorder-use-workspace-logo'; @@ -109,6 +111,10 @@ export default defineApplication({ description: `How many hours Recall.ai retains recording media after processing. Defaults to ${DEFAULT_CALL_RECORDER_RECORDING_RETENTION_HOURS} hours (6 days and 22 hours) to stay below Recall.ai's 7-day free storage window. Values above 168 hours may incur Recall.ai storage charges.`, isSecret: false, }, + [CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME]: { + description: `Maximum size in megabytes for a single recording media file (video or audio) ingested from Recall.ai. Larger files are skipped and noted in the call recording failure reason; the recording still completes with its remaining artifacts. Defaults to ${DEFAULT_CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB} MB to keep media ingestion within the logic function memory limit.`, + isSecret: false, + }, [RECALL_WEBHOOK_SECRET_ENV_VAR_NAME]: { description: 'Recall.ai webhook signing secret (whsec_...). Set by the server admin from the Recall webhook endpoint settings; used to verify the Svix signature of incoming Recall webhook deliveries.', diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-max-media-file-size-mb-env-var-name.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-max-media-file-size-mb-env-var-name.ts new file mode 100644 index 0000000000..7638ae89a9 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-max-media-file-size-mb-env-var-name.ts @@ -0,0 +1,2 @@ +export const CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME = + 'CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB'; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-max-media-file-size-mb.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-max-media-file-size-mb.ts new file mode 100644 index 0000000000..7165af6aca --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-max-media-file-size-mb.ts @@ -0,0 +1 @@ +export const DEFAULT_CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB = 80; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/get-max-media-file-size-bytes.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/get-max-media-file-size-bytes.ts new file mode 100644 index 0000000000..40d8b7aad3 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/get-max-media-file-size-bytes.ts @@ -0,0 +1,23 @@ +import { CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-max-media-file-size-mb-env-var-name'; +import { DEFAULT_CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB } from 'src/logic-functions/constants/default-call-recorder-max-media-file-size-mb'; +import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +const BYTES_PER_MEGABYTE = 1024 * 1024; + +export const getMaxMediaFileSizeBytes = (): number => { + const configuredMaxMediaFileSizeMb = getApplicationVariableValue( + CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME, + ); + + const maxMediaFileSizeMb = isNonEmptyString(configuredMaxMediaFileSizeMb) + ? Number(configuredMaxMediaFileSizeMb.trim()) + : NaN; + + const resolvedMaxMediaFileSizeMb = + Number.isFinite(maxMediaFileSizeMb) && maxMediaFileSizeMb > 0 + ? maxMediaFileSizeMb + : DEFAULT_CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB; + + return resolvedMaxMediaFileSizeMb * BYTES_PER_MEGABYTE; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/media-file-too-large-failure-reasons.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/media-file-too-large-failure-reasons.ts new file mode 100644 index 0000000000..7d59195a29 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/media-file-too-large-failure-reasons.ts @@ -0,0 +1,2 @@ +export const VIDEO_FILE_TOO_LARGE_FAILURE_REASON = 'video_file_too_large'; +export const AUDIO_FILE_TOO_LARGE_FAILURE_REASON = 'audio_file_too_large'; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-call-recording-ingestion-complete.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-call-recording-ingestion-complete.test.ts index a7052f6fa7..11e00d23ca 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-call-recording-ingestion-complete.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-call-recording-ingestion-complete.test.ts @@ -13,6 +13,7 @@ describe('isCallRecordingIngestionComplete', () => { transcript: TRANSCRIPT_CONTENT, audio: AUDIO_VALUE, video: VIDEO_VALUE, + callRecorderFailureReason: undefined, }), ).toBe(true); }); @@ -26,6 +27,7 @@ describe('isCallRecordingIngestionComplete', () => { }, audio: AUDIO_VALUE, video: VIDEO_VALUE, + callRecorderFailureReason: undefined, }), ).toBe(false); }); @@ -36,6 +38,7 @@ describe('isCallRecordingIngestionComplete', () => { transcript: null, audio: AUDIO_VALUE, video: VIDEO_VALUE, + callRecorderFailureReason: undefined, }), ).toBe(false); }); @@ -46,6 +49,7 @@ describe('isCallRecordingIngestionComplete', () => { transcript: TRANSCRIPT_CONTENT, audio: undefined, video: VIDEO_VALUE, + callRecorderFailureReason: undefined, }), ).toBe(false); expect( @@ -53,6 +57,56 @@ describe('isCallRecordingIngestionComplete', () => { transcript: TRANSCRIPT_CONTENT, audio: AUDIO_VALUE, video: [], + callRecorderFailureReason: undefined, + }), + ).toBe(false); + }); + + it('treats a media file skipped for size as resolved', () => { + expect( + isCallRecordingIngestionComplete({ + transcript: TRANSCRIPT_CONTENT, + audio: AUDIO_VALUE, + video: undefined, + callRecorderFailureReason: 'video_file_too_large', + }), + ).toBe(true); + expect( + isCallRecordingIngestionComplete({ + transcript: TRANSCRIPT_CONTENT, + audio: undefined, + video: VIDEO_VALUE, + callRecorderFailureReason: 'audio_file_too_large', + }), + ).toBe(true); + expect( + isCallRecordingIngestionComplete({ + transcript: TRANSCRIPT_CONTENT, + audio: undefined, + video: undefined, + callRecorderFailureReason: 'video_file_too_large,audio_file_too_large', + }), + ).toBe(true); + }); + + it('does not let a size marker excuse the other missing artifact', () => { + expect( + isCallRecordingIngestionComplete({ + transcript: TRANSCRIPT_CONTENT, + audio: undefined, + video: VIDEO_VALUE, + callRecorderFailureReason: 'video_file_too_large', + }), + ).toBe(false); + }); + + it('ignores unrelated failure reasons', () => { + expect( + isCallRecordingIngestionComplete({ + transcript: TRANSCRIPT_CONTENT, + audio: AUDIO_VALUE, + video: undefined, + callRecorderFailureReason: 'recall_bot_not_found', }), ).toBe(false); }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/parse-media-file-too-large-markers.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/parse-media-file-too-large-markers.test.ts new file mode 100644 index 0000000000..3b86d03283 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/parse-media-file-too-large-markers.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { parseMediaFileTooLargeMarkers } from 'src/logic-functions/domain/parse-media-file-too-large-markers.util'; + +describe('parseMediaFileTooLargeMarkers', () => { + it('parses single-artifact markers', () => { + expect(parseMediaFileTooLargeMarkers('video_file_too_large')).toEqual({ + audioFileTooLarge: false, + videoFileTooLarge: true, + }); + expect(parseMediaFileTooLargeMarkers('audio_file_too_large')).toEqual({ + audioFileTooLarge: true, + videoFileTooLarge: false, + }); + }); + + it('parses comma-joined markers for both artifacts', () => { + expect( + parseMediaFileTooLargeMarkers( + 'video_file_too_large,audio_file_too_large', + ), + ).toEqual({ + audioFileTooLarge: true, + videoFileTooLarge: true, + }); + }); + + it('reports no markers for unset or unrelated reasons', () => { + expect(parseMediaFileTooLargeMarkers(undefined)).toEqual({ + audioFileTooLarge: false, + videoFileTooLarge: false, + }); + expect(parseMediaFileTooLargeMarkers(null)).toEqual({ + audioFileTooLarge: false, + videoFileTooLarge: false, + }); + expect(parseMediaFileTooLargeMarkers('')).toEqual({ + audioFileTooLarge: false, + videoFileTooLarge: false, + }); + expect(parseMediaFileTooLargeMarkers('recall_bot_not_found')).toEqual({ + audioFileTooLarge: false, + videoFileTooLarge: false, + }); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/should-complete-call-recording-ingestion.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/should-complete-call-recording-ingestion.test.ts index 87c95e643a..bae388da11 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/should-complete-call-recording-ingestion.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/should-complete-call-recording-ingestion.test.ts @@ -66,6 +66,37 @@ describe('shouldCompleteCallRecordingIngestion', () => { ).toBe(false); }); + it('completes when a missing media file is marked too large', () => { + expect( + shouldCompleteCallRecordingIngestion({ + current: { + status: CallRecordingStatus.PROCESSING, + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: '2026-06-10T10:00:00.000Z', + transcript: filledTranscript, + audio: filledAudio, + }, + updateData: { + callRecorderFailureReason: 'video_file_too_large', + }, + }), + ).toBe(true); + + expect( + shouldCompleteCallRecordingIngestion({ + current: { + status: CallRecordingStatus.PROCESSING, + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: '2026-06-10T10:00:00.000Z', + transcript: filledTranscript, + audio: filledAudio, + callRecorderFailureReason: 'video_file_too_large', + }, + updateData: {}, + }), + ).toBe(true); + }); + it('does not complete a persisted failed recording', () => { expect( shouldCompleteCallRecordingIngestion({ diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-call-recording-ingestion-complete.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-call-recording-ingestion-complete.util.ts index ec32b40758..0bc970bcb7 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-call-recording-ingestion-complete.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-call-recording-ingestion-complete.util.ts @@ -1,19 +1,28 @@ import { isNonEmptyArray, isNull, isUndefined } from '@sniptt/guards'; import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type'; +import { parseMediaFileTooLargeMarkers } from 'src/logic-functions/domain/parse-media-file-too-large-markers.util'; import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util'; export const isCallRecordingIngestionComplete = ({ transcript, audio, video, + callRecorderFailureReason, }: { transcript: unknown; audio: FilesFieldValue | undefined; video: FilesFieldValue | undefined; -}): boolean => - !isNull(transcript) && - !isUndefined(transcript) && - isUndefined(parseTranscriptMarker(transcript)) && - isNonEmptyArray(audio) && - isNonEmptyArray(video); + callRecorderFailureReason: string | null | undefined; +}): boolean => { + const { audioFileTooLarge, videoFileTooLarge } = + parseMediaFileTooLargeMarkers(callRecorderFailureReason); + + return ( + !isNull(transcript) && + !isUndefined(transcript) && + isUndefined(parseTranscriptMarker(transcript)) && + (isNonEmptyArray(audio) || audioFileTooLarge) && + (isNonEmptyArray(video) || videoFileTooLarge) + ); +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/parse-media-file-too-large-markers.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/parse-media-file-too-large-markers.util.ts new file mode 100644 index 0000000000..58c5c0fe9b --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/parse-media-file-too-large-markers.util.ts @@ -0,0 +1,27 @@ +import { + AUDIO_FILE_TOO_LARGE_FAILURE_REASON, + VIDEO_FILE_TOO_LARGE_FAILURE_REASON, +} from 'src/logic-functions/constants/media-file-too-large-failure-reasons'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +export type MediaFileTooLargeMarkers = { + audioFileTooLarge: boolean; + videoFileTooLarge: boolean; +}; + +export const parseMediaFileTooLargeMarkers = ( + callRecorderFailureReason: string | null | undefined, +): MediaFileTooLargeMarkers => { + const failureReasons = isNonEmptyString(callRecorderFailureReason) + ? callRecorderFailureReason.split(',').map((reason) => reason.trim()) + : []; + + return { + audioFileTooLarge: failureReasons.includes( + AUDIO_FILE_TOO_LARGE_FAILURE_REASON, + ), + videoFileTooLarge: failureReasons.includes( + VIDEO_FILE_TOO_LARGE_FAILURE_REASON, + ), + }; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/should-complete-call-recording-ingestion.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/should-complete-call-recording-ingestion.util.ts index b6c054e96a..cf1c711401 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/should-complete-call-recording-ingestion.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/should-complete-call-recording-ingestion.util.ts @@ -15,6 +15,7 @@ export const shouldCompleteCallRecordingIngestion = ({ transcript?: unknown; audio?: FilesFieldValue; video?: FilesFieldValue; + callRecorderFailureReason?: string | null; }; updateData: CallRecordingUpdateFields; }): boolean => @@ -29,4 +30,6 @@ export const shouldCompleteCallRecordingIngestion = ({ transcript: updateData.transcript ?? current.transcript, audio: updateData.audio ?? current.audio, video: updateData.video ?? current.video, + callRecorderFailureReason: + updateData.callRecorderFailureReason ?? current.callRecorderFailureReason, }); 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 76a3d97ea5..660e969574 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 @@ -279,6 +279,163 @@ describe('convergeDivergedCallRecordings', () => { }); }); + it('completes and charges when the missing video is marked too large', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + status_changes: [ + { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-09T13:02:00.000Z', + completed_at: '2026-06-09T14:00:00.000Z', + }, + ], + }, + }); + ingestCallRecordingMediaMock.mockResolvedValue({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + callRecorderFailureReason: 'video_file_too_large', + }); + const client = buildClient([ + buildStuckRecordingNode({ + status: 'PROCESSING', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + externalRecordingId: 'recall-recording-1', + transcript: [{ participant: { id: 1 }, words: [] }], + }), + ]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + callRecorderFailureReason: 'video_file_too_large', + }, + }, + { + id: 'call-recording-1', + data: { status: 'COMPLETED' }, + }, + ]); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + }); + expect(result.updatedCallRecordingIds).toEqual(['call-recording-1']); + }); + + it('completes from a persisted size marker once the transcript lands', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + status_changes: [ + { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-09T13:02:00.000Z', + completed_at: '2026-06-09T14:00:00.000Z', + }, + ], + }, + }); + const client = buildClient([ + buildStuckRecordingNode({ + status: 'PROCESSING', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + externalRecordingId: 'recall-recording-1', + callRecorderFailureReason: 'video_file_too_large', + audio: [{ fileId: 'file-audio-1' }], + transcript: [{ participant: { id: 1 }, words: [] }], + }), + ]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(ingestCallRecordingMediaMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: true, + hasVideo: false, + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { status: 'COMPLETED' }, + }, + ]); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + }); + expect(result.updatedCallRecordingIds).toEqual(['call-recording-1']); + }); + + it('keeps the real failure reason over the size marker when the bot failed', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + status_changes: [ + { code: 'fatal', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-09T13:02:00.000Z', + completed_at: '2026-06-09T14:00:00.000Z', + }, + ], + }, + }); + ingestCallRecordingMediaMock.mockResolvedValue({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + callRecorderFailureReason: 'video_file_too_large', + }); + const client = buildClient([ + buildStuckRecordingNode({ + status: 'PROCESSING', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + externalRecordingId: 'recall-recording-1', + transcript: [{ participant: { id: 1 }, words: [] }], + }), + ]); + + await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'FAILED', + callRecorderFailureReason: 'fatal', + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + }, + }, + ]); + expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + }); + it('skips records whose meeting has not started yet', async () => { const client = buildClient([ buildStuckRecordingNode({ 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 81f5c1930d..d21742945a 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 @@ -936,6 +936,110 @@ describe('handleRecallWebhook', () => { }); }); + it('completes and keeps the size marker when a media file is too large', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { id: 'recall-bot-1' }, + }); + ingestCallRecordingMediaMock.mockResolvedValue({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + callRecorderFailureReason: 'video_file_too_large', + }); + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:05:00.000Z', + transcript: [{ participant: { id: 1 }, words: [] }], + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: buildRecordingDoneWebhookBody(), + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + callRecorderFailureReason: 'video_file_too_large', + }, + }, + { + id: 'call-recording-1', + data: { status: 'COMPLETED' }, + }, + ]); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:05:00.000Z', + }); + expect(result).toEqual({ + status: 'updated', + event: 'recording.done', + callRecordingId: 'call-recording-1', + callRecordingStatus: 'COMPLETED', + }); + }); + + it('keeps the real failure reason over the size marker on recording.failed', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { id: 'recall-bot-1' }, + }); + ingestCallRecordingMediaMock.mockResolvedValue({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + callRecorderFailureReason: 'video_file_too_large', + }); + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:05:00.000Z', + transcript: [{ participant: { id: 1 }, words: [] }], + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + ...buildRecordingDoneWebhookBody(), + event: 'recording.failed', + }, + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + status: 'FAILED', + callRecorderFailureReason: 'recording.failed', + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + }, + }, + ]); + expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + status: 'updated', + event: 'recording.failed', + callRecordingId: 'call-recording-1', + callRecordingStatus: 'FAILED', + }); + }); + it('stays PROCESSING on recording.done while artifacts are missing', async () => { getRecallBotMock.mockResolvedValue({ ok: true, diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/ingest-call-recording-media.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/ingest-call-recording-media.test.ts index 3506fd2505..93ca765dc1 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/ingest-call-recording-media.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/ingest-call-recording-media.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-max-media-file-size-mb-env-var-name'; import { ingestCallRecordingMedia } from 'src/logic-functions/flows/ingest-call-recording-media.util'; const uploadFileMock = vi.hoisted(() => vi.fn()); @@ -15,19 +16,66 @@ vi.mock('src/logic-functions/recall-api/get-recall-recording.util', () => ({ getRecallRecording: getRecallRecordingMock, })); +const VIDEO_URL = 'https://media.example.com/video.mp4'; +const AUDIO_URL = 'https://media.example.com/audio.mp3'; + const RECORDING_WITH_MEDIA = { id: 'recall-recording-1', media_shortcuts: { - video_mixed: { download_url: 'https://media.example.com/video.mp4' }, - audio_mixed: { download_url: 'https://media.example.com/audio.mp3' }, + video_mixed: { download_url: VIDEO_URL }, + audio_mixed: { download_url: AUDIO_URL }, }, }; -const buildFetchResponse = () => ({ - ok: true, - headers: { get: () => 'video/mp4' }, - arrayBuffer: async () => new ArrayBuffer(8), -}); +const buildBodyStream = (chunks: Uint8Array[]): ReadableStream => + new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + controller.close(); + }, + }); + +const buildFetchResponse = ({ + contentType = 'video/mp4', + contentLengthBytes, + body, +}: { + contentType?: string; + contentLengthBytes?: number; + body?: unknown; +} = {}) => { + const headers = new Map([['content-type', contentType]]); + + if (contentLengthBytes !== undefined) { + headers.set('content-length', String(contentLengthBytes)); + } + + return { + ok: true, + status: 200, + headers: { + get: (name: string) => headers.get(name.toLowerCase()) ?? null, + }, + body: body ?? buildBodyStream([new Uint8Array(8)]), + }; +}; + +const stubFetchByUrl = (responsesByUrl: Record) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((url: string) => { + const response = responsesByUrl[url]; + + if (response === undefined) { + throw new Error(`Unhandled fetch url in test: ${url}`); + } + + return Promise.resolve(response); + }), + ); +}; describe('ingestCallRecordingMedia', () => { beforeEach(() => { @@ -38,11 +86,19 @@ describe('ingestCallRecordingMedia', () => { ok: true, recording: RECORDING_WITH_MEDIA, }); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildFetchResponse())); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockImplementation(() => + Promise.resolve(buildFetchResponse({ contentLengthBytes: 8 })), + ), + ); }); afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); it('downloads and uploads every missing artifact', async () => { @@ -150,4 +206,154 @@ describe('ingestCallRecordingMedia', () => { expect.stringContaining('recording boom'), ); }); + + it('skips an oversized file without reading its body and records the reason', async () => { + const cancelMock = vi.fn().mockResolvedValue(undefined); + + stubFetchByUrl({ + [VIDEO_URL]: buildFetchResponse({ + contentLengthBytes: 200 * 1024 * 1024, + body: { cancel: cancelMock }, + }), + [AUDIO_URL]: buildFetchResponse({ contentLengthBytes: 8 }), + }); + uploadFileMock.mockResolvedValue({ id: 'file-audio-1' }); + + const updateFields = await ingestCallRecordingMedia({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: false, + hasVideo: false, + }); + + expect(updateFields).toEqual({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + callRecorderFailureReason: 'video_file_too_large', + }); + expect(uploadFileMock).toHaveBeenCalledTimes(1); + expect(cancelMock).toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('artifact-too-large'), + ); + }); + + it('records both markers when video and audio exceed the cap', async () => { + stubFetchByUrl({ + [VIDEO_URL]: buildFetchResponse({ + contentLengthBytes: 200 * 1024 * 1024, + }), + [AUDIO_URL]: buildFetchResponse({ + contentLengthBytes: 120 * 1024 * 1024, + }), + }); + + const updateFields = await ingestCallRecordingMedia({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: false, + hasVideo: false, + }); + + expect(updateFields).toEqual({ + callRecorderFailureReason: 'video_file_too_large,audio_file_too_large', + }); + expect(uploadFileMock).not.toHaveBeenCalled(); + }); + + it('honors the cap configured through the environment', async () => { + vi.stubEnv(CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME, '1'); + stubFetchByUrl({ + [VIDEO_URL]: buildFetchResponse({ + contentLengthBytes: 2 * 1024 * 1024, + }), + [AUDIO_URL]: buildFetchResponse({ contentLengthBytes: 8 }), + }); + uploadFileMock.mockResolvedValue({ id: 'file-audio-1' }); + + const updateFields = await ingestCallRecordingMedia({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: false, + hasVideo: false, + }); + + expect(updateFields).toEqual({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + callRecorderFailureReason: 'video_file_too_large', + }); + }); + + it('falls back to the default cap when the configured value is invalid', async () => { + vi.stubEnv( + CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME, + 'not-a-number', + ); + uploadFileMock.mockResolvedValue({ id: 'file-video-1' }); + stubFetchByUrl({ + [VIDEO_URL]: buildFetchResponse({ + contentLengthBytes: 2 * 1024 * 1024, + }), + }); + + const updateFields = await ingestCallRecordingMedia({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: true, + hasVideo: false, + }); + + expect(updateFields).toEqual({ + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + }); + }); + + it('enforces the cap while reading a response without content-length', async () => { + vi.stubEnv(CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB_ENV_VAR_NAME, '1'); + stubFetchByUrl({ + [VIDEO_URL]: buildFetchResponse({ + body: buildBodyStream([ + new Uint8Array(700_000), + new Uint8Array(700_000), + ]), + }), + }); + + const updateFields = await ingestCallRecordingMedia({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: true, + hasVideo: false, + }); + + expect(updateFields).toEqual({ + callRecorderFailureReason: 'video_file_too_large', + }); + expect(uploadFileMock).not.toHaveBeenCalled(); + }); + + it('ingests a response without content-length when it stays within the cap', async () => { + uploadFileMock.mockResolvedValue({ id: 'file-video-1' }); + stubFetchByUrl({ + [VIDEO_URL]: buildFetchResponse({ + body: buildBodyStream([new Uint8Array([1, 2, 3]), new Uint8Array([4])]), + }), + }); + + const updateFields = await ingestCallRecordingMedia({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: true, + hasVideo: false, + }); + + expect(updateFields).toEqual({ + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + }); + expect(uploadFileMock).toHaveBeenCalledWith( + Buffer.from([1, 2, 3, 4]), + 'video.mp4', + 'video/mp4', + expect.any(String), + ); + }); }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/converge-diverged-call-recordings.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/converge-diverged-call-recordings.util.ts index 05eb727847..019fe609a7 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/converge-diverged-call-recordings.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/converge-diverged-call-recordings.util.ts @@ -35,6 +35,7 @@ type DivergedCallRecordingCandidate = { endedAt: string | undefined; externalBotId: string | undefined; externalRecordingId: string | undefined; + callRecorderFailureReason: string | undefined; transcript: unknown; audio: FilesFieldValue | undefined; video: FilesFieldValue | undefined; @@ -50,6 +51,7 @@ type DivergedCallRecordingNode = { endedAt?: string | null; externalBotId?: string | null; externalRecordingId?: string | null; + callRecorderFailureReason?: string | null; transcript?: unknown; audio?: FilesFieldValue | null; video?: FilesFieldValue | null; @@ -150,6 +152,7 @@ const fetchDivergedCallRecordingCandidates = async ( endedAt: true, externalBotId: true, externalRecordingId: true, + callRecorderFailureReason: true, transcript: true, audio: { fileId: true }, video: { fileId: true }, @@ -180,6 +183,9 @@ const fetchDivergedCallRecordingCandidates = async ( externalRecordingId: isNonEmptyString(node.externalRecordingId) ? node.externalRecordingId : undefined, + callRecorderFailureReason: isNonEmptyString(node.callRecorderFailureReason) + ? node.callRecorderFailureReason + : undefined, transcript: node.transcript ?? undefined, audio: node.audio ?? undefined, video: node.video ?? undefined, @@ -267,15 +273,18 @@ const convergeCallRecording = async ({ result.requestedTranscriptCallRecordingIds.push(candidate.id); } - Object.assign( - updateData, - await ingestCallRecordingMedia({ - callRecordingId: candidate.id, - externalRecordingId, - hasAudio: isNonEmptyArray(candidate.audio), - hasVideo: isNonEmptyArray(candidate.video), - }), - ); + const mediaIngestionUpdate = await ingestCallRecordingMedia({ + callRecordingId: candidate.id, + externalRecordingId, + hasAudio: isNonEmptyArray(candidate.audio), + hasVideo: isNonEmptyArray(candidate.video), + }); + + if (updateData.status === CallRecordingStatus.FAILED) { + delete mediaIngestionUpdate.callRecorderFailureReason; + } + + Object.assign(updateData, mediaIngestionUpdate); } const terminalArtifactGateFailureUpdate = 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 f92120473c..d6e332b910 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 @@ -30,6 +30,7 @@ type MatchedCallRecording = { startedAt?: string; endedAt?: string; externalRecordingId?: string; + callRecorderFailureReason?: string; transcript?: unknown; audio?: FilesFieldValue; video?: FilesFieldValue; @@ -196,13 +197,16 @@ const handleRecallStatusEvent = async ({ callRecordingStatus, }); - Object.assign( - updateData, - await buildMediaIngestionUpdate({ - callRecording, - externalRecordingId: externalRecordingIdResolution.externalRecordingId, - }), - ); + const mediaIngestionUpdate = await buildMediaIngestionUpdate({ + callRecording, + externalRecordingId: externalRecordingIdResolution.externalRecordingId, + }); + + if (updateData.status === CallRecordingStatus.FAILED) { + delete mediaIngestionUpdate.callRecorderFailureReason; + } + + Object.assign(updateData, mediaIngestionUpdate); const terminalArtifactGateFailureUpdate = buildTerminalArtifactGateFailureUpdate({ @@ -348,6 +352,7 @@ const findCallRecordingByFilter = async ( startedAt: true, endedAt: true, externalRecordingId: true, + callRecorderFailureReason: true, transcript: true, audio: { fileId: true }, video: { fileId: true }, @@ -368,6 +373,7 @@ const findCallRecordingByFilter = async ( startedAt: getString(node.startedAt), endedAt: getString(node.endedAt), externalRecordingId: getString(node.externalRecordingId), + callRecorderFailureReason: getString(node.callRecorderFailureReason), transcript: node.transcript ?? undefined, audio: node.audio ?? undefined, video: node.video ?? undefined, @@ -539,7 +545,12 @@ const buildMediaIngestionUpdate = async ({ }: { callRecording: MatchedCallRecording; externalRecordingId: string | undefined; -}): Promise> => { +}): Promise< + Pick< + CallRecordingUpdateFields, + 'audio' | 'video' | 'callRecorderFailureReason' + > +> => { const hasAudio = isNonEmptyArray(callRecording.audio); const hasVideo = isNonEmptyArray(callRecording.video); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/ingest-call-recording-media.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/ingest-call-recording-media.util.ts index 7b016fbd72..308fb6aade 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/ingest-call-recording-media.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/ingest-call-recording-media.util.ts @@ -1,18 +1,33 @@ -import { isUndefined } from '@sniptt/guards'; +import { isNull, isUndefined } from '@sniptt/guards'; import { MetadataApiClient } from 'twenty-client-sdk/metadata'; import { CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-audio-field-universal-identifier'; import { CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-video-field-universal-identifier'; +import { getMaxMediaFileSizeBytes } from 'src/logic-functions/constants/get-max-media-file-size-bytes'; +import { + AUDIO_FILE_TOO_LARGE_FAILURE_REASON, + VIDEO_FILE_TOO_LARGE_FAILURE_REASON, +} from 'src/logic-functions/constants/media-file-too-large-failure-reasons'; import { extractRecallMediaUrls } from 'src/logic-functions/recall-api/extract-recall-media-urls.util'; import { getRecallRecording } from 'src/logic-functions/recall-api/get-recall-recording.util'; import { type CallRecordingMediaFile } from 'src/logic-functions/types/call-recording-media-file.type'; import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; type CallRecordingMediaUpdateFields = Pick< CallRecordingUpdateFields, - 'audio' | 'video' + 'audio' | 'video' | 'callRecorderFailureReason' >; +type IngestMediaArtifactResult = + | { outcome: 'ingested'; files: CallRecordingMediaFile[] } + | { outcome: 'too-large' } + | { outcome: 'failed' }; + +type DownloadMediaFileResult = + | { outcome: 'downloaded'; buffer: Buffer; contentType: string } + | { outcome: 'too-large'; sizeBytes: number | undefined }; + const MEDIA_DOWNLOAD_TIMEOUT_MS = 120_000; export const ingestCallRecordingMedia = async ({ @@ -42,7 +57,10 @@ export const ingestCallRecordingMedia = async ({ const mediaUrls = extractRecallMediaUrls(recordingResult.recording); const metadataClient = new MetadataApiClient(); + // TODO: drop the size cap once core streams uploads without buffering whole files in memory. + const maxMediaFileSizeBytes = getMaxMediaFileSizeBytes(); const updateFields: CallRecordingMediaUpdateFields = {}; + const tooLargeFailureReasons: string[] = []; if (!hasVideo && !isUndefined(mediaUrls.videoUrl)) { const video = await ingestMediaArtifact({ @@ -52,10 +70,15 @@ export const ingestCallRecordingMedia = async ({ fileName: 'video.mp4', fieldMetadataUniversalIdentifier: CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER, + maxMediaFileSizeBytes, }); - if (!isUndefined(video)) { - updateFields.video = video; + if (video.outcome === 'ingested') { + updateFields.video = video.files; + } + + if (video.outcome === 'too-large') { + tooLargeFailureReasons.push(VIDEO_FILE_TOO_LARGE_FAILURE_REASON); } } @@ -67,11 +90,20 @@ export const ingestCallRecordingMedia = async ({ fileName: 'audio.mp3', fieldMetadataUniversalIdentifier: CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER, + maxMediaFileSizeBytes, }); - if (!isUndefined(audio)) { - updateFields.audio = audio; + if (audio.outcome === 'ingested') { + updateFields.audio = audio.files; } + + if (audio.outcome === 'too-large') { + tooLargeFailureReasons.push(AUDIO_FILE_TOO_LARGE_FAILURE_REASON); + } + } + + if (tooLargeFailureReasons.length > 0) { + updateFields.callRecorderFailureReason = tooLargeFailureReasons.join(','); } return updateFields; @@ -83,20 +115,33 @@ const ingestMediaArtifact = async ({ url, fileName, fieldMetadataUniversalIdentifier, + maxMediaFileSizeBytes, }: { callRecordingId: string; metadataClient: InstanceType; url: string; fileName: string; fieldMetadataUniversalIdentifier: string; -}): Promise => { + maxMediaFileSizeBytes: number; +}): Promise => { try { - const { buffer, contentType } = await downloadMediaFile({ + const downloadResult = await downloadMediaFile({ callRecordingId, fileName, url, + maxMediaFileSizeBytes, }); + if (downloadResult.outcome === 'too-large') { + console.warn( + `[call-recorder] media-ingestion phase=artifact-too-large callRecordingId=${callRecordingId} fileName=${fileName} sizeBytes=${downloadResult.sizeBytes ?? 'unknown'} maxMediaFileSizeBytes=${maxMediaFileSizeBytes}`, + ); + + return { outcome: 'too-large' }; + } + + const { buffer, contentType } = downloadResult; + console.log( `[call-recorder] media-ingestion phase=artifact-upload-start callRecordingId=${callRecordingId} fileName=${fileName} downloadedBytes=${buffer.byteLength} contentType=${contentType} ${formatMemoryUsageForLog()}`, ); @@ -108,13 +153,16 @@ const ingestMediaArtifact = async ({ fieldMetadataUniversalIdentifier, ); - return [{ fileId: uploadedFile.id, label: fileName }]; + return { + outcome: 'ingested', + files: [{ fileId: uploadedFile.id, label: fileName }], + }; } catch (error) { console.warn( `[call-recorder] failed to ingest ${fileName} for call recording ${callRecordingId}: ${error instanceof Error ? error.message : String(error)}`, ); - return undefined; + return { outcome: 'failed' }; } }; @@ -122,35 +170,102 @@ const downloadMediaFile = async ({ callRecordingId, fileName, url, + maxMediaFileSizeBytes, }: { callRecordingId: string; fileName: string; url: string; -}): Promise<{ buffer: Buffer; contentType: string }> => { + maxMediaFileSizeBytes: number; +}): Promise => { const response = await fetch(url, { signal: AbortSignal.timeout(MEDIA_DOWNLOAD_TIMEOUT_MS), }); const contentType = response.headers.get('content-type') ?? 'application/octet-stream'; - const contentLength = response.headers.get('content-length') ?? 'unknown'; + const contentLengthBytes = parseContentLengthBytes( + response.headers.get('content-length'), + ); console.log( - `[call-recorder] media-ingestion phase=artifact-download-response callRecordingId=${callRecordingId} fileName=${fileName} responseStatus=${response.status} contentLengthBytes=${contentLength} contentType=${contentType} ${formatMemoryUsageForLog()}`, + `[call-recorder] media-ingestion phase=artifact-download-response callRecordingId=${callRecordingId} fileName=${fileName} responseStatus=${response.status} contentLengthBytes=${contentLengthBytes ?? 'unknown'} contentType=${contentType} ${formatMemoryUsageForLog()}`, ); if (!response.ok) { throw new Error(`download failed with status ${response.status}`); } - const arrayBuffer = await response.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); + if ( + !isUndefined(contentLengthBytes) && + contentLengthBytes > maxMediaFileSizeBytes + ) { + await response.body?.cancel(); + + return { outcome: 'too-large', sizeBytes: contentLengthBytes }; + } + + if (isNull(response.body)) { + throw new Error('download returned no body'); + } + + return readBodyWithinSizeCap({ + body: response.body, + contentType, + maxMediaFileSizeBytes, + }); +}; + +const readBodyWithinSizeCap = async ({ + body, + contentType, + maxMediaFileSizeBytes, +}: { + body: ReadableStream; + contentType: string; + maxMediaFileSizeBytes: number; +}): Promise => { + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let downloadedBytes = 0; + + for (;;) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + downloadedBytes += value.byteLength; + + if (downloadedBytes > maxMediaFileSizeBytes) { + await reader.cancel(); + + return { outcome: 'too-large', sizeBytes: undefined }; + } + + chunks.push(value); + } return { - buffer, + outcome: 'downloaded', + buffer: Buffer.concat(chunks), contentType, }; }; +const parseContentLengthBytes = ( + headerValue: string | null, +): number | undefined => { + if (!isNonEmptyString(headerValue)) { + return undefined; + } + + const parsedBytes = Number(headerValue.trim()); + + return Number.isFinite(parsedBytes) && parsedBytes >= 0 + ? parsedBytes + : undefined; +}; + const formatMemoryUsageForLog = (): string => { const memoryUsage = process.memoryUsage(); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/persist-call-recording-progress.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/persist-call-recording-progress.util.ts index 22aa7a2916..17fab2679a 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/persist-call-recording-progress.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/persist-call-recording-progress.util.ts @@ -13,6 +13,7 @@ type PersistCallRecordingProgressCurrent = { transcript?: unknown; audio?: FilesFieldValue; video?: FilesFieldValue; + callRecorderFailureReason?: string | null; }; export const persistCallRecordingProgress = async ( @@ -42,7 +43,6 @@ export const persistCallRecordingProgress = async ( const nonStatusUpdate: CallRecordingUpdateFields = { ...updateData }; delete nonStatusUpdate.status; - delete nonStatusUpdate.callRecorderFailureReason; if (Object.keys(nonStatusUpdate).length > 0) { await updateCallRecording(client, { id, data: nonStatusUpdate });