diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/__tests__/call-recording-field-universal-identifiers.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/__tests__/call-recording-field-universal-identifiers.test.ts new file mode 100644 index 0000000000..78d6c1b799 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/__tests__/call-recording-field-universal-identifiers.test.ts @@ -0,0 +1,19 @@ +import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define'; +import { describe, expect, it } from 'vitest'; + +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'; + +// This test is nothing more than a sanity check to ensure that the universal identifiers for the call recording media fields are correct. +describe('call recording field universal identifiers', () => { + it('matches the standard CallRecording media field identifiers', () => { + expect(CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER).toBe( + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.fields.audio + .universalIdentifier, + ); + expect(CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER).toBe( + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.fields.video + .universalIdentifier, + ); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/call-recording-audio-field-universal-identifier.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/call-recording-audio-field-universal-identifier.ts new file mode 100644 index 0000000000..6d17dc45d9 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/call-recording-audio-field-universal-identifier.ts @@ -0,0 +1,2 @@ +export const CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER = + '2eafc2d0-8fec-430c-a939-65ca5fbc0f08'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/call-recording-video-field-universal-identifier.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/call-recording-video-field-universal-identifier.ts new file mode 100644 index 0000000000..ff4ebcf5fa --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/call-recording-video-field-universal-identifier.ts @@ -0,0 +1,2 @@ +export const CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER = + 'bb9523d3-457e-4f4b-8c79-27a77afb87da'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/default-role.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/default-role.ts index 5cb23410fb..ff071e51bf 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/default-role.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/default-role.ts @@ -1,5 +1,6 @@ import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, + SystemPermissionFlag, defineApplicationRole, } from 'twenty-sdk/define'; @@ -10,7 +11,7 @@ export default defineApplicationRole({ universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, label: `${APP_DISPLAY_NAME} default role`, description: - 'Reads calendar events to decide whether the meeting bot should attend a meeting; writes the resulting CallRecording records.', + 'Reads calendar events to decide whether the meeting bot should attend a meeting; writes the resulting CallRecording records, uploads recording media, and fills transcripts.', canReadAllObjectRecords: false, canUpdateAllObjectRecords: false, canSoftDeleteAllObjectRecords: false, @@ -38,4 +39,5 @@ export default defineApplicationRole({ }, ], fieldPermissions: [], + permissionFlagUniversalIdentifiers: [SystemPermissionFlag.UPLOAD_FILE], }); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/call-recording-micro-credits-per-hour.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/call-recording-micro-credits-per-hour.ts new file mode 100644 index 0000000000..8fa84cc76f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/call-recording-micro-credits-per-hour.ts @@ -0,0 +1 @@ +export const CALL_RECORDING_MICRO_CREDITS_PER_HOUR = 1_000_000; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/complete-call-recording-ingestion.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/complete-call-recording-ingestion.test.ts new file mode 100644 index 0000000000..09a12ce43b --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/complete-call-recording-ingestion.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { completeCallRecordingIngestion } from 'src/logic-functions/data/complete-call-recording-ingestion.util'; + +describe('completeCallRecordingIngestion', () => { + it('guards the flip with status != COMPLETED and returns true when the row is claimed', async () => { + let capturedArgs: { filter: unknown; data: unknown } | undefined; + const mutation = vi.fn(async (mutationArg: any) => { + capturedArgs = mutationArg.updateCallRecordings.__args; + + return { updateCallRecordings: [{ id: 'call-recording-1' }] }; + }); + + const claimed = await completeCallRecordingIngestion( + { mutation } as never, + { + id: 'call-recording-1', + }, + ); + + expect(claimed).toBe(true); + expect(mutation).toHaveBeenCalledTimes(1); + expect(capturedArgs?.filter).toEqual({ + id: { eq: 'call-recording-1' }, + status: { neq: 'COMPLETED' }, + }); + expect(capturedArgs?.data).toEqual({ status: 'COMPLETED' }); + }); + + it('returns false when the row was already COMPLETED, so the loser cannot charge', async () => { + const mutation = vi.fn(async () => ({ updateCallRecordings: [] })); + + const claimed = await completeCallRecordingIngestion( + { mutation } as never, + { + id: 'call-recording-1', + }, + ); + + expect(claimed).toBe(false); + }); + + it('returns false when the API omits the result list', async () => { + const mutation = vi.fn(async () => ({})); + + const claimed = await completeCallRecordingIngestion( + { mutation } as never, + { + id: 'call-recording-1', + }, + ); + + expect(claimed).toBe(false); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/complete-call-recording-ingestion.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/complete-call-recording-ingestion.util.ts new file mode 100644 index 0000000000..4a48a91e47 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/complete-call-recording-ingestion.util.ts @@ -0,0 +1,23 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; + +export const completeCallRecordingIngestion = async ( + client: CoreApiClient, + { id }: { id: string }, +): Promise => { + const result = await client.mutation({ + updateCallRecordings: { + __args: { + filter: { + id: { eq: id }, + status: { neq: CallRecordingStatus.COMPLETED }, + }, + data: { status: CallRecordingStatus.COMPLETED }, + }, + id: true, + }, + }); + + return (result.updateCallRecordings ?? []).length > 0; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/update-call-recording.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/update-call-recording.util.ts index 27da032bcf..bbe8bd0012 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/update-call-recording.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/update-call-recording.util.ts @@ -14,6 +14,9 @@ export type CallRecordingUpdateFields = Partial<{ // null clears the field on cancel/eject; the only field we ever write null to. externalBotId: string | null; externalRecordingId: string; + transcript: Record; + audio: { fileId: string; label: string }[]; + video: { fileId: string; label: string }[]; }>; export const updateCallRecording = async ( diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-call-recording-charge.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-call-recording-charge.test.ts new file mode 100644 index 0000000000..7abf925f9e --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-call-recording-charge.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { computeCallRecordingCharge } from 'src/logic-functions/domain/compute-call-recording-charge.util'; + +describe('computeCallRecordingCharge', () => { + it('charges one credit for a one-hour recording', () => { + expect( + computeCallRecordingCharge({ + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: '2026-06-10T10:00:00.000Z', + }), + ).toEqual({ + creditsUsedMicro: 1_000_000, + quantityMinutes: 60, + }); + }); + + it('prorates partial hours by duration', () => { + expect( + computeCallRecordingCharge({ + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: '2026-06-10T09:45:00.000Z', + }), + ).toEqual({ + creditsUsedMicro: 750_000, + quantityMinutes: 45, + }); + }); + + it('reports at least one minute for very short recordings', () => { + expect( + computeCallRecordingCharge({ + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: '2026-06-10T09:00:30.000Z', + }), + ).toEqual({ + creditsUsedMicro: 8_333, + quantityMinutes: 1, + }); + }); + + it('returns undefined when either timestamp is missing', () => { + expect( + computeCallRecordingCharge({ + startedAt: undefined, + endedAt: '2026-06-10T10:00:00.000Z', + }), + ).toBeUndefined(); + expect( + computeCallRecordingCharge({ + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: undefined, + }), + ).toBeUndefined(); + }); + + it('returns undefined for non-positive or unparseable durations', () => { + expect( + computeCallRecordingCharge({ + startedAt: '2026-06-10T10:00:00.000Z', + endedAt: '2026-06-10T09:00:00.000Z', + }), + ).toBeUndefined(); + expect( + computeCallRecordingCharge({ + startedAt: 'not-a-date', + endedAt: '2026-06-10T10:00:00.000Z', + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-ingestion-complete.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-ingestion-complete.test.ts new file mode 100644 index 0000000000..a7052f6fa7 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-ingestion-complete.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { isCallRecordingIngestionComplete } from 'src/logic-functions/domain/is-call-recording-ingestion-complete.util'; + +const AUDIO_VALUE = [{ fileId: 'file-audio-1', label: 'audio.mp3' }]; +const VIDEO_VALUE = [{ fileId: 'file-video-1', label: 'video.mp4' }]; +const TRANSCRIPT_CONTENT = [{ participant: { id: 1 }, words: [] }]; + +describe('isCallRecordingIngestionComplete', () => { + it('is complete when transcript content and both media files are present', () => { + expect( + isCallRecordingIngestionComplete({ + transcript: TRANSCRIPT_CONTENT, + audio: AUDIO_VALUE, + video: VIDEO_VALUE, + }), + ).toBe(true); + }); + + it('is incomplete while the transcript holds a marker', () => { + expect( + isCallRecordingIngestionComplete({ + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + }, + audio: AUDIO_VALUE, + video: VIDEO_VALUE, + }), + ).toBe(false); + }); + + it('is incomplete when the transcript is unset', () => { + expect( + isCallRecordingIngestionComplete({ + transcript: null, + audio: AUDIO_VALUE, + video: VIDEO_VALUE, + }), + ).toBe(false); + }); + + it('is incomplete while any media field is empty', () => { + expect( + isCallRecordingIngestionComplete({ + transcript: TRANSCRIPT_CONTENT, + audio: undefined, + video: VIDEO_VALUE, + }), + ).toBe(false); + expect( + isCallRecordingIngestionComplete({ + transcript: TRANSCRIPT_CONTENT, + audio: AUDIO_VALUE, + video: [], + }), + ).toBe(false); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/should-complete-call-recording-ingestion.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/should-complete-call-recording-ingestion.test.ts new file mode 100644 index 0000000000..15ea0752f6 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/should-complete-call-recording-ingestion.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; +import { shouldCompleteCallRecordingIngestion } from 'src/logic-functions/domain/should-complete-call-recording-ingestion.util'; + +const filledTranscript = [{ participant: { id: 1 }, words: [] }]; +const filledAudio = [{ fileId: 'file-audio-1', label: 'audio.mp3' }]; +const filledVideo = [{ fileId: 'file-video-1', label: 'video.mp4' }]; + +describe('shouldCompleteCallRecordingIngestion', () => { + it('requires complete artifacts and billable timestamps before completion', () => { + expect( + shouldCompleteCallRecordingIngestion({ + 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: {}, + }), + ).toBe(true); + + expect( + shouldCompleteCallRecordingIngestion({ + current: { + status: CallRecordingStatus.PROCESSING, + endedAt: '2026-06-10T10:00:00.000Z', + transcript: filledTranscript, + audio: filledAudio, + video: filledVideo, + }, + updateData: {}, + }), + ).toBe(false); + + expect( + shouldCompleteCallRecordingIngestion({ + current: { + status: CallRecordingStatus.PROCESSING, + transcript: filledTranscript, + audio: filledAudio, + video: filledVideo, + }, + updateData: { + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: '2026-06-10T10:00:00.000Z', + }, + }), + ).toBe(true); + + expect( + shouldCompleteCallRecordingIngestion({ + current: { + status: CallRecordingStatus.PROCESSING, + startedAt: '2026-06-10T10:00:00.000Z', + endedAt: '2026-06-10T09:00:00.000Z', + transcript: filledTranscript, + audio: filledAudio, + video: filledVideo, + }, + updateData: {}, + }), + ).toBe(false); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-failed-transcript-marker.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-failed-transcript-marker.util.ts new file mode 100644 index 0000000000..c7719537f6 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-failed-transcript-marker.util.ts @@ -0,0 +1,13 @@ +import { type TranscriptMarker } from 'src/logic-functions/types/transcript-marker.type'; + +export const buildFailedTranscriptMarker = ({ + recallTranscriptId, + subCode, +}: { + recallTranscriptId: string | null; + subCode: string | null; +}): TranscriptMarker => ({ + recallTranscriptId, + status: 'FAILED', + subCode, +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-pending-transcript-marker.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-pending-transcript-marker.util.ts new file mode 100644 index 0000000000..ae65c4b556 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-pending-transcript-marker.util.ts @@ -0,0 +1,13 @@ +import { type TranscriptMarker } from 'src/logic-functions/types/transcript-marker.type'; + +export const buildPendingTranscriptMarker = ({ + recallTranscriptId, + requestedAt, +}: { + recallTranscriptId: string; + requestedAt: string; +}): TranscriptMarker => ({ + recallTranscriptId, + status: 'PENDING', + requestedAt, +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-call-recording-charge.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-call-recording-charge.util.ts new file mode 100644 index 0000000000..8109218471 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-call-recording-charge.util.ts @@ -0,0 +1,41 @@ +import { isUndefined } from '@sniptt/guards'; + +import { CALL_RECORDING_MICRO_CREDITS_PER_HOUR } from 'src/logic-functions/constants/call-recording-micro-credits-per-hour'; + +const MILLISECONDS_PER_HOUR = 3_600_000; +const MILLISECONDS_PER_MINUTE = 60_000; + +export type CallRecordingCharge = { + creditsUsedMicro: number; + quantityMinutes: number; +}; + +export const computeCallRecordingCharge = ({ + startedAt, + endedAt, +}: { + startedAt: string | undefined; + endedAt: string | undefined; +}): CallRecordingCharge | undefined => { + if (isUndefined(startedAt) || isUndefined(endedAt)) { + return undefined; + } + + const durationMilliseconds = + new Date(endedAt).getTime() - new Date(startedAt).getTime(); + + if (!Number.isFinite(durationMilliseconds) || durationMilliseconds <= 0) { + return undefined; + } + + return { + creditsUsedMicro: Math.round( + (durationMilliseconds / MILLISECONDS_PER_HOUR) * + CALL_RECORDING_MICRO_CREDITS_PER_HOUR, + ), + quantityMinutes: Math.max( + 1, + Math.round(durationMilliseconds / MILLISECONDS_PER_MINUTE), + ), + }; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-ingestion-complete.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-ingestion-complete.util.ts new file mode 100644 index 0000000000..ec32b40758 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-ingestion-complete.util.ts @@ -0,0 +1,19 @@ +import { isNonEmptyArray, isNull, isUndefined } from '@sniptt/guards'; + +import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type'; +import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util'; + +export const isCallRecordingIngestionComplete = ({ + transcript, + audio, + video, +}: { + transcript: unknown; + audio: FilesFieldValue | undefined; + video: FilesFieldValue | undefined; +}): boolean => + !isNull(transcript) && + !isUndefined(transcript) && + isUndefined(parseTranscriptMarker(transcript)) && + isNonEmptyArray(audio) && + isNonEmptyArray(video); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-recall-recording-done-signal.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-recall-recording-done-signal.util.ts new file mode 100644 index 0000000000..53a4799526 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-recall-recording-done-signal.util.ts @@ -0,0 +1,7 @@ +export const isRecallRecordingDoneSignal = ({ + event, + statusCode, +}: { + event: string; + statusCode: string | undefined; +}): boolean => event === 'recording.done' || statusCode === 'done'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/parse-transcript-marker.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/parse-transcript-marker.util.ts new file mode 100644 index 0000000000..b2806f2a7b --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/parse-transcript-marker.util.ts @@ -0,0 +1,29 @@ +import { isString, isUndefined } from '@sniptt/guards'; + +import { type TranscriptMarker } from 'src/logic-functions/types/transcript-marker.type'; +import { asRecord } from 'src/logic-functions/utils/as-record.util'; + +export const parseTranscriptMarker = ( + transcript: unknown, +): TranscriptMarker | undefined => { + const candidate = asRecord(transcript); + + if (isUndefined(candidate)) { + return undefined; + } + + if (candidate.status !== 'PENDING' && candidate.status !== 'FAILED') { + return undefined; + } + + return { + recallTranscriptId: isString(candidate.recallTranscriptId) + ? candidate.recallTranscriptId + : null, + status: candidate.status, + ...(isString(candidate.requestedAt) + ? { requestedAt: candidate.requestedAt } + : {}), + ...(isString(candidate.subCode) ? { subCode: candidate.subCode } : {}), + }; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/should-complete-call-recording-ingestion.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/should-complete-call-recording-ingestion.util.ts new file mode 100644 index 0000000000..e2dfe4e218 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/should-complete-call-recording-ingestion.util.ts @@ -0,0 +1,30 @@ +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 { isCallRecordingIngestionComplete } from 'src/logic-functions/domain/is-call-recording-ingestion-complete.util'; +import { type CallRecordingUpdateFields } from 'src/logic-functions/data/update-call-recording.util'; + +export const shouldCompleteCallRecordingIngestion = ({ + current, + updateData, +}: { + current: { + status?: string; + startedAt?: string; + endedAt?: string; + transcript?: unknown; + audio?: FilesFieldValue; + video?: FilesFieldValue; + }; + updateData: CallRecordingUpdateFields; +}): boolean => + current.status !== CallRecordingStatus.COMPLETED && + computeCallRecordingCharge({ + startedAt: updateData.startedAt ?? current.startedAt, + endedAt: updateData.endedAt ?? current.endedAt, + }) !== undefined && + isCallRecordingIngestionComplete({ + transcript: updateData.transcript ?? current.transcript, + audio: updateData.audio ?? current.audio, + video: updateData.video ?? current.video, + }); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/charge-completed-call-recording.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/charge-completed-call-recording.test.ts new file mode 100644 index 0000000000..f1e9077ed8 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/charge-completed-call-recording.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { chargeCompletedCallRecording } from 'src/logic-functions/flows/charge-completed-call-recording.util'; + +const chargeCreditsMock = vi.hoisted(() => vi.fn()); + +vi.mock('twenty-sdk/billing', () => ({ + chargeCredits: chargeCreditsMock, +})); + +describe('chargeCompletedCallRecording', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + chargeCreditsMock.mockReset(); + chargeCreditsMock.mockResolvedValue(undefined); + }); + + it('charges prorated micro-credits with the recording duration in minutes', async () => { + await chargeCompletedCallRecording({ + callRecordingId: 'call-recording-1', + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: '2026-06-10T09:30:00.000Z', + }); + + expect(chargeCreditsMock).toHaveBeenCalledWith({ + creditsUsedMicro: 500_000, + quantity: 30, + operationType: 'CALL_RECORDING', + resourceContext: 'recall', + }); + }); + + it('skips and warns loudly when timestamps are unusable', async () => { + await chargeCompletedCallRecording({ + callRecordingId: 'call-recording-1', + startedAt: undefined, + endedAt: '2026-06-10T09:30:00.000Z', + }); + + expect(chargeCreditsMock).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('will not be billed'), + ); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/complete-and-charge-call-recording.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/complete-and-charge-call-recording.test.ts new file mode 100644 index 0000000000..42c48e63cf --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/complete-and-charge-call-recording.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const completeCallRecordingIngestionMock = vi.hoisted(() => vi.fn()); +const chargeCompletedCallRecordingMock = vi.hoisted(() => vi.fn()); + +vi.mock( + 'src/logic-functions/data/complete-call-recording-ingestion.util', + () => ({ + completeCallRecordingIngestion: completeCallRecordingIngestionMock, + }), +); + +vi.mock( + 'src/logic-functions/flows/charge-completed-call-recording.util', + () => ({ + chargeCompletedCallRecording: chargeCompletedCallRecordingMock, + }), +); + +import { completeAndChargeCallRecording } from 'src/logic-functions/flows/complete-and-charge-call-recording.util'; + +describe('completeAndChargeCallRecording', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('charges exactly once when this path wins the completion claim', async () => { + completeCallRecordingIngestionMock.mockResolvedValue(true); + + const claimed = await completeAndChargeCallRecording({} as never, { + id: 'call-recording-1', + startedAt: '2026-06-10T12:00:00.000Z', + endedAt: '2026-06-10T13:00:00.000Z', + }); + + expect(claimed).toBe(true); + expect(completeCallRecordingIngestionMock).toHaveBeenCalledWith( + {}, + { id: 'call-recording-1' }, + ); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledTimes(1); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + startedAt: '2026-06-10T12:00:00.000Z', + endedAt: '2026-06-10T13:00:00.000Z', + }); + }); + + it('does not charge when another path already completed the recording', async () => { + completeCallRecordingIngestionMock.mockResolvedValue(false); + + const claimed = await completeAndChargeCallRecording({} as never, { + id: 'call-recording-1', + startedAt: '2026-06-10T12:00:00.000Z', + endedAt: '2026-06-10T13:00:00.000Z', + }); + + expect(claimed).toBe(false); + expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts index 7cae8ae55a..ad1caf3f03 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts @@ -4,11 +4,32 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { convergeDivergedCallRecordings } from 'src/logic-functions/flows/converge-diverged-call-recordings.util'; const getRecallBotMock = vi.hoisted(() => vi.fn()); +const createAsyncRecallTranscriptMock = vi.hoisted(() => vi.fn()); +const ingestCallRecordingMediaMock = vi.hoisted(() => vi.fn()); +const chargeCompletedCallRecordingMock = vi.hoisted(() => vi.fn()); vi.mock('src/logic-functions/recall-api/get-recall-bot.util', () => ({ getRecallBot: getRecallBotMock, })); +vi.mock( + 'src/logic-functions/recall-api/create-async-recall-transcript.util', + () => ({ + createAsyncRecallTranscript: createAsyncRecallTranscriptMock, + }), +); + +vi.mock('src/logic-functions/flows/ingest-call-recording-media.util', () => ({ + ingestCallRecordingMedia: ingestCallRecordingMediaMock, +})); + +vi.mock( + 'src/logic-functions/flows/charge-completed-call-recording.util', + () => ({ + chargeCompletedCallRecording: chargeCompletedCallRecordingMock, + }), +); + const NOW = new Date('2026-06-10T12:00:00.000Z'); type CallRecordingNode = Record; @@ -18,33 +39,25 @@ class FakeCoreApiClient { constructor(private callRecordingNodes: CallRecordingNode[]) {} - async query(query: any): Promise { - const callRecordingFilter = query.callRecordings.__args.filter - .or[0] as Record; - const requestedRecordingRequestStatus = - callRecordingFilter.recordingRequestStatus.eq; - const requestedCallRecordingStatuses = callRecordingFilter.status.in; - const requiresExternalBotId = - callRecordingFilter.externalBotId.is === 'NOT_NULL'; - const matchingCallRecordingNodes = this.callRecordingNodes.filter( - (callRecordingNode) => - callRecordingNode.recordingRequestStatus === - requestedRecordingRequestStatus && - requestedCallRecordingStatuses.includes(callRecordingNode.status) && - (!requiresExternalBotId || - (callRecordingNode.externalBotId !== null && - callRecordingNode.externalBotId !== undefined)), - ); - + async query(_query: any): Promise { return { callRecordings: { pageInfo: { hasNextPage: false, endCursor: undefined }, - edges: matchingCallRecordingNodes.map((node) => ({ node })), + edges: this.callRecordingNodes.map((node) => ({ node })), }, }; } async mutation(mutation: any): Promise { + if (mutation.updateCallRecordings !== undefined) { + const { filter, data } = mutation.updateCallRecordings.__args; + const id = filter.id.eq; + + this.mutations.push({ id, data }); + + return { updateCallRecordings: [{ id }] }; + } + const { id, data } = mutation.updateCallRecording.__args; this.mutations.push({ id, data }); @@ -61,11 +74,13 @@ const buildStuckRecordingNode = ( ): CallRecordingNode => ({ id: 'call-recording-1', status: 'RECORDING', - recordingRequestStatus: 'REQUESTED', startedAt: null, endedAt: null, externalBotId: 'recall-bot-1', externalRecordingId: null, + transcript: null, + audio: null, + video: null, createdAt: '2026-06-09T12:00:00.000Z', calendarEvent: { endsAt: '2026-06-09T13:00:00.000Z' }, ...overrides, @@ -75,9 +90,18 @@ describe('convergeDivergedCallRecordings', () => { beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}); getRecallBotMock.mockReset(); + createAsyncRecallTranscriptMock.mockReset(); + createAsyncRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcriptId: 'recall-transcript-1', + }); + ingestCallRecordingMediaMock.mockReset(); + ingestCallRecordingMediaMock.mockResolvedValue({}); + chargeCompletedCallRecordingMock.mockReset(); + chargeCompletedCallRecordingMock.mockResolvedValue(undefined); }); - it('fills timestamps and recording id for a stuck RECORDING record from the Recall bot state', async () => { + it('heals a stuck RECORDING record from the Recall bot state', async () => { getRecallBotMock.mockResolvedValue({ ok: true, bot: { @@ -105,7 +129,24 @@ describe('convergeDivergedCallRecordings', () => { expect(getRecallBotMock).toHaveBeenCalledWith({ externalBotId: 'recall-bot-1', }); + expect(ingestCallRecordingMediaMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: false, + hasVideo: false, + }); expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: NOW.toISOString(), + }, + externalRecordingId: 'recall-recording-1', + }, + }, { id: 'call-recording-1', data: { @@ -113,9 +154,15 @@ describe('convergeDivergedCallRecordings', () => { startedAt: '2026-06-09T13:02:00.000Z', endedAt: '2026-06-09T14:00:00.000Z', externalRecordingId: 'recall-recording-1', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: NOW.toISOString(), + }, }, }, ]); + expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); expect(result).toEqual({ candidateCount: 1, updatedCallRecordingIds: ['call-recording-1'], @@ -124,18 +171,34 @@ describe('convergeDivergedCallRecordings', () => { }); }); - it('converges a SCHEDULED record when Recall moved forward but webhooks were missed', async () => { + it('completes and charges when convergence lands the last artifact', async () => { getRecallBotMock.mockResolvedValue({ ok: true, bot: { status_changes: [ - { code: 'joining_call', created_at: '2026-06-09T13:01:00.000Z' }, - { code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' }, + { 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' }], + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + }); const client = buildClient([ - buildStuckRecordingNode({ status: 'SCHEDULED' }), + 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({ @@ -143,19 +206,31 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(getRecallBotMock).toHaveBeenCalledWith({ - externalBotId: 'recall-bot-1', - }); + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); expect(client.mutations).toEqual([ { id: 'call-recording-1', data: { - status: 'RECORDING', - startedAt: '2026-06-09T13:02:00.000Z', + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], }, }, + { + id: 'call-recording-1', + data: { status: 'COMPLETED' }, + }, ]); - expect(result.updatedCallRecordingIds).toEqual(['call-recording-1']); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + }); + expect(result).toEqual({ + candidateCount: 1, + updatedCallRecordingIds: ['call-recording-1'], + markedFailedCallRecordingIds: [], + unconvergeableCallRecordingIds: [], + }); }); it('skips records whose meeting may still be live', async () => { @@ -198,7 +273,7 @@ describe('convergeDivergedCallRecordings', () => { expect(console.warn).toHaveBeenCalled(); }); - it('does not select COMPLETED records as convergence candidates', async () => { + it('does not downgrade a COMPLETED record when its bot 404s', async () => { getRecallBotMock.mockResolvedValue({ ok: false, status: 404, @@ -208,6 +283,7 @@ describe('convergeDivergedCallRecordings', () => { buildStuckRecordingNode({ status: 'COMPLETED', startedAt: '2026-06-09T13:02:00.000Z', + transcript: [{ participant: { id: 1 }, words: [] }], }), ]); @@ -217,9 +293,7 @@ describe('convergeDivergedCallRecordings', () => { }); expect(client.mutations).toEqual([]); - expect(result.candidateCount).toBe(0); - expect(result.unconvergeableCallRecordingIds).toEqual([]); - expect(getRecallBotMock).not.toHaveBeenCalled(); + expect(result.unconvergeableCallRecordingIds).toEqual(['call-recording-1']); }); it('logs candidates whose meeting ended before the lookback bound instead of converging them', async () => { @@ -299,6 +373,66 @@ describe('convergeDivergedCallRecordings', () => { ]); }); + it('requests a transcript for a COMPLETED candidate that has none', 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: 'COMPLETED', + startedAt: '2026-06-09T13:02:00.000Z', + externalRecordingId: 'recall-recording-1', + }), + ]); + + await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledTimes(1); + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-1', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: NOW.toISOString(), + }, + externalRecordingId: 'recall-recording-1', + }, + }, + { + id: 'call-recording-1', + data: { + endedAt: '2026-06-09T14:00:00.000Z', + externalRecordingId: 'recall-recording-1', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: NOW.toISOString(), + }, + }, + }, + ]); + }); + it('does not mutate a record the bot state agrees with', async () => { getRecallBotMock.mockResolvedValue({ ok: true, diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/download-transcript.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/download-transcript.test.ts new file mode 100644 index 0000000000..bc1b7f0ee3 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/download-transcript.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { downloadTranscript } from 'src/logic-functions/flows/download-transcript.util'; + +const retrieveRecallTranscriptMock = vi.hoisted(() => vi.fn()); + +vi.mock( + 'src/logic-functions/recall-api/retrieve-recall-transcript.util', + () => ({ + retrieveRecallTranscript: retrieveRecallTranscriptMock, + }), +); + +describe('downloadTranscript', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + retrieveRecallTranscriptMock.mockReset(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + }); + + it('downloads transcript content with a timeout', async () => { + const transcriptContent = [{ participant: { id: 1 }, words: [] }]; + + retrieveRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcript: { + downloadUrl: 'https://recall-transcripts.example.com/transcript-1', + statusCode: 'done', + statusSubCode: undefined, + }, + }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => transcriptContent, + }); + + const result = await downloadTranscript({ + transcriptId: 'recall-transcript-1', + }); + + expect(result).toEqual({ outcome: 'filled', content: transcriptContent }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://recall-transcripts.example.com/transcript-1', + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); + }); + + it('logs raw download failures but returns a generic error', async () => { + retrieveRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcript: { + downloadUrl: 'https://recall-transcripts.example.com/transcript-1', + statusCode: 'done', + statusSubCode: undefined, + }, + }); + fetchMock.mockRejectedValue(new Error('socket leaked detail')); + + await expect( + downloadTranscript({ transcriptId: 'recall-transcript-1' }), + ).resolves.toEqual({ + outcome: 'error', + errorMessage: 'transcript download failed', + }); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('socket leaked detail'), + ); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts index 8b9d7148d2..4f536dfe14 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts @@ -1,8 +1,43 @@ import { type CoreApiClient } from 'twenty-client-sdk/core'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { handleRecallWebhook } from 'src/logic-functions/flows/handle-recall-webhook.util'; +const getRecallBotMock = vi.hoisted(() => vi.fn()); +const createAsyncRecallTranscriptMock = vi.hoisted(() => vi.fn()); +const retrieveRecallTranscriptMock = vi.hoisted(() => vi.fn()); +const ingestCallRecordingMediaMock = vi.hoisted(() => vi.fn()); +const chargeCompletedCallRecordingMock = vi.hoisted(() => vi.fn()); + +vi.mock('src/logic-functions/recall-api/get-recall-bot.util', () => ({ + getRecallBot: getRecallBotMock, +})); + +vi.mock( + 'src/logic-functions/recall-api/create-async-recall-transcript.util', + () => ({ + createAsyncRecallTranscript: createAsyncRecallTranscriptMock, + }), +); + +vi.mock( + 'src/logic-functions/recall-api/retrieve-recall-transcript.util', + () => ({ + retrieveRecallTranscript: retrieveRecallTranscriptMock, + }), +); + +vi.mock('src/logic-functions/flows/ingest-call-recording-media.util', () => ({ + ingestCallRecordingMedia: ingestCallRecordingMediaMock, +})); + +vi.mock( + 'src/logic-functions/flows/charge-completed-call-recording.util', + () => ({ + chargeCompletedCallRecording: chargeCompletedCallRecordingMock, + }), +); + type CallRecordingNode = { id: string; status?: string | null; @@ -10,6 +45,9 @@ type CallRecordingNode = { externalRecordingId?: string | null; startedAt?: string | null; endedAt?: string | null; + transcript?: unknown; + audio?: unknown; + video?: unknown; }; class FakeCoreApiClient { @@ -37,6 +75,15 @@ class FakeCoreApiClient { } async mutation(mutation: any): Promise { + if (mutation.updateCallRecordings !== undefined) { + const { filter, data } = mutation.updateCallRecordings.__args; + const id = filter.id.eq; + + this.mutations.push({ id, data }); + + return { updateCallRecordings: [{ id }] }; + } + if (mutation.updateCallRecording !== undefined) { const { id, data } = mutation.updateCallRecording.__args; @@ -73,6 +120,32 @@ class FakeCoreApiClient { } describe('handleRecallWebhook', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + getRecallBotMock.mockReset(); + getRecallBotMock.mockResolvedValue({ + ok: false, + status: null, + errorMessage: 'bot fetch disabled in test', + }); + createAsyncRecallTranscriptMock.mockReset(); + createAsyncRecallTranscriptMock.mockResolvedValue({ + ok: false, + status: null, + errorMessage: 'transcript request disabled in test', + }); + retrieveRecallTranscriptMock.mockReset(); + retrieveRecallTranscriptMock.mockResolvedValue({ + ok: false, + status: null, + errorMessage: 'transcript retrieval disabled in test', + }); + ingestCallRecordingMediaMock.mockReset(); + ingestCallRecordingMediaMock.mockResolvedValue({}); + chargeCompletedCallRecordingMock.mockReset(); + chargeCompletedCallRecordingMock.mockResolvedValue(undefined); + }); + it('updates a call recording from bot metadata on status change events', async () => { const client = new FakeCoreApiClient([ { @@ -470,6 +543,11 @@ describe('handleRecallWebhook', () => { externalBotId: 'recall-bot-1', startedAt: '2026-01-01T13:02:00.000Z', endedAt: '2026-01-01T14:05:00.000Z', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: '2026-01-01T14:06:00.000Z', + }, }, ]); @@ -503,50 +581,6 @@ describe('handleRecallWebhook', () => { ]); }); - it('maps a fatal bot status to FAILED_UNKNOWN', 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: { - twentyCallRecordingId: 'call-recording-1', - }, - }, - status: { - code: 'fatal', - }, - }, - }, - }); - - expect(result).toEqual({ - status: 'updated', - event: 'bot.status_change', - callRecordingId: 'call-recording-1', - callRecordingStatus: 'FAILED_UNKNOWN', - }); - expect(client.mutations).toEqual([ - { - id: 'call-recording-1', - data: { - status: 'FAILED_UNKNOWN', - externalBotId: 'recall-bot-1', - }, - }, - ]); - }); - it('skips a late done event once the recording is COMPLETED', async () => { const client = new FakeCoreApiClient([ { @@ -666,4 +700,567 @@ describe('handleRecallWebhook', () => { }); expect(client.mutations).toEqual([]); }); + + it('requests a transcript once when the recording first completes', async () => { + createAsyncRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcriptId: 'recall-transcript-1', + }); + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + transcript: null, + }, + ]); + + await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'recording.done', + data: { + bot_id: 'recall-bot-1', + recording: { + id: 'recall-recording-1', + }, + }, + }, + }); + + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledTimes(1); + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-1', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: expect.any(String), + }, + externalRecordingId: 'recall-recording-1', + }, + }, + { + id: 'call-recording-1', + data: { + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: expect.any(String), + }, + }, + }, + ]); + }); + + it('does not re-request a transcript on a redelivered done event', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }, + ]); + + await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'recording.done', + data: { + bot_id: 'recall-bot-1', + recording: { + id: 'recall-recording-1', + }, + }, + }, + }); + + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + }, + }, + ]); + }); + + it('resolves the recording id from the bot when the payload and record lack one', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + recordings: [{ id: 'recall-recording-9' }], + }, + }); + createAsyncRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcriptId: 'recall-transcript-9', + }); + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + transcript: null, + }, + ]); + + await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'done', + }, + }, + }, + }); + + expect(getRecallBotMock).toHaveBeenCalledWith({ + externalBotId: 'recall-bot-1', + }); + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-9', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + transcript: { + recallTranscriptId: 'recall-transcript-9', + status: 'PENDING', + requestedAt: expect.any(String), + }, + externalRecordingId: 'recall-recording-9', + }, + }, + { + id: 'call-recording-1', + data: { + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-9', + transcript: { + recallTranscriptId: 'recall-transcript-9', + status: 'PENDING', + requestedAt: expect.any(String), + }, + }, + }, + ]); + }); + + it('ingests media on recording.done and completes once all artifacts are present', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { id: 'recall-bot-1' }, + }); + ingestCallRecordingMediaMock.mockResolvedValue({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + }); + 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: [] }], + }, + ]); + + await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'recording.done', + data: { + bot_id: 'recall-bot-1', + recording: { + id: 'recall-recording-1', + }, + }, + }, + }); + + expect(ingestCallRecordingMediaMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: false, + hasVideo: false, + }); + 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' }], + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + }, + }, + { + 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', + }); + }); + + it('stays PROCESSING on recording.done while artifacts are missing', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { id: 'recall-bot-1' }, + }); + ingestCallRecordingMediaMock.mockResolvedValue({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + }); + createAsyncRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcriptId: 'recall-transcript-1', + }); + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:05:00.000Z', + transcript: null, + }, + ]); + + await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'recording.done', + data: { + bot_id: 'recall-bot-1', + recording: { + id: 'recall-recording-1', + }, + }, + }, + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: expect.any(String), + }, + externalRecordingId: 'recall-recording-1', + }, + }, + { + id: 'call-recording-1', + data: { + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: expect.any(String), + }, + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + }, + }, + ]); + expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + }); + + it('completes and charges on transcript.done when media is already ingested', async () => { + const transcriptContent = [ + { + participant: { id: 1, name: 'Alice' }, + words: [{ text: 'hello', start_timestamp: { relative: 0.5 } }], + }, + ]; + + retrieveRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcript: { + downloadUrl: 'https://recall-transcripts.example.com/transcript-1', + statusCode: 'done', + statusSubCode: null, + }, + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => transcriptContent, + }), + ); + + 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: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'transcript.done', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + transcript: { + id: 'recall-transcript-1', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'transcript.done', + callRecordingId: 'call-recording-1', + transcriptOutcome: 'FILLED', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { transcript: transcriptContent }, + }, + { + 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', + }); + + vi.unstubAllGlobals(); + }); + + it('fills the transcript from the download URL on transcript.done', async () => { + const transcriptContent = [ + { + participant: { id: 1, name: 'Alice' }, + words: [{ text: 'hello', start_timestamp: { relative: 0.5 } }], + }, + ]; + + retrieveRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcript: { + downloadUrl: 'https://recall-transcripts.example.com/transcript-1', + statusCode: 'done', + statusSubCode: null, + }, + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => transcriptContent, + }), + ); + + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'COMPLETED', + externalBotId: 'recall-bot-1', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'transcript.done', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + transcript: { + id: 'recall-transcript-1', + }, + recording: { + id: 'recall-recording-1', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'transcript.done', + callRecordingId: 'call-recording-1', + transcriptOutcome: 'FILLED', + }); + expect(retrieveRecallTranscriptMock).toHaveBeenCalledWith({ + transcriptId: 'recall-transcript-1', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + transcript: transcriptContent, + externalRecordingId: 'recall-recording-1', + }, + }, + ]); + expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + + vi.unstubAllGlobals(); + }); + + it('writes a FAILED marker on transcript.failed', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'transcript.failed', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + transcript: { + id: 'recall-transcript-1', + }, + status: { + sub_code: 'transcription_failed', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'transcript.failed', + callRecordingId: 'call-recording-1', + transcriptOutcome: 'FAILED', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'FAILED', + subCode: 'transcription_failed', + }, + status: 'FAILED_UNKNOWN', + }, + }, + ]); + expect(console.warn).toHaveBeenCalled(); + }); + + it('does not clobber a downloaded transcript with a late transcript.failed', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'COMPLETED', + externalBotId: 'recall-bot-1', + transcript: [{ participant: { id: 1 }, words: [] }], + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'transcript.failed', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + transcript: { + id: 'recall-transcript-1', + }, + status: { + sub_code: 'transcription_failed', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'skipped', + event: 'transcript.failed', + reason: 'transcript already filled', + }); + expect(client.mutations).toEqual([]); + }); }); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/ingest-call-recording-media.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/ingest-call-recording-media.test.ts new file mode 100644 index 0000000000..3506fd2505 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/ingest-call-recording-media.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ingestCallRecordingMedia } from 'src/logic-functions/flows/ingest-call-recording-media.util'; + +const uploadFileMock = vi.hoisted(() => vi.fn()); +const getRecallRecordingMock = vi.hoisted(() => vi.fn()); + +vi.mock('twenty-client-sdk/metadata', () => ({ + MetadataApiClient: class { + uploadFile = uploadFileMock; + }, +})); + +vi.mock('src/logic-functions/recall-api/get-recall-recording.util', () => ({ + getRecallRecording: getRecallRecordingMock, +})); + +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' }, + }, +}; + +const buildFetchResponse = () => ({ + ok: true, + headers: { get: () => 'video/mp4' }, + arrayBuffer: async () => new ArrayBuffer(8), +}); + +describe('ingestCallRecordingMedia', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + uploadFileMock.mockReset(); + getRecallRecordingMock.mockReset(); + getRecallRecordingMock.mockResolvedValue({ + ok: true, + recording: RECORDING_WITH_MEDIA, + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildFetchResponse())); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('downloads and uploads every missing artifact', async () => { + uploadFileMock + .mockResolvedValueOnce({ id: 'file-video-1' }) + .mockResolvedValueOnce({ id: 'file-audio-1' }); + + const updateFields = await ingestCallRecordingMedia({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: false, + hasVideo: false, + }); + + expect(updateFields).toEqual({ + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + }); + expect(uploadFileMock).toHaveBeenCalledTimes(2); + }); + + it('skips artifacts already on the record', async () => { + uploadFileMock.mockResolvedValue({ id: 'file-audio-1' }); + + const updateFields = await ingestCallRecordingMedia({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: false, + hasVideo: true, + }); + + expect(updateFields).toEqual({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + }); + expect(uploadFileMock).toHaveBeenCalledTimes(1); + }); + + it('does not fetch the recording when both artifacts are present', async () => { + const updateFields = await ingestCallRecordingMedia({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: true, + hasVideo: true, + }); + + expect(updateFields).toEqual({}); + expect(getRecallRecordingMock).not.toHaveBeenCalled(); + expect(uploadFileMock).not.toHaveBeenCalled(); + }); + + it('omits an artifact and warns when its transfer fails', async () => { + uploadFileMock.mockRejectedValueOnce(new Error('upload exploded')); + uploadFileMock.mockResolvedValueOnce({ 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' }], + }); + expect(Object.keys(updateFields)).toEqual(['audio']); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('upload exploded'), + ); + }); + + it('returns nothing when the recording exposes no media urls', async () => { + getRecallRecordingMock.mockResolvedValue({ + ok: true, + recording: { id: 'recall-recording-1' }, + }); + + const updateFields = await ingestCallRecordingMedia({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: false, + hasVideo: false, + }); + + expect(updateFields).toEqual({}); + expect(uploadFileMock).not.toHaveBeenCalled(); + }); + + it('warns and returns nothing when the recording fetch fails', async () => { + getRecallRecordingMock.mockResolvedValue({ + ok: false, + status: 500, + errorMessage: 'recording boom', + }); + + const updateFields = await ingestCallRecordingMedia({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: false, + hasVideo: false, + }); + + expect(updateFields).toEqual({}); + expect(uploadFileMock).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('recording boom'), + ); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-pending-transcripts.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-pending-transcripts.test.ts new file mode 100644 index 0000000000..b2fa642417 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-pending-transcripts.test.ts @@ -0,0 +1,342 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { reconcilePendingTranscripts } from 'src/logic-functions/flows/reconcile-pending-transcripts.util'; + +const retrieveRecallTranscriptMock = vi.hoisted(() => vi.fn()); +const chargeCompletedCallRecordingMock = vi.hoisted(() => vi.fn()); + +vi.mock( + 'src/logic-functions/recall-api/retrieve-recall-transcript.util', + () => ({ + retrieveRecallTranscript: retrieveRecallTranscriptMock, + }), +); + +vi.mock( + 'src/logic-functions/flows/charge-completed-call-recording.util', + () => ({ + chargeCompletedCallRecording: chargeCompletedCallRecordingMock, + }), +); + +const NOW = new Date('2026-06-10T12:00:00.000Z'); +const STALE_REQUESTED_AT = '2026-06-10T10:00:00.000Z'; + +type CallRecordingNode = { + id: string; + status?: string | null; + startedAt?: string | null; + endedAt?: string | null; + transcript?: unknown; + audio?: unknown; + video?: unknown; +}; + +class FakeCoreApiClient { + mutations: Array<{ id: string; data: Record }> = []; + + constructor(private callRecordingNodes: CallRecordingNode[]) {} + + async query(_query: any): Promise { + return { + callRecordings: { + pageInfo: { hasNextPage: false, endCursor: undefined }, + edges: this.callRecordingNodes.map((node) => ({ node })), + }, + }; + } + + async mutation(mutation: any): Promise { + if (mutation.updateCallRecordings !== undefined) { + const { filter, data } = mutation.updateCallRecordings.__args; + const id = filter.id.eq; + + this.mutations.push({ id, data }); + + return { updateCallRecordings: [{ id }] }; + } + + const { id, data } = mutation.updateCallRecording.__args; + + this.mutations.push({ id, data }); + + return { updateCallRecording: { id } }; + } +} + +describe('reconcilePendingTranscripts', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + retrieveRecallTranscriptMock.mockReset(); + chargeCompletedCallRecordingMock.mockReset(); + chargeCompletedCallRecordingMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('fills a stale pending marker from the downloaded transcript', async () => { + const transcriptContent = [{ participant: { id: 1 }, words: [] }]; + + retrieveRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcript: { + downloadUrl: 'https://recall-transcripts.example.com/transcript-1', + statusCode: 'done', + statusSubCode: undefined, + }, + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => transcriptContent, + }), + ); + + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: STALE_REQUESTED_AT, + }, + }, + ]); + + const result = await reconcilePendingTranscripts({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(retrieveRecallTranscriptMock).toHaveBeenCalledWith({ + transcriptId: 'recall-transcript-1', + }); + expect(client.mutations).toEqual([ + { id: 'call-recording-1', data: { transcript: transcriptContent } }, + ]); + expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + pendingMarkerCount: 1, + filledCallRecordingIds: ['call-recording-1'], + failedCallRecordingIds: [], + }); + }); + + it('completes and charges when the late transcript is the last artifact', async () => { + const transcriptContent = [{ participant: { id: 1 }, words: [] }]; + + retrieveRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcript: { + downloadUrl: 'https://recall-transcripts.example.com/transcript-1', + statusCode: 'done', + statusSubCode: undefined, + }, + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => transcriptContent, + }), + ); + + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: '2026-06-10T09:45:00.000Z', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: STALE_REQUESTED_AT, + }, + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + }, + ]); + + const result = await reconcilePendingTranscripts({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { transcript: transcriptContent }, + }, + { + id: 'call-recording-1', + data: { status: 'COMPLETED' }, + }, + ]); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: '2026-06-10T09:45:00.000Z', + }); + expect(result.filledCallRecordingIds).toEqual(['call-recording-1']); + }); + + it('leaves recently requested pending markers alone', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: '2026-06-10T11:50:00.000Z', + }, + }, + ]); + + const result = await reconcilePendingTranscripts({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(retrieveRecallTranscriptMock).not.toHaveBeenCalled(); + expect(client.mutations).toEqual([]); + expect(result.pendingMarkerCount).toBe(1); + }); + + it('fails a stale pending marker whose transcript errored at Recall', async () => { + retrieveRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcript: { + downloadUrl: undefined, + statusCode: 'error', + statusSubCode: 'audio_missing', + }, + }); + + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: STALE_REQUESTED_AT, + }, + }, + ]); + + const result = await reconcilePendingTranscripts({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'FAILED', + subCode: 'audio_missing', + }, + status: 'FAILED_UNKNOWN', + }, + }, + ]); + expect(result.failedCallRecordingIds).toEqual(['call-recording-1']); + }); + + it('keeps a still-processing stale marker pending', async () => { + retrieveRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcript: { + downloadUrl: undefined, + statusCode: 'processing', + statusSubCode: undefined, + }, + }); + + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: STALE_REQUESTED_AT, + }, + }, + ]); + + const result = await reconcilePendingTranscripts({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(client.mutations).toEqual([]); + expect(result.filledCallRecordingIds).toEqual([]); + expect(result.failedCallRecordingIds).toEqual([]); + }); + + it('fails a stale pending marker without a Recall transcript id', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + transcript: { + recallTranscriptId: null, + status: 'PENDING', + requestedAt: STALE_REQUESTED_AT, + }, + }, + ]); + + const result = await reconcilePendingTranscripts({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(retrieveRecallTranscriptMock).not.toHaveBeenCalled(); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + transcript: { + recallTranscriptId: null, + status: 'FAILED', + subCode: 'missing_transcript_id', + }, + status: 'FAILED_UNKNOWN', + }, + }, + ]); + expect(result.failedCallRecordingIds).toEqual(['call-recording-1']); + }); + + it('ignores records holding real transcript content or FAILED markers', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + transcript: [{ participant: { id: 1 }, words: [] }], + }, + { + id: 'call-recording-2', + transcript: { + recallTranscriptId: 'recall-transcript-2', + status: 'FAILED', + subCode: 'transcription_failed', + }, + }, + ]); + + const result = await reconcilePendingTranscripts({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(retrieveRecallTranscriptMock).not.toHaveBeenCalled(); + expect(result.pendingMarkerCount).toBe(0); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/charge-completed-call-recording.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/charge-completed-call-recording.util.ts new file mode 100644 index 0000000000..dd1a02be0f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/charge-completed-call-recording.util.ts @@ -0,0 +1,31 @@ +import { isUndefined } from '@sniptt/guards'; +import { chargeCredits } from 'twenty-sdk/billing'; + +import { computeCallRecordingCharge } from 'src/logic-functions/domain/compute-call-recording-charge.util'; + +export const chargeCompletedCallRecording = async ({ + callRecordingId, + startedAt, + endedAt, +}: { + callRecordingId: string; + startedAt: string | undefined; + endedAt: string | undefined; +}): Promise => { + const charge = computeCallRecordingCharge({ startedAt, endedAt }); + + if (isUndefined(charge)) { + console.warn( + `[twenty-meeting-bot] call recording ${callRecordingId} completed without usable recording timestamps; it will not be billed`, + ); + + return; + } + + await chargeCredits({ + creditsUsedMicro: charge.creditsUsedMicro, + quantity: charge.quantityMinutes, + operationType: 'CALL_RECORDING', + resourceContext: 'recall', + }); +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/complete-and-charge-call-recording.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/complete-and-charge-call-recording.util.ts new file mode 100644 index 0000000000..ebe3a7c6a1 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/complete-and-charge-call-recording.util.ts @@ -0,0 +1,29 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { completeCallRecordingIngestion } from 'src/logic-functions/data/complete-call-recording-ingestion.util'; +import { chargeCompletedCallRecording } from 'src/logic-functions/flows/charge-completed-call-recording.util'; + +export const completeAndChargeCallRecording = async ( + client: CoreApiClient, + { + id, + startedAt, + endedAt, + }: { + id: string; + startedAt: string | undefined; + endedAt: string | undefined; + }, +): Promise => { + const claimed = await completeCallRecordingIngestion(client, { id }); + + if (claimed) { + await chargeCompletedCallRecording({ + callRecordingId: id, + startedAt, + endedAt, + }); + } + + return claimed; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/converge-diverged-call-recordings.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/converge-diverged-call-recordings.util.ts index f78edc4a0b..e89f36a30e 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/converge-diverged-call-recordings.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/converge-diverged-call-recordings.util.ts @@ -1,9 +1,10 @@ -import { isUndefined } from '@sniptt/guards'; +import { isNonEmptyArray, isNull, isUndefined } from '@sniptt/guards'; import { CoreApiClient } from 'twenty-client-sdk/core'; import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status'; import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size'; +import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type'; import { extractRecallBotConvergence, type RecallBotConvergence, @@ -13,8 +14,12 @@ import { type ConnectionPage, } from 'src/logic-functions/data/fetch-all-nodes.util'; import { getRecallBot } from 'src/logic-functions/recall-api/get-recall-bot.util'; +import { ingestCallRecordingMedia } from 'src/logic-functions/flows/ingest-call-recording-media.util'; import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util'; import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; +import { persistCallRecordingProgress } from 'src/logic-functions/flows/persist-call-recording-progress.util'; +import { requestTranscript } from 'src/logic-functions/flows/request-transcript.util'; +import { shouldCompleteCallRecordingIngestion } from 'src/logic-functions/domain/should-complete-call-recording-ingestion.util'; import { updateCallRecording, type CallRecordingUpdateFields, @@ -37,6 +42,9 @@ type DivergedCallRecordingCandidate = { endedAt: string | undefined; externalBotId: string | undefined; externalRecordingId: string | undefined; + transcript: unknown; + audio: FilesFieldValue | undefined; + video: FilesFieldValue | undefined; createdAt: string | undefined; calendarEventEndsAt: string | undefined; }; @@ -48,6 +56,9 @@ type DivergedCallRecordingNode = { endedAt?: string | null; externalBotId?: string | null; externalRecordingId?: string | null; + transcript?: unknown; + audio?: FilesFieldValue | null; + video?: FilesFieldValue | null; createdAt?: string | null; calendarEvent?: { endsAt?: string | null } | null; }; @@ -107,6 +118,7 @@ export const convergeDivergedCallRecordings = async ({ client, candidate, externalBotId: candidate.externalBotId, + now, result, }); } @@ -125,6 +137,10 @@ const fetchDivergedCallRecordingCandidates = async ( status: { in: NON_TERMINAL_CALL_RECORDING_STATUSES }, externalBotId: { is: 'NOT_NULL' }, }, + { + status: { eq: CallRecordingStatus.COMPLETED }, + or: [{ startedAt: { is: 'NULL' } }, { endedAt: { is: 'NULL' } }], + }, ], }; const candidateNodes = await fetchAllNodes( @@ -148,6 +164,9 @@ const fetchDivergedCallRecordingCandidates = async ( endedAt: true, externalBotId: true, externalRecordingId: true, + transcript: true, + audio: { fileId: true }, + video: { fileId: true }, createdAt: true, calendarEvent: { endsAt: true, @@ -157,7 +176,7 @@ const fetchDivergedCallRecordingCandidates = async ( }, }); - return queryResult.callRecordings as + return (queryResult.callRecordings ?? undefined) as | ConnectionPage | undefined; }, @@ -174,6 +193,9 @@ const fetchDivergedCallRecordingCandidates = async ( externalRecordingId: isNonEmptyString(node.externalRecordingId) ? node.externalRecordingId : undefined, + transcript: node.transcript ?? undefined, + audio: node.audio ?? undefined, + video: node.video ?? undefined, createdAt: node.createdAt ?? undefined, calendarEventEndsAt: node.calendarEvent?.endsAt ?? undefined, })); @@ -198,6 +220,7 @@ const isPossiblyStillLive = ( candidate: DivergedCallRecordingCandidate, liveMeetingCutoff: Date, ): boolean => + candidate.status !== CallRecordingStatus.COMPLETED && !isUndefined(candidate.calendarEventEndsAt) && new Date(candidate.calendarEventEndsAt).getTime() > liveMeetingCutoff.getTime(); @@ -206,11 +229,13 @@ const convergeCallRecording = async ({ client, candidate, externalBotId, + now, result, }: { client: CoreApiClient; candidate: DivergedCallRecordingCandidate; externalBotId: string; + now: Date; result: ConvergeDivergedCallRecordingsResult; }): Promise => { const botResult = await getRecallBot({ externalBotId }); @@ -237,13 +262,50 @@ const convergeCallRecording = async ({ const convergence = extractRecallBotConvergence(botResult.bot); const updateData = buildConvergenceFieldUpdates({ candidate, convergence }); - if (Object.keys(updateData).length === 0) { + const externalRecordingId = + candidate.externalRecordingId ?? convergence.externalRecordingId; + + if (convergence.isRecallRecordingDone && !isUndefined(externalRecordingId)) { + if (isUndefined(candidate.transcript)) { + const transcriptMarker = await requestTranscript({ + externalRecordingId, + requestedAt: now.toISOString(), + }); + + if (!isNull(transcriptMarker)) { + updateData.transcript = transcriptMarker; + updateData.externalRecordingId = externalRecordingId; + await updateCallRecording(client, { + id: candidate.id, + data: { transcript: transcriptMarker, externalRecordingId }, + }); + } + } + + Object.assign( + updateData, + await ingestCallRecordingMedia({ + callRecordingId: candidate.id, + externalRecordingId, + hasAudio: isNonEmptyArray(candidate.audio), + hasVideo: isNonEmptyArray(candidate.video), + }), + ); + } + + const completesIngestion = shouldCompleteCallRecordingIngestion({ + current: candidate, + updateData, + }); + + if (Object.keys(updateData).length === 0 && !completesIngestion) { return; } - await updateCallRecording(client, { + await persistCallRecordingProgress(client, { id: candidate.id, - data: updateData, + current: candidate, + updateData, }); result.updatedCallRecordingIds.push(candidate.id); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/download-transcript.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/download-transcript.util.ts new file mode 100644 index 0000000000..f4a69ea813 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/download-transcript.util.ts @@ -0,0 +1,67 @@ +import { isUndefined } from '@sniptt/guards'; + +import { retrieveRecallTranscript } from 'src/logic-functions/recall-api/retrieve-recall-transcript.util'; + +const TRANSCRIPT_DOWNLOAD_TIMEOUT_MS = 20_000; + +export type DownloadTranscriptResult = + | { outcome: 'filled'; content: unknown } + | { outcome: 'failed'; subCode: string | null } + | { outcome: 'pending' } + | { outcome: 'error'; errorMessage: string }; + +export const downloadTranscript = async ({ + transcriptId, +}: { + transcriptId: string; +}): Promise => { + const retrieveResult = await retrieveRecallTranscript({ transcriptId }); + + if (!retrieveResult.ok) { + return { outcome: 'error', errorMessage: retrieveResult.errorMessage }; + } + + const { downloadUrl, statusCode, statusSubCode } = retrieveResult.transcript; + + if (!isUndefined(downloadUrl)) { + return downloadTranscriptContent(downloadUrl); + } + + if (statusCode === 'error' || statusCode === 'failed') { + return { outcome: 'failed', subCode: statusSubCode ?? null }; + } + + return { outcome: 'pending' }; +}; + +const downloadTranscriptContent = async ( + downloadUrl: string, +): Promise => { + try { + const response = await fetch(downloadUrl, { + signal: AbortSignal.timeout(TRANSCRIPT_DOWNLOAD_TIMEOUT_MS), + }); + + if (!response.ok) { + console.warn( + `[twenty-meeting-bot] transcript download responded with HTTP ${response.status}`, + ); + + return { + outcome: 'error', + errorMessage: 'transcript download failed', + }; + } + + return { outcome: 'filled', content: await response.json() }; + } catch (error) { + console.warn( + `[twenty-meeting-bot] transcript download failed: ${error instanceof Error ? error.message : String(error)}`, + ); + + return { + outcome: 'error', + errorMessage: 'transcript download failed', + }; + } +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts index 99992f9cb8..85ea0751c8 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts @@ -1,20 +1,40 @@ -import { isUndefined } from '@sniptt/guards'; +import { isNonEmptyArray, isNull, isUndefined } from '@sniptt/guards'; import { CoreApiClient } from 'twenty-client-sdk/core'; import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; -import { findCallRecordingsByFilter } from 'src/logic-functions/data/find-call-recordings-by-filter.util'; -import { - updateCallRecording, - type CallRecordingUpdateFields, -} from 'src/logic-functions/data/update-call-recording.util'; +import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type'; +import { buildFailedTranscriptMarker } from 'src/logic-functions/domain/build-failed-transcript-marker.util'; +import { downloadTranscript } from 'src/logic-functions/flows/download-transcript.util'; +import { extractRecallBotConvergence } from 'src/logic-functions/recall-api/extract-recall-bot-convergence.util'; +import { getRecallBot } from 'src/logic-functions/recall-api/get-recall-bot.util'; +import { getString } from 'src/logic-functions/utils/get-string.util'; +import { ingestCallRecordingMedia } from 'src/logic-functions/flows/ingest-call-recording-media.util'; import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util'; +import { isRecallRecordingDoneSignal } from 'src/logic-functions/domain/is-recall-recording-done-signal.util'; import { mapRecallStatusCodeToCallRecordingStatus } from 'src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util'; import { parseRecallWebhookEvent, type RecallWebhookBody, type RecallWebhookEvent, } from 'src/logic-functions/recall-api/parse-recall-webhook-event.util'; -import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type'; +import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util'; +import { persistCallRecordingProgress } from 'src/logic-functions/flows/persist-call-recording-progress.util'; +import { requestTranscript } from 'src/logic-functions/flows/request-transcript.util'; +import { + updateCallRecording, + type CallRecordingUpdateFields, +} from 'src/logic-functions/data/update-call-recording.util'; + +type MatchedCallRecording = { + id: string; + status?: string; + startedAt?: string; + endedAt?: string; + externalRecordingId?: string; + transcript?: unknown; + audio?: FilesFieldValue; + video?: FilesFieldValue; +}; type RecallWebhookHandlerResult = | { @@ -23,6 +43,12 @@ type RecallWebhookHandlerResult = event: string; callRecordingStatus: string; } + | { + status: 'updated'; + callRecordingId: string; + event: string; + transcriptOutcome: 'FILLED' | 'FAILED'; + } | { status: 'skipped'; event: string | null; @@ -46,6 +72,22 @@ export const handleRecallWebhook = async ({ }; } + const { event } = webhookEvent; + + if (event === 'transcript.done' || event === 'transcript.failed') { + return handleRecallTranscriptEvent({ client, webhookEvent, event }); + } + + return handleRecallStatusEvent({ client, webhookEvent }); +}; + +const handleRecallStatusEvent = async ({ + client, + webhookEvent, +}: { + client: CoreApiClient; + webhookEvent: RecallWebhookEvent; +}): Promise => { const { event, statusCode } = webhookEvent; const callRecordingStatus = mapRecallEventToCallRecordingStatus({ event, @@ -95,16 +137,41 @@ export const handleRecallWebhook = async ({ ...buildRecordingTimestampsUpdate({ webhookEvent, callRecording }), }; - await updateCallRecording(client, { + if (isRecallRecordingDoneSignal({ event, statusCode })) { + if (isTranscriptUnset(callRecording)) { + const transcriptRequestUpdate = await buildTranscriptRequestUpdate({ + callRecording, + webhookEvent, + }); + + if (Object.keys(transcriptRequestUpdate).length > 0) { + await updateCallRecording(client, { + id: callRecording.id, + data: transcriptRequestUpdate, + }); + Object.assign(updateData, transcriptRequestUpdate); + } + } + + Object.assign( + updateData, + await buildMediaIngestionUpdate({ callRecording, webhookEvent }), + ); + } + + const { completesIngestion } = await persistCallRecordingProgress(client, { id: callRecording.id, - data: updateData, + current: callRecording, + updateData, }); return { status: 'updated', event, callRecordingId: callRecording.id, - callRecordingStatus, + callRecordingStatus: completesIngestion + ? CallRecordingStatus.COMPLETED + : (updateData.status ?? callRecordingStatus), }; }; @@ -114,24 +181,63 @@ const findMatchingCallRecording = async ({ }: { client: CoreApiClient; webhookEvent: RecallWebhookEvent; -}): Promise => { +}): Promise => { if (!isUndefined(webhookEvent.callRecordingIdFromMetadata)) { - const [callRecording] = await findCallRecordingsByFilter(client, { + return findCallRecordingByFilter(client, { id: { eq: webhookEvent.callRecordingIdFromMetadata }, }); - - return callRecording; } if (isUndefined(webhookEvent.externalBotId)) { return undefined; } - const [callRecording] = await findCallRecordingsByFilter(client, { + return findCallRecordingByFilter(client, { externalBotId: { eq: webhookEvent.externalBotId }, }); +}; - return callRecording; +const findCallRecordingByFilter = async ( + client: CoreApiClient, + filter: Record, +): Promise => { + const queryResult = await client.query({ + callRecordings: { + __args: { + filter, + first: 1, + }, + edges: { + node: { + id: true, + status: true, + startedAt: true, + endedAt: true, + externalRecordingId: true, + transcript: true, + audio: { fileId: true }, + video: { fileId: true }, + }, + }, + }, + }); + + const node = queryResult.callRecordings?.edges?.[0]?.node; + + if (isUndefined(node) || isNull(node)) { + return undefined; + } + + return { + id: node.id, + status: getString(node.status), + startedAt: getString(node.startedAt), + endedAt: getString(node.endedAt), + externalRecordingId: getString(node.externalRecordingId), + transcript: node.transcript ?? undefined, + audio: node.audio ?? undefined, + video: node.video ?? undefined, + }; }; const mapRecallEventToCallRecordingStatus = ({ @@ -152,13 +258,12 @@ const mapRecallEventToCallRecordingStatus = ({ return mapRecallStatusCodeToCallRecordingStatus(statusCode); }; -// Never overwrite an already-set actual time; redeliveries must stay idempotent. const buildRecordingTimestampsUpdate = ({ webhookEvent, callRecording, }: { webhookEvent: RecallWebhookEvent; - callRecording: CallRecordingRecord; + callRecording: MatchedCallRecording; }): { startedAt?: string; endedAt?: string } => { const { event, statusCode, statusTimestamp } = webhookEvent; @@ -191,3 +296,252 @@ const buildExternalRecordingIdUpdate = ( isUndefined(webhookEvent.externalRecordingId) ? {} : { externalRecordingId: webhookEvent.externalRecordingId }; + +const isTranscriptUnset = (callRecording: MatchedCallRecording): boolean => + isUndefined(callRecording.transcript); + +const buildMediaIngestionUpdate = async ({ + callRecording, + webhookEvent, +}: { + callRecording: MatchedCallRecording; + webhookEvent: RecallWebhookEvent; +}): Promise> => { + const hasAudio = isNonEmptyArray(callRecording.audio); + const hasVideo = isNonEmptyArray(callRecording.video); + + if (hasAudio && hasVideo) { + return {}; + } + + const externalRecordingId = await resolveExternalRecordingId({ + callRecording, + webhookEvent, + }); + + if (isUndefined(externalRecordingId)) { + console.warn( + `[twenty-meeting-bot] cannot ingest media for call recording ${callRecording.id}: no Recall recording id available`, + ); + + return {}; + } + + return ingestCallRecordingMedia({ + callRecordingId: callRecording.id, + externalRecordingId, + hasAudio, + hasVideo, + }); +}; + +const buildTranscriptRequestUpdate = async ({ + callRecording, + webhookEvent, +}: { + callRecording: MatchedCallRecording; + webhookEvent: RecallWebhookEvent; +}): Promise => { + const externalRecordingId = await resolveExternalRecordingId({ + callRecording, + webhookEvent, + }); + + if (isUndefined(externalRecordingId)) { + console.warn( + `[twenty-meeting-bot] cannot request transcript for call recording ${callRecording.id}: no Recall recording id available`, + ); + + return {}; + } + + const transcriptMarker = await requestTranscript({ + externalRecordingId, + requestedAt: new Date().toISOString(), + }); + + if (isNull(transcriptMarker)) { + return {}; + } + + return { + transcript: transcriptMarker, + externalRecordingId, + }; +}; + +const resolveExternalRecordingId = async ({ + callRecording, + webhookEvent, +}: { + callRecording: MatchedCallRecording; + webhookEvent: RecallWebhookEvent; +}): Promise => + webhookEvent.externalRecordingId ?? + callRecording.externalRecordingId ?? + (isUndefined(webhookEvent.externalBotId) + ? undefined + : await fetchExternalRecordingIdFromRecallBot(webhookEvent.externalBotId)); + +const fetchExternalRecordingIdFromRecallBot = async ( + externalBotId: string, +): Promise => { + const botResult = await getRecallBot({ externalBotId }); + + if (!botResult.ok) { + console.warn( + `[twenty-meeting-bot] failed to fetch Recall bot ${externalBotId} while resolving a recording id: ${botResult.errorMessage}`, + ); + + return undefined; + } + + return extractRecallBotConvergence(botResult.bot).externalRecordingId; +}; + +const handleRecallTranscriptEvent = async ({ + client, + webhookEvent, + event, +}: { + client: CoreApiClient; + webhookEvent: RecallWebhookEvent; + event: 'transcript.done' | 'transcript.failed'; +}): Promise => { + const callRecording = await findMatchingCallRecording({ + client, + webhookEvent, + }); + + if (isUndefined(callRecording)) { + return { + status: 'skipped', + event, + reason: 'no matching call recording', + }; + } + + const { transcriptId } = webhookEvent; + + if (event === 'transcript.failed') { + return applyTranscriptFailure({ + client, + callRecording, + event, + transcriptId, + subCode: webhookEvent.transcriptFailureSubCode ?? null, + }); + } + + if (isUndefined(transcriptId)) { + return { + status: 'skipped', + event, + reason: 'missing transcript id', + }; + } + + const downloadResult = await downloadTranscript({ transcriptId }); + + switch (downloadResult.outcome) { + case 'filled': { + const updateData: CallRecordingUpdateFields = { + transcript: downloadResult.content as Record, + ...(isUndefined(callRecording.externalRecordingId) + ? buildExternalRecordingIdUpdate(webhookEvent) + : {}), + }; + + await persistCallRecordingProgress(client, { + id: callRecording.id, + current: callRecording, + updateData, + }); + + return { + status: 'updated', + event, + callRecordingId: callRecording.id, + transcriptOutcome: 'FILLED', + }; + } + case 'failed': + return applyTranscriptFailure({ + client, + callRecording, + event, + transcriptId, + subCode: downloadResult.subCode, + }); + case 'pending': + case 'error': { + // 200-acked either way, Svix never redelivers; the cron re-check retries this. + const reason = + downloadResult.outcome === 'pending' + ? 'transcript not downloadable yet' + : downloadResult.errorMessage; + + console.warn( + `[twenty-meeting-bot] could not fill transcript for call recording ${callRecording.id}: ${reason}`, + ); + + return { + status: 'skipped', + event, + reason, + }; + } + } +}; + +const applyTranscriptFailure = async ({ + client, + callRecording, + event, + transcriptId, + subCode, +}: { + client: CoreApiClient; + callRecording: MatchedCallRecording; + event: string; + transcriptId: string | undefined; + subCode: string | null; +}): Promise => { + const existingMarker = parseTranscriptMarker(callRecording.transcript); + + if (!isTranscriptUnset(callRecording) && isUndefined(existingMarker)) { + return { + status: 'skipped', + event, + reason: 'transcript already filled', + }; + } + + console.warn( + `[twenty-meeting-bot] transcript failed for call recording ${callRecording.id}${isNull(subCode) ? '' : ` (${subCode})`}`, + ); + + await updateCallRecording(client, { + id: callRecording.id, + data: { + transcript: buildFailedTranscriptMarker({ + recallTranscriptId: + transcriptId ?? existingMarker?.recallTranscriptId ?? null, + subCode, + }), + ...(isCallRecordingStatusDowngrade({ + fromStatus: callRecording.status, + toStatus: CallRecordingStatus.FAILED_UNKNOWN, + }) + ? {} + : { status: CallRecordingStatus.FAILED_UNKNOWN }), + }, + }); + + return { + status: 'updated', + event, + callRecordingId: callRecording.id, + transcriptOutcome: 'FAILED', + }; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/ingest-call-recording-media.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/ingest-call-recording-media.util.ts new file mode 100644 index 0000000000..79259d9ce2 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/ingest-call-recording-media.util.ts @@ -0,0 +1,127 @@ +import { 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 { 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 CallRecordingUpdateFields } from 'src/logic-functions/data/update-call-recording.util'; + +type CallRecordingMediaUpdateFields = Pick< + CallRecordingUpdateFields, + 'audio' | 'video' +>; + +const MEDIA_DOWNLOAD_TIMEOUT_MS = 120_000; + +export const ingestCallRecordingMedia = async ({ + callRecordingId, + externalRecordingId, + hasAudio, + hasVideo, +}: { + callRecordingId: string; + externalRecordingId: string; + hasAudio: boolean; + hasVideo: boolean; +}): Promise => { + if (hasAudio && hasVideo) { + return {}; + } + + const recordingResult = await getRecallRecording({ externalRecordingId }); + + if (!recordingResult.ok) { + console.warn( + `[twenty-meeting-bot] failed to fetch Recall recording ${externalRecordingId} while ingesting media for call recording ${callRecordingId}: ${recordingResult.errorMessage}`, + ); + + return {}; + } + + const mediaUrls = extractRecallMediaUrls(recordingResult.recording); + const metadataClient = new MetadataApiClient(); + const updateFields: CallRecordingMediaUpdateFields = {}; + + if (!hasVideo && !isUndefined(mediaUrls.videoUrl)) { + const video = await ingestMediaArtifact({ + callRecordingId, + metadataClient, + url: mediaUrls.videoUrl, + fileName: 'video.mp4', + fieldMetadataUniversalIdentifier: + CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER, + }); + + if (!isUndefined(video)) { + updateFields.video = video; + } + } + + if (!hasAudio && !isUndefined(mediaUrls.audioUrl)) { + const audio = await ingestMediaArtifact({ + callRecordingId, + metadataClient, + url: mediaUrls.audioUrl, + fileName: 'audio.mp3', + fieldMetadataUniversalIdentifier: + CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER, + }); + + if (!isUndefined(audio)) { + updateFields.audio = audio; + } + } + + return updateFields; +}; + +const ingestMediaArtifact = async ({ + callRecordingId, + metadataClient, + url, + fileName, + fieldMetadataUniversalIdentifier, +}: { + callRecordingId: string; + metadataClient: InstanceType; + url: string; + fileName: string; + fieldMetadataUniversalIdentifier: string; +}): Promise<{ fileId: string; label: string }[] | undefined> => { + try { + const { buffer, contentType } = await downloadMediaFile(url); + const uploadedFile = await metadataClient.uploadFile( + buffer, + fileName, + contentType, + fieldMetadataUniversalIdentifier, + ); + + return [{ fileId: uploadedFile.id, label: fileName }]; + } catch (error) { + console.warn( + `[twenty-meeting-bot] failed to ingest ${fileName} for call recording ${callRecordingId}: ${error instanceof Error ? error.message : String(error)}`, + ); + + return undefined; + } +}; + +const downloadMediaFile = async ( + url: string, +): Promise<{ buffer: Buffer; contentType: string }> => { + const response = await fetch(url, { + signal: AbortSignal.timeout(MEDIA_DOWNLOAD_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`download failed with status ${response.status}`); + } + + return { + buffer: Buffer.from(await response.arrayBuffer()), + contentType: + response.headers.get('content-type') ?? 'application/octet-stream', + }; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/persist-call-recording-progress.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/persist-call-recording-progress.util.ts new file mode 100644 index 0000000000..6a1a620786 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/persist-call-recording-progress.util.ts @@ -0,0 +1,59 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type'; +import { completeAndChargeCallRecording } from 'src/logic-functions/flows/complete-and-charge-call-recording.util'; +import { shouldCompleteCallRecordingIngestion } from 'src/logic-functions/domain/should-complete-call-recording-ingestion.util'; +import { + updateCallRecording, + type CallRecordingUpdateFields, +} from 'src/logic-functions/data/update-call-recording.util'; + +type PersistCallRecordingProgressCurrent = { + status?: string; + startedAt?: string; + endedAt?: string; + transcript?: unknown; + audio?: FilesFieldValue; + video?: FilesFieldValue; +}; + +export const persistCallRecordingProgress = async ( + client: CoreApiClient, + { + id, + current, + updateData, + }: { + id: string; + current: PersistCallRecordingProgressCurrent; + updateData: CallRecordingUpdateFields; + }, +): Promise<{ completesIngestion: boolean }> => { + const completesIngestion = shouldCompleteCallRecordingIngestion({ + current, + updateData, + }); + + if (!completesIngestion) { + await updateCallRecording(client, { id, data: updateData }); + + return { completesIngestion: false }; + } + + // Strip status so COMPLETED is written only by the atomic claim — its single winner bills once. + const nonStatusUpdate: CallRecordingUpdateFields = { ...updateData }; + + delete nonStatusUpdate.status; + + if (Object.keys(nonStatusUpdate).length > 0) { + await updateCallRecording(client, { id, data: nonStatusUpdate }); + } + + await completeAndChargeCallRecording(client, { + id, + startedAt: updateData.startedAt ?? current.startedAt, + endedAt: updateData.endedAt ?? current.endedAt, + }); + + return { completesIngestion: true }; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-pending-transcripts.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-pending-transcripts.util.ts new file mode 100644 index 0000000000..3d2e738204 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-pending-transcripts.util.ts @@ -0,0 +1,224 @@ +import { isNull, isUndefined } from '@sniptt/guards'; +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; +import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size'; +import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type'; +import { type TranscriptMarker } from 'src/logic-functions/types/transcript-marker.type'; +import { buildFailedTranscriptMarker } from 'src/logic-functions/domain/build-failed-transcript-marker.util'; +import { downloadTranscript } from 'src/logic-functions/flows/download-transcript.util'; +import { + fetchAllNodes, + type ConnectionPage, +} from 'src/logic-functions/data/fetch-all-nodes.util'; +import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util'; +import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util'; +import { persistCallRecordingProgress } from 'src/logic-functions/flows/persist-call-recording-progress.util'; +import { + updateCallRecording, + type CallRecordingUpdateFields, +} from 'src/logic-functions/data/update-call-recording.util'; + +const PENDING_TRANSCRIPT_RECHECK_MINUTES = 60; +const PENDING_TRANSCRIPT_LOOKBACK_DAYS = 7; + +type PendingTranscriptCallRecording = { + id: string; + marker: TranscriptMarker; + status: string | undefined; + startedAt: string | undefined; + endedAt: string | undefined; + transcript: unknown; + audio: FilesFieldValue | undefined; + video: FilesFieldValue | undefined; +}; + +type PendingTranscriptCallRecordingNode = { + id: string; + status?: string | null; + startedAt?: string | null; + endedAt?: string | null; + transcript?: unknown; + audio?: FilesFieldValue | null; + video?: FilesFieldValue | null; +}; + +export type ReconcilePendingTranscriptsResult = { + pendingMarkerCount: number; + filledCallRecordingIds: string[]; + failedCallRecordingIds: string[]; +}; + +export const reconcilePendingTranscripts = async ({ + client, + now, +}: { + client: CoreApiClient; + now: Date; +}): Promise => { + const pendingCallRecordings = await fetchPendingTranscriptCallRecordings( + client, + now, + ); + const recheckCutoff = new Date( + now.getTime() - PENDING_TRANSCRIPT_RECHECK_MINUTES * 60 * 1000, + ); + + const result: ReconcilePendingTranscriptsResult = { + pendingMarkerCount: pendingCallRecordings.length, + filledCallRecordingIds: [], + failedCallRecordingIds: [], + }; + + for (const pendingCallRecording of pendingCallRecordings) { + const { id, marker } = pendingCallRecording; + + if ( + !isUndefined(marker.requestedAt) && + new Date(marker.requestedAt).getTime() > recheckCutoff.getTime() + ) { + continue; + } + + if (isNull(marker.recallTranscriptId)) { + console.warn( + `[twenty-meeting-bot] call recording ${id} has a pending transcript marker without a transcript id; marking it failed`, + ); + await updateCallRecording(client, { + id, + data: { + transcript: buildFailedTranscriptMarker({ + recallTranscriptId: null, + subCode: 'missing_transcript_id', + }), + ...(isCallRecordingStatusDowngrade({ + fromStatus: pendingCallRecording.status, + toStatus: CallRecordingStatus.FAILED_UNKNOWN, + }) + ? {} + : { status: CallRecordingStatus.FAILED_UNKNOWN }), + }, + }); + result.failedCallRecordingIds.push(id); + continue; + } + + const downloadResult = await downloadTranscript({ + transcriptId: marker.recallTranscriptId, + }); + + if (downloadResult.outcome === 'filled') { + const updateData: CallRecordingUpdateFields = { + transcript: downloadResult.content as Record, + }; + + await persistCallRecordingProgress(client, { + id, + current: pendingCallRecording, + updateData, + }); + + result.filledCallRecordingIds.push(id); + continue; + } + + if (downloadResult.outcome === 'failed') { + console.warn( + `[twenty-meeting-bot] transcript failed for call recording ${id}${isNull(downloadResult.subCode) ? '' : ` (${downloadResult.subCode})`}`, + ); + await updateCallRecording(client, { + id, + data: { + transcript: buildFailedTranscriptMarker({ + recallTranscriptId: marker.recallTranscriptId, + subCode: downloadResult.subCode, + }), + ...(isCallRecordingStatusDowngrade({ + fromStatus: pendingCallRecording.status, + toStatus: CallRecordingStatus.FAILED_UNKNOWN, + }) + ? {} + : { status: CallRecordingStatus.FAILED_UNKNOWN }), + }, + }); + result.failedCallRecordingIds.push(id); + continue; + } + + if (downloadResult.outcome === 'error') { + console.warn( + `[twenty-meeting-bot] could not re-check pending transcript for call recording ${id}: ${downloadResult.errorMessage}`, + ); + } + } + + return result; +}; + +const fetchPendingTranscriptCallRecordings = async ( + client: CoreApiClient, + now: Date, +): Promise => { + const filter: Record = { + transcript: { is: 'NOT_NULL' }, + updatedAt: { + gte: new Date( + now.getTime() - PENDING_TRANSCRIPT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000, + ).toISOString(), + }, + }; + const callRecordingNodes = + await fetchAllNodes( + async (afterCursor) => { + const queryResult = await client.query({ + callRecordings: { + __args: { + filter, + first: TWENTY_PAGE_SIZE, + ...(isUndefined(afterCursor) ? {} : { after: afterCursor }), + }, + pageInfo: { + hasNextPage: true, + endCursor: true, + }, + edges: { + node: { + id: true, + status: true, + startedAt: true, + endedAt: true, + transcript: true, + audio: { fileId: true }, + video: { fileId: true }, + }, + }, + }, + }); + + return (queryResult.callRecordings ?? undefined) as + | ConnectionPage + | undefined; + }, + ); + + return callRecordingNodes.flatMap((node) => { + const marker = parseTranscriptMarker(node.transcript); + + if (isUndefined(marker) || marker.status !== 'PENDING') { + return []; + } + + return [ + { + id: node.id, + marker, + status: node.status ?? undefined, + startedAt: node.startedAt ?? undefined, + endedAt: node.endedAt ?? undefined, + transcript: node.transcript ?? undefined, + audio: node.audio ?? undefined, + video: node.video ?? undefined, + }, + ]; + }); +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/request-transcript.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/request-transcript.util.ts new file mode 100644 index 0000000000..18dc1d5289 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/request-transcript.util.ts @@ -0,0 +1,26 @@ +import { type TranscriptMarker } from 'src/logic-functions/types/transcript-marker.type'; +import { buildPendingTranscriptMarker } from 'src/logic-functions/domain/build-pending-transcript-marker.util'; +import { createAsyncRecallTranscript } from 'src/logic-functions/recall-api/create-async-recall-transcript.util'; + +export const requestTranscript = async ({ + externalRecordingId, + requestedAt, +}: { + externalRecordingId: string; + requestedAt: string; +}): Promise => { + const result = await createAsyncRecallTranscript({ externalRecordingId }); + + if (!result.ok) { + console.warn( + `[twenty-meeting-bot] failed to request transcript for Recall recording ${externalRecordingId}: ${result.errorMessage}`, + ); + + return null; + } + + return buildPendingTranscriptMarker({ + recallTranscriptId: result.transcriptId, + requestedAt, + }); +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/extract-recall-bot-convergence.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/extract-recall-bot-convergence.test.ts index 1b46375a08..ca6c66958b 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/extract-recall-bot-convergence.test.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/extract-recall-bot-convergence.test.ts @@ -15,6 +15,7 @@ describe('extractRecallBotConvergence', () => { // COMPLETED is reserved for full artifact ingestion, never bot state. expect(convergence.status).toBe('PROCESSING'); + expect(convergence.isRecallRecordingDone).toBe(true); }); it('uses created_at to find the latest status when Recall returns status changes out of order', () => { @@ -49,6 +50,7 @@ describe('extractRecallBotConvergence', () => { startedAt: '2026-01-01T13:02:00.000Z', endedAt: '2026-01-01T14:00:00.000Z', externalRecordingId: 'recall-recording-1', + isRecallRecordingDone: true, }); }); @@ -66,6 +68,7 @@ describe('extractRecallBotConvergence', () => { startedAt: '2026-01-01T13:02:00.000Z', endedAt: '2026-01-01T14:00:00.000Z', externalRecordingId: 'recall-recording-1', + isRecallRecordingDone: false, }); }); @@ -93,6 +96,7 @@ describe('extractRecallBotConvergence', () => { startedAt: undefined, endedAt: undefined, externalRecordingId: undefined, + isRecallRecordingDone: false, }); }); @@ -112,6 +116,7 @@ describe('extractRecallBotConvergence', () => { startedAt: '2026-01-01T13:02:00.000Z', endedAt: undefined, externalRecordingId: undefined, + isRecallRecordingDone: false, }); }); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/extract-recall-media-urls.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/extract-recall-media-urls.test.ts new file mode 100644 index 0000000000..1f254c3cc9 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/extract-recall-media-urls.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { extractRecallMediaUrls } from 'src/logic-functions/recall-api/extract-recall-media-urls.util'; + +describe('extractRecallMediaUrls', () => { + it('reads both download urls flat from the v1.11 media shortcuts', () => { + expect( + extractRecallMediaUrls({ + 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', + }, + }, + }), + ).toEqual({ + videoUrl: 'https://media.example.com/video.mp4', + audioUrl: 'https://media.example.com/audio.mp3', + }); + }); + + it('falls back to the nested data.download_url shape', () => { + expect( + extractRecallMediaUrls({ + id: 'recall-recording-1', + media_shortcuts: { + video_mixed: { + data: { download_url: 'https://media.example.com/video.mp4' }, + }, + audio_mixed: { + data: { download_url: 'https://media.example.com/audio.mp3' }, + }, + }, + }), + ).toEqual({ + videoUrl: 'https://media.example.com/video.mp4', + audioUrl: 'https://media.example.com/audio.mp3', + }); + }); + + it('returns undefined urls when artifacts are absent', () => { + expect( + extractRecallMediaUrls({ + id: 'recall-recording-1', + media_shortcuts: { + video_mixed: {}, + }, + }), + ).toEqual({ videoUrl: undefined, audioUrl: undefined }); + }); + + it('tolerates malformed recording payloads', () => { + expect(extractRecallMediaUrls({})).toEqual({ + videoUrl: undefined, + audioUrl: undefined, + }); + expect(extractRecallMediaUrls({ media_shortcuts: 'not-a-record' })).toEqual( + { + videoUrl: undefined, + audioUrl: undefined, + }, + ); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts index b02abaa142..4e7a72324a 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts @@ -1,10 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { cancelRecallBot } from 'src/logic-functions/recall-api/cancel-recall-bot.util'; +import { createAsyncRecallTranscript } from 'src/logic-functions/recall-api/create-async-recall-transcript.util'; import { ejectRecallBot } from 'src/logic-functions/recall-api/eject-recall-bot.util'; import { getRecallBot } from 'src/logic-functions/recall-api/get-recall-bot.util'; import { listScheduledRecallBots } from 'src/logic-functions/recall-api/list-scheduled-recall-bots.util'; import { rescheduleRecallBot } from 'src/logic-functions/recall-api/reschedule-recall-bot.util'; +import { retrieveRecallTranscript } from 'src/logic-functions/recall-api/retrieve-recall-transcript.util'; import { scheduleRecallBot } from 'src/logic-functions/recall-api/schedule-recall-bot.util'; const getRecallApiConfigMock = vi.hoisted(() => vi.fn()); @@ -335,22 +337,6 @@ describe('recall bot api', () => { ); }); - it('fails when fetching a bot returns an empty response payload', async () => { - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - json: async () => null, - }); - - const result = await getRecallBot({ externalBotId: 'recall-bot-id' }); - - expect(result).toEqual({ - ok: false, - status: 200, - errorMessage: 'Recall API returned an empty bot response', - }); - }); - it('reports the HTTP status when fetching a bot that no longer exists', async () => { fetchMock.mockResolvedValue({ ok: false, @@ -368,6 +354,139 @@ describe('recall bot api', () => { }); }); + it('creates an async transcript with the locked provider settings', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ id: 'recall-transcript-id' }), + }); + + const result = await createAsyncRecallTranscript({ + externalRecordingId: 'recall-recording-id', + }); + + expect(result).toEqual({ ok: true, transcriptId: 'recall-transcript-id' }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://ap-northeast-1.recall.ai/api/v1/recording/recall-recording-id/create_transcript/', + expect.objectContaining({ method: 'POST' }), + ); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ + provider: { recallai_async: { language_code: 'auto' } }, + diarization: { use_separate_streams_when_available: true }, + }); + }); + + it('fails when the transcript creation response has no id', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({}), + }); + + const result = await createAsyncRecallTranscript({ + externalRecordingId: 'recall-recording-id', + }); + + expect(result).toEqual({ + ok: false, + status: null, + errorMessage: + 'Recall API created a transcript but the response did not include a transcript id', + }); + }); + + it('retrieves transcript details with the download URL and status', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + id: 'recall-transcript-id', + status: { code: 'done', sub_code: null }, + data: { + download_url: 'https://recall-transcripts.example.com/transcript', + }, + }), + }); + + const result = await retrieveRecallTranscript({ + transcriptId: 'recall-transcript-id', + }); + + expect(result).toEqual({ + ok: true, + transcript: { + downloadUrl: 'https://recall-transcripts.example.com/transcript', + statusCode: 'done', + statusSubCode: undefined, + }, + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://ap-northeast-1.recall.ai/api/v1/transcript/recall-transcript-id/', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('surfaces the failure sub code of an errored transcript', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + id: 'recall-transcript-id', + status: { code: 'error', sub_code: 'audio_missing' }, + data: {}, + }), + }); + + const result = await retrieveRecallTranscript({ + transcriptId: 'recall-transcript-id', + }); + + expect(result).toEqual({ + ok: true, + transcript: { + downloadUrl: undefined, + statusCode: 'error', + statusSubCode: 'audio_missing', + }, + }); + }); + + it('rejects malformed transcript details', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + id: 'recall-transcript-id', + status: { code: 'done' }, + data: {}, + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + id: 'recall-transcript-id', + data: {}, + }), + }); + + await expect( + retrieveRecallTranscript({ transcriptId: 'recall-transcript-id' }), + ).resolves.toEqual({ + ok: false, + status: 200, + errorMessage: 'Recall API returned malformed transcript details', + }); + await expect( + retrieveRecallTranscript({ transcriptId: 'recall-transcript-id' }), + ).resolves.toEqual({ + ok: false, + status: 200, + errorMessage: 'Recall API returned malformed transcript details', + }); + }); + describe('transient failure retries', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/create-async-recall-transcript.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/create-async-recall-transcript.util.ts new file mode 100644 index 0000000000..3802448d11 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/create-async-recall-transcript.util.ts @@ -0,0 +1,46 @@ +import { isString } from '@sniptt/guards'; + +import { type RecallBotOperationFailure } from 'src/logic-functions/types/recall-bot-operation-result.type'; +import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util'; +import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util'; + +type CreateAsyncRecallTranscriptResult = + | { ok: true; transcriptId: string } + | RecallBotOperationFailure; + +export const createAsyncRecallTranscript = async ({ + externalRecordingId, +}: { + externalRecordingId: string; +}): Promise => { + const configResult = getRecallApiConfig(); + + if (!configResult.success) { + return { ok: false, status: null, errorMessage: configResult.error }; + } + + const result = await recallBotApiRequest<{ id?: unknown }>({ + config: configResult.config, + path: `/recording/${externalRecordingId}/create_transcript/`, + method: 'POST', + body: { + provider: { recallai_async: { language_code: 'auto' } }, + diarization: { use_separate_streams_when_available: true }, + }, + }); + + if (!result.ok) { + return result; + } + + if (!isString(result.data?.id)) { + return { + ok: false, + status: null, + errorMessage: + 'Recall API created a transcript but the response did not include a transcript id', + }; + } + + return { ok: true, transcriptId: result.data.id }; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-bot-convergence.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-bot-convergence.util.ts index d3ef27a030..6fac09eb47 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-bot-convergence.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-bot-convergence.util.ts @@ -11,6 +11,7 @@ export type RecallBotConvergence = { startedAt: string | undefined; endedAt: string | undefined; externalRecordingId: string | undefined; + isRecallRecordingDone: boolean; }; type RecallBotStatusChange = { @@ -37,6 +38,9 @@ export const extractRecallBotConvergence = ( findStatusChangeTimestamp(statusChanges, 'call_ended'), ), externalRecordingId: recording?.id, + isRecallRecordingDone: + !isUndefined(recording?.completedAt) || + statusChanges.some((statusChange) => statusChange.code === 'done'), }; }; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-media-urls.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-media-urls.util.ts new file mode 100644 index 0000000000..83d4bdba7c --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-media-urls.util.ts @@ -0,0 +1,30 @@ +import { asRecord } from 'src/logic-functions/utils/as-record.util'; +import { getRecordAtPath } from 'src/logic-functions/utils/get-record-at-path.util'; +import { getString } from 'src/logic-functions/utils/get-string.util'; + +export type RecallMediaUrls = { + videoUrl: string | undefined; + audioUrl: string | undefined; +}; + +// Pre-signed URLs expire within hours; always re-extract from a fresh GET /recording. +export const extractRecallMediaUrls = ( + recording: Record, +): RecallMediaUrls => { + const mediaShortcuts = asRecord(recording.media_shortcuts); + + return { + videoUrl: extractArtifactDownloadUrl(mediaShortcuts, 'video_mixed'), + audioUrl: extractArtifactDownloadUrl(mediaShortcuts, 'audio_mixed'), + }; +}; + +// v1.11 exposes download_url flat on the artifact; older artifacts nest it under data. +const extractArtifactDownloadUrl = ( + mediaShortcuts: Record | undefined, + artifactKey: string, +): string | undefined => + getString(getRecordAtPath(mediaShortcuts, [artifactKey, 'download_url'])) ?? + getString( + getRecordAtPath(mediaShortcuts, [artifactKey, 'data', 'download_url']), + ); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/get-recall-recording.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/get-recall-recording.util.ts new file mode 100644 index 0000000000..a81133ec09 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/get-recall-recording.util.ts @@ -0,0 +1,31 @@ +import { type RecallBotOperationFailure } from 'src/logic-functions/types/recall-bot-operation-result.type'; +import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util'; +import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util'; + +type GetRecallRecordingResult = + | { ok: true; recording: Record } + | RecallBotOperationFailure; + +export const getRecallRecording = async ({ + externalRecordingId, +}: { + externalRecordingId: string; +}): Promise => { + const configResult = getRecallApiConfig(); + + if (!configResult.success) { + return { ok: false, status: null, errorMessage: configResult.error }; + } + + const result = await recallBotApiRequest>({ + config: configResult.config, + path: `/recording/${externalRecordingId}/`, + method: 'GET', + }); + + if (!result.ok) { + return result; + } + + return { ok: true, recording: result.data ?? {} }; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts index 1f339350c8..e1752ac8c8 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts @@ -21,6 +21,8 @@ export type RecallWebhookEvent = { callRecordingIdFromMetadata: string | undefined; 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. @@ -68,6 +70,10 @@ export const parseRecallWebhookEvent = ( recordingEndedAt: normalizeRecallTimestamp( 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/internal/twenty-meeting-bot/src/logic-functions/recall-api/retrieve-recall-transcript.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/retrieve-recall-transcript.util.ts new file mode 100644 index 0000000000..a096da7c62 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/retrieve-recall-transcript.util.ts @@ -0,0 +1,71 @@ +import { isUndefined } from '@sniptt/guards'; + +import { type RecallBotOperationFailure } from 'src/logic-functions/types/recall-bot-operation-result.type'; +import { asRecord } from 'src/logic-functions/utils/as-record.util'; +import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util'; +import { getString } from 'src/logic-functions/utils/get-string.util'; +import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util'; + +export type RecallTranscriptDetails = { + downloadUrl: string | undefined; + statusCode: string | undefined; + statusSubCode: string | undefined; +}; + +type RetrieveRecallTranscriptResult = + | { ok: true; transcript: RecallTranscriptDetails } + | RecallBotOperationFailure; + +export const retrieveRecallTranscript = async ({ + transcriptId, +}: { + transcriptId: string; +}): Promise => { + const configResult = getRecallApiConfig(); + + if (!configResult.success) { + return { ok: false, status: null, errorMessage: configResult.error }; + } + + const result = await recallBotApiRequest>({ + config: configResult.config, + path: `/transcript/${transcriptId}/`, + method: 'GET', + }); + + if (!result.ok) { + return result; + } + + const transcript = extractRecallTranscriptDetails(result.data); + + if (isMalformedRecallTranscriptDetails(transcript)) { + return { + ok: false, + status: result.status, + errorMessage: 'Recall API returned malformed transcript details', + }; + } + + return { ok: true, transcript }; +}; + +const extractRecallTranscriptDetails = ( + response: Record | undefined, +): RecallTranscriptDetails => { + const data = asRecord(response?.data); + const status = asRecord(response?.status); + + return { + downloadUrl: getString(data?.download_url), + statusCode: getString(status?.code), + statusSubCode: getString(status?.sub_code), + }; +}; + +const isMalformedRecallTranscriptDetails = ({ + downloadUrl, + statusCode, +}: RecallTranscriptDetails): boolean => + (isUndefined(downloadUrl) && isUndefined(statusCode)) || + (isUndefined(downloadUrl) && statusCode === 'done'); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/reconcile-stale-bot-state.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/reconcile-stale-bot-state.ts index f7a89bcae8..769d2a4438 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/reconcile-stale-bot-state.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/reconcile-stale-bot-state.ts @@ -15,6 +15,10 @@ import { reapOrphanedMeetingBots, type ReapOrphanedMeetingBotsResult, } from 'src/logic-functions/flows/reap-orphaned-meeting-bots.util'; +import { + reconcilePendingTranscripts, + type ReconcilePendingTranscriptsResult, +} from 'src/logic-functions/flows/reconcile-pending-transcripts.util'; // Every unwanted bot passes through this join_at window before it can attend. const REAPER_JOIN_AT_LOOKBACK_HOURS = 4; @@ -38,11 +42,16 @@ export const reconcileStaleBotStateHandler = async (): Promise => { client, now, ); + const pendingTranscriptResult = await reconcilePendingTranscriptsSafely( + client, + now, + ); return { botlessHealResult, orphanedBotReapingResult, statusConvergenceResult, + pendingTranscriptResult, }; }; @@ -87,6 +96,17 @@ const convergeDivergedCallRecordingsSafely = async ( } }; +const reconcilePendingTranscriptsSafely = async ( + client: CoreApiClient, + now: Date, +): Promise => { + try { + return await reconcilePendingTranscripts({ client, now }); + } catch (error) { + return buildStepFailure('pending transcript reconciliation', error); + } +}; + const buildStepFailure = (stepLabel: string, error: unknown): StepFailure => { const errorMessage = error instanceof Error ? error.message : String(error); @@ -101,8 +121,7 @@ export default defineLogicFunction({ universalIdentifier: STALE_BOT_STATE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, name: 'reconcile-stale-bot-state', description: - 'Converges call recordings with Recall on a schedule: pulls stale bot statuses, finishes failed cancellations, schedules bots for recordings still missing one, and reaps unclaimed bots. Reads calendar events only to heal already-decided recordings, never to discover meetings.', - // Pulling bot statuses for many recordings is the dominant cost. + 'Converges call recordings with Recall on a schedule: pulls stale bot statuses and overdue transcripts, finishes failed cancellations, schedules bots for recordings still missing one, and reaps unclaimed bots. Reads calendar events only to heal already-decided recordings, never to discover meetings.', timeoutSeconds: 300, handler: reconcileStaleBotStateHandler, cronTriggerSettings: { diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/files-field-value.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/files-field-value.type.ts new file mode 100644 index 0000000000..e75a1bddd7 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/files-field-value.type.ts @@ -0,0 +1 @@ +export type FilesFieldValue = { fileId: string }[]; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/transcript-marker.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/transcript-marker.type.ts new file mode 100644 index 0000000000..205ce51d1c --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/transcript-marker.type.ts @@ -0,0 +1,6 @@ +export type TranscriptMarker = { + recallTranscriptId: string | null; + status: 'PENDING' | 'FAILED'; + requestedAt?: string; + subCode?: string | null; +}; diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 4bb660e7f4..1841eaceec 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -3231,6 +3231,7 @@ enum UsageOperationType { WORKFLOW_EXECUTION CODE_EXECUTION WEB_SEARCH + CALL_RECORDING } type Mutation { diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 22bceee3f8..ad3e5c2294 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -2753,7 +2753,7 @@ export interface Query { export type EventLogTable = 'WORKSPACE_EVENT' | 'PAGEVIEW' | 'OBJECT_EVENT' | 'USAGE_EVENT' | 'APPLICATION_LOG' -export type UsageOperationType = 'AI_CHAT_TOKEN' | 'AI_WORKFLOW_TOKEN' | 'WORKFLOW_EXECUTION' | 'CODE_EXECUTION' | 'WEB_SEARCH' +export type UsageOperationType = 'AI_CHAT_TOKEN' | 'AI_WORKFLOW_TOKEN' | 'WORKFLOW_EXECUTION' | 'CODE_EXECUTION' | 'WEB_SEARCH' | 'CALL_RECORDING' export interface Mutation { addQueryToEventStream: Scalars['Boolean'] @@ -9252,7 +9252,8 @@ export const enumUsageOperationType = { AI_WORKFLOW_TOKEN: 'AI_WORKFLOW_TOKEN' as const, WORKFLOW_EXECUTION: 'WORKFLOW_EXECUTION' as const, CODE_EXECUTION: 'CODE_EXECUTION' as const, - WEB_SEARCH: 'WEB_SEARCH' as const + WEB_SEARCH: 'WEB_SEARCH' as const, + CALL_RECORDING: 'CALL_RECORDING' as const } export const enumWorkspaceMigrationActionType = { diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 492ff57154..742105ab97 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -5762,6 +5762,7 @@ export type UsageBreakdownItem = { export enum UsageOperationType { AI_CHAT_TOKEN = 'AI_CHAT_TOKEN', AI_WORKFLOW_TOKEN = 'AI_WORKFLOW_TOKEN', + CALL_RECORDING = 'CALL_RECORDING', CODE_EXECUTION = 'CODE_EXECUTION', WEB_SEARCH = 'WEB_SEARCH', WORKFLOW_EXECUTION = 'WORKFLOW_EXECUTION' diff --git a/packages/twenty-server/src/engine/core-modules/billing/app-billing/app-billing.service.ts b/packages/twenty-server/src/engine/core-modules/billing/app-billing/app-billing.service.ts index 9ec8f50fa5..2543d1eb91 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/app-billing/app-billing.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/app-billing/app-billing.service.ts @@ -21,6 +21,7 @@ const USAGE_UNIT_BY_OPERATION_TYPE: Record = { [UsageOperationType.WORKFLOW_EXECUTION]: UsageUnit.INVOCATION, [UsageOperationType.CODE_EXECUTION]: UsageUnit.INVOCATION, [UsageOperationType.WEB_SEARCH]: UsageUnit.INVOCATION, + [UsageOperationType.CALL_RECORDING]: UsageUnit.MINUTE, }; // `workspaceId` + `applicationId` come from the application-access token, diff --git a/packages/twenty-server/src/engine/core-modules/usage/enums/usage-operation-type.enum.ts b/packages/twenty-server/src/engine/core-modules/usage/enums/usage-operation-type.enum.ts index 55e05da54d..f280707ff6 100644 --- a/packages/twenty-server/src/engine/core-modules/usage/enums/usage-operation-type.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/usage/enums/usage-operation-type.enum.ts @@ -8,6 +8,7 @@ export enum UsageOperationType { WORKFLOW_EXECUTION = 'WORKFLOW_EXECUTION', CODE_EXECUTION = 'CODE_EXECUTION', WEB_SEARCH = 'WEB_SEARCH', + CALL_RECORDING = 'CALL_RECORDING', } registerEnumType(UsageOperationType, {