From 640a8e6ca6759be02230fb98adf115317535cee1 Mon Sep 17 00:00:00 2001
From: nitin <142569587+ehconitin@users.noreply.github.com>
Date: Thu, 18 Jun 2026 14:01:18 +0530
Subject: [PATCH] Add post-call recording ingestion and billing (#21758)
## Summary
- Add post-call Recall recording ingestion for transcripts, audio, and
video
- Request/retrieve async transcripts and reconcile stale pending
transcript markers
- Complete call recordings atomically once all artifacts and billable
timestamps are available
- Charge `CALL_RECORDING` usage once per completed recording based on
recording duration
- Add Recall recording/media API helpers, transcript marker utilities,
and audio/video field identifiers
- Update generated metadata/SDK files and billing usage operation
support
- Add unit coverage for ingestion, completion, charging, Recall API
behavior, and reconciliation flows
---
...ording-field-universal-identifiers.test.ts | 19 +
...ording-audio-field-universal-identifier.ts | 2 +
...ording-video-field-universal-identifier.ts | 2 +
.../twenty-meeting-bot/src/default-role.ts | 4 +-
.../call-recording-micro-credits-per-hour.ts | 1 +
.../complete-call-recording-ingestion.test.ts | 55 ++
.../complete-call-recording-ingestion.util.ts | 23 +
.../data/update-call-recording.util.ts | 3 +
.../compute-call-recording-charge.test.ts | 71 ++
...-call-recording-ingestion-complete.test.ts | 59 ++
...-complete-call-recording-ingestion.test.ts | 68 ++
.../build-failed-transcript-marker.util.ts | 13 +
.../build-pending-transcript-marker.util.ts | 13 +
.../compute-call-recording-charge.util.ts | 41 ++
...-call-recording-ingestion-complete.util.ts | 19 +
.../is-recall-recording-done-signal.util.ts | 7 +
.../domain/parse-transcript-marker.util.ts | 29 +
...-complete-call-recording-ingestion.util.ts | 30 +
.../charge-completed-call-recording.test.ts | 45 ++
...complete-and-charge-call-recording.test.ts | 61 ++
.../converge-diverged-call-recordings.test.ts | 204 +++++-
.../__tests__/download-transcript.test.ts | 74 ++
.../__tests__/handle-recall-webhook.test.ts | 687 ++++++++++++++++--
.../ingest-call-recording-media.test.ts | 153 ++++
.../reconcile-pending-transcripts.test.ts | 342 +++++++++
.../charge-completed-call-recording.util.ts | 31 +
...complete-and-charge-call-recording.util.ts | 29 +
.../converge-diverged-call-recordings.util.ts | 72 +-
.../flows/download-transcript.util.ts | 67 ++
.../flows/handle-recall-webhook.util.ts | 390 +++++++++-
.../flows/ingest-call-recording-media.util.ts | 127 ++++
.../persist-call-recording-progress.util.ts | 59 ++
.../reconcile-pending-transcripts.util.ts | 224 ++++++
.../flows/request-transcript.util.ts | 26 +
.../extract-recall-bot-convergence.test.ts | 5 +
.../extract-recall-media-urls.test.ts | 67 ++
.../__tests__/recall-bot-api.test.ts | 151 +++-
.../create-async-recall-transcript.util.ts | 46 ++
.../extract-recall-bot-convergence.util.ts | 4 +
.../extract-recall-media-urls.util.ts | 30 +
.../recall-api/get-recall-recording.util.ts | 31 +
.../parse-recall-webhook-event.util.ts | 6 +
.../retrieve-recall-transcript.util.ts | 71 ++
.../reconcile-stale-bot-state.ts | 23 +-
.../types/files-field-value.type.ts | 1 +
.../types/transcript-marker.type.ts | 6 +
.../src/metadata/generated/schema.graphql | 1 +
.../src/metadata/generated/schema.ts | 5 +-
.../src/generated-metadata/graphql.ts | 1 +
.../app-billing/app-billing.service.ts | 1 +
.../usage/enums/usage-operation-type.enum.ts | 1 +
51 files changed, 3376 insertions(+), 124 deletions(-)
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/constants/__tests__/call-recording-field-universal-identifiers.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/constants/call-recording-audio-field-universal-identifier.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/constants/call-recording-video-field-universal-identifier.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/call-recording-micro-credits-per-hour.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/complete-call-recording-ingestion.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/complete-call-recording-ingestion.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-call-recording-charge.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-ingestion-complete.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/should-complete-call-recording-ingestion.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-failed-transcript-marker.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-pending-transcript-marker.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-call-recording-charge.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-ingestion-complete.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/is-recall-recording-done-signal.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/parse-transcript-marker.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/should-complete-call-recording-ingestion.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/charge-completed-call-recording.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/complete-and-charge-call-recording.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/download-transcript.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/ingest-call-recording-media.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-pending-transcripts.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/charge-completed-call-recording.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/complete-and-charge-call-recording.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/download-transcript.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/ingest-call-recording-media.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/persist-call-recording-progress.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-pending-transcripts.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/request-transcript.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/extract-recall-media-urls.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/create-async-recall-transcript.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-media-urls.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/get-recall-recording.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/retrieve-recall-transcript.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/files-field-value.type.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/transcript-marker.type.ts
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