From 0eca0b96c267d85f1bc3a3a3d371d30928d1279d Mon Sep 17 00:00:00 2001 From: ehconitin Date: Thu, 18 Jun 2026 15:50:53 +0530 Subject: [PATCH 1/4] Converge call recordings from meeting start, not scheduled end --- .../converge-diverged-call-recordings.test.ts | 55 +++++++++++++++++-- .../converge-diverged-call-recordings.util.ts | 27 +++++---- 2 files changed, 64 insertions(+), 18 deletions(-) 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 ad1caf3f031dc..17a06ab4d7558 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 @@ -82,7 +82,10 @@ const buildStuckRecordingNode = ( audio: null, video: null, createdAt: '2026-06-09T12:00:00.000Z', - calendarEvent: { endsAt: '2026-06-09T13:00:00.000Z' }, + calendarEvent: { + startsAt: '2026-06-09T12:00:00.000Z', + endsAt: '2026-06-09T13:00:00.000Z', + }, ...overrides, }); @@ -168,6 +171,7 @@ describe('convergeDivergedCallRecordings', () => { updatedCallRecordingIds: ['call-recording-1'], markedFailedCallRecordingIds: [], unconvergeableCallRecordingIds: [], + skippedNotStartedCallRecordingIds: [], }); }); @@ -230,13 +234,17 @@ describe('convergeDivergedCallRecordings', () => { updatedCallRecordingIds: ['call-recording-1'], markedFailedCallRecordingIds: [], unconvergeableCallRecordingIds: [], + skippedNotStartedCallRecordingIds: [], }); }); - it('skips records whose meeting may still be live', async () => { + it('skips records whose meeting has not started yet', async () => { const client = buildClient([ buildStuckRecordingNode({ - calendarEvent: { endsAt: '2026-06-10T11:50:00.000Z' }, + calendarEvent: { + startsAt: '2026-06-10T12:30:00.000Z', + endsAt: '2026-06-10T13:30:00.000Z', + }, }), ]); @@ -247,7 +255,46 @@ describe('convergeDivergedCallRecordings', () => { expect(getRecallBotMock).not.toHaveBeenCalled(); expect(client.mutations).toEqual([]); - expect(result.candidateCount).toBe(1); + expect(result.skippedNotStartedCallRecordingIds).toEqual([ + 'call-recording-1', + ]); + }); + + it('converges a meeting that ended early while its scheduled end is still in the future', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + status_changes: [ + { code: 'done', created_at: '2026-06-10T11:30:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-10T11:05:00.000Z', + completed_at: '2026-06-10T11:25:00.000Z', + }, + ], + }, + }); + const client = buildClient([ + buildStuckRecordingNode({ + calendarEvent: { + startsAt: '2026-06-10T11:00:00.000Z', + endsAt: '2026-06-10T13:00:00.000Z', + }, + }), + ]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(getRecallBotMock).toHaveBeenCalledWith({ + externalBotId: 'recall-bot-1', + }); + expect(result.updatedCallRecordingIds).toEqual(['call-recording-1']); + expect(result.skippedNotStartedCallRecordingIds).toEqual([]); }); it('marks FAILED_UNKNOWN without clearing the bot id when Recall returns 404', async () => { 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 e89f36a30e934..ac98c7da13f26 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 @@ -26,7 +26,6 @@ import { } from 'src/logic-functions/data/update-call-recording.util'; const CONVERGENCE_LOOKBACK_DAYS = 7; -const LIVE_MEETING_GRACE_MINUTES = 30; const NON_TERMINAL_CALL_RECORDING_STATUSES = [ CallRecordingStatus.SCHEDULED, @@ -46,6 +45,7 @@ type DivergedCallRecordingCandidate = { audio: FilesFieldValue | undefined; video: FilesFieldValue | undefined; createdAt: string | undefined; + calendarEventStartsAt: string | undefined; calendarEventEndsAt: string | undefined; }; @@ -60,7 +60,7 @@ type DivergedCallRecordingNode = { audio?: FilesFieldValue | null; video?: FilesFieldValue | null; createdAt?: string | null; - calendarEvent?: { endsAt?: string | null } | null; + calendarEvent?: { startsAt?: string | null; endsAt?: string | null } | null; }; export type ConvergeDivergedCallRecordingsResult = { @@ -68,6 +68,7 @@ export type ConvergeDivergedCallRecordingsResult = { updatedCallRecordingIds: string[]; markedFailedCallRecordingIds: string[]; unconvergeableCallRecordingIds: string[]; + skippedNotStartedCallRecordingIds: string[]; }; // Webhook deliveries get lost; this pull pass re-derives state from Recall. @@ -82,15 +83,12 @@ export const convergeDivergedCallRecordings = async ({ const convergenceLowerBound = new Date( now.getTime() - CONVERGENCE_LOOKBACK_DAYS * 24 * 60 * 60 * 1000, ); - const liveMeetingCutoff = new Date( - now.getTime() - LIVE_MEETING_GRACE_MINUTES * 60 * 1000, - ); - const result: ConvergeDivergedCallRecordingsResult = { candidateCount: candidates.length, updatedCallRecordingIds: [], markedFailedCallRecordingIds: [], unconvergeableCallRecordingIds: [], + skippedNotStartedCallRecordingIds: [], }; for (const candidate of candidates) { @@ -110,7 +108,8 @@ export const convergeDivergedCallRecordings = async ({ continue; } - if (isPossiblyStillLive(candidate, liveMeetingCutoff)) { + if (isBeforeMeetingStart(candidate, now)) { + result.skippedNotStartedCallRecordingIds.push(candidate.id); continue; } @@ -169,6 +168,7 @@ const fetchDivergedCallRecordingCandidates = async ( video: { fileId: true }, createdAt: true, calendarEvent: { + startsAt: true, endsAt: true, }, }, @@ -197,6 +197,7 @@ const fetchDivergedCallRecordingCandidates = async ( audio: node.audio ?? undefined, video: node.video ?? undefined, createdAt: node.createdAt ?? undefined, + calendarEventStartsAt: node.calendarEvent?.startsAt ?? undefined, calendarEventEndsAt: node.calendarEvent?.endsAt ?? undefined, })); }; @@ -215,15 +216,13 @@ const isOutsideConvergenceBound = ( ); }; -// Inside the grace period the meeting may still be recording; webhooks own it. -const isPossiblyStillLive = ( +// Until the meeting starts the bot has recorded nothing, so there is nothing to pull yet. +const isBeforeMeetingStart = ( candidate: DivergedCallRecordingCandidate, - liveMeetingCutoff: Date, + now: Date, ): boolean => - candidate.status !== CallRecordingStatus.COMPLETED && - !isUndefined(candidate.calendarEventEndsAt) && - new Date(candidate.calendarEventEndsAt).getTime() > - liveMeetingCutoff.getTime(); + !isUndefined(candidate.calendarEventStartsAt) && + new Date(candidate.calendarEventStartsAt).getTime() > now.getTime(); const convergeCallRecording = async ({ client, From 298fd556bb4fbe39098b2db7e6fb3a9738ad9af0 Mon Sep 17 00:00:00 2001 From: ehconitin Date: Thu, 18 Jun 2026 17:42:43 +0530 Subject: [PATCH 2/4] Converge Recall transcript artifacts --- .../build-pending-transcript-marker.util.ts | 13 - .../converge-diverged-call-recordings.test.ts | 244 +++++++++++-- .../__tests__/handle-recall-webhook.test.ts | 80 ++-- .../reconcile-pending-transcripts.test.ts | 342 ------------------ .../converge-diverged-call-recordings.util.ts | 29 +- .../flows/handle-recall-webhook.util.ts | 44 +-- ...call-recording-transcript-artifact.util.ts | 220 +++++++++++ .../reconcile-pending-transcripts.util.ts | 224 ------------ .../flows/request-transcript.util.ts | 26 -- .../__tests__/recall-bot-api.test.ts | 171 +++++++++ .../create-async-recall-transcript.util.ts | 6 + .../list-recall-transcripts.util.ts | 166 +++++++++ .../recall-api/recall-bot-api-request.util.ts | 8 +- .../reconcile-stale-bot-state.ts | 20 - 14 files changed, 852 insertions(+), 741 deletions(-) delete mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-pending-transcript-marker.util.ts delete 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/reconcile-call-recording-transcript-artifact.util.ts delete mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-pending-transcripts.util.ts delete 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/list-recall-transcripts.util.ts 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 deleted file mode 100644 index ae65c4b55608d..0000000000000 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-pending-transcript-marker.util.ts +++ /dev/null @@ -1,13 +0,0 @@ -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/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 17a06ab4d7558..4cfca8f66f668 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,7 +4,9 @@ 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 listRecallTranscriptsMock = vi.hoisted(() => vi.fn()); const createAsyncRecallTranscriptMock = vi.hoisted(() => vi.fn()); +const downloadTranscriptMock = vi.hoisted(() => vi.fn()); const ingestCallRecordingMediaMock = vi.hoisted(() => vi.fn()); const chargeCompletedCallRecordingMock = vi.hoisted(() => vi.fn()); @@ -12,6 +14,10 @@ vi.mock('src/logic-functions/recall-api/get-recall-bot.util', () => ({ getRecallBot: getRecallBotMock, })); +vi.mock('src/logic-functions/recall-api/list-recall-transcripts.util', () => ({ + listRecallTranscripts: listRecallTranscriptsMock, +})); + vi.mock( 'src/logic-functions/recall-api/create-async-recall-transcript.util', () => ({ @@ -19,6 +25,10 @@ vi.mock( }), ); +vi.mock('src/logic-functions/flows/download-transcript.util', () => ({ + downloadTranscript: downloadTranscriptMock, +})); + vi.mock('src/logic-functions/flows/ingest-call-recording-media.util', () => ({ ingestCallRecordingMedia: ingestCallRecordingMediaMock, })); @@ -93,11 +103,18 @@ describe('convergeDivergedCallRecordings', () => { beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}); getRecallBotMock.mockReset(); + listRecallTranscriptsMock.mockReset(); + listRecallTranscriptsMock.mockResolvedValue({ + ok: true, + transcripts: [], + }); createAsyncRecallTranscriptMock.mockReset(); createAsyncRecallTranscriptMock.mockResolvedValue({ ok: true, transcriptId: 'recall-transcript-1', }); + downloadTranscriptMock.mockReset(); + downloadTranscriptMock.mockResolvedValue({ outcome: 'pending' }); ingestCallRecordingMediaMock.mockReset(); ingestCallRecordingMediaMock.mockResolvedValue({}); chargeCompletedCallRecordingMock.mockReset(); @@ -138,18 +155,14 @@ describe('convergeDivergedCallRecordings', () => { hasAudio: false, hasVideo: false, }); + expect(listRecallTranscriptsMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-1', + }); + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-1', + callRecordingId: 'call-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: { @@ -157,11 +170,6 @@ 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(), - }, }, }, ]); @@ -170,6 +178,7 @@ describe('convergeDivergedCallRecordings', () => { candidateCount: 1, updatedCallRecordingIds: ['call-recording-1'], markedFailedCallRecordingIds: [], + requestedTranscriptCallRecordingIds: ['call-recording-1'], unconvergeableCallRecordingIds: [], skippedNotStartedCallRecordingIds: [], }); @@ -211,6 +220,7 @@ describe('convergeDivergedCallRecordings', () => { }); expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(listRecallTranscriptsMock).not.toHaveBeenCalled(); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -233,6 +243,7 @@ describe('convergeDivergedCallRecordings', () => { candidateCount: 1, updatedCallRecordingIds: ['call-recording-1'], markedFailedCallRecordingIds: [], + requestedTranscriptCallRecordingIds: [], unconvergeableCallRecordingIds: [], skippedNotStartedCallRecordingIds: [], }); @@ -444,7 +455,7 @@ describe('convergeDivergedCallRecordings', () => { }), ]); - await convergeDivergedCallRecordings({ + const result = await convergeDivergedCallRecordings({ client: client as unknown as CoreApiClient, now: NOW, }); @@ -452,32 +463,215 @@ describe('convergeDivergedCallRecordings', () => { expect(createAsyncRecallTranscriptMock).toHaveBeenCalledTimes(1); expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ externalRecordingId: 'recall-recording-1', + callRecordingId: 'call-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', + endedAt: '2026-06-09T14:00:00.000Z', }, }, + ]); + expect(result.requestedTranscriptCallRecordingIds).toEqual([ + 'call-recording-1', + ]); + }); + + it('does not create a duplicate transcript when Recall already has one processing', 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', + }, + ], + }, + }); + listRecallTranscriptsMock.mockResolvedValue({ + ok: true, + transcripts: [ + { + id: 'recall-transcript-1', + createdAt: '2026-06-09T14:06:00.000Z', + downloadUrl: undefined, + provider: 'recallai_async', + statusCode: 'processing', + statusSubCode: undefined, + }, + ], + }); + const client = buildClient([buildStuckRecordingNode()]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(downloadTranscriptMock).not.toHaveBeenCalled(); + expect(client.mutations).toEqual([ { id: 'call-recording-1', data: { + status: 'PROCESSING', + startedAt: '2026-06-09T13:02:00.000Z', endedAt: '2026-06-09T14:00:00.000Z', externalRecordingId: 'recall-recording-1', + }, + }, + ]); + expect(result.requestedTranscriptCallRecordingIds).toEqual([]); + }); + + it('fills a completed Recall transcript artifact during convergence', async () => { + const transcriptContent = [ + { + participant: { id: 1, name: 'Ada' }, + words: [{ text: 'hello', start_timestamp: 1, end_timestamp: 2 }], + }, + ]; + + 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', + }, + ], + }, + }); + listRecallTranscriptsMock.mockResolvedValue({ + ok: true, + transcripts: [ + { + id: 'recall-transcript-1', + createdAt: '2026-06-09T14:06:00.000Z', + downloadUrl: 'https://recall-transcripts.example.com/transcript', + provider: 'recallai_async', + statusCode: 'done', + statusSubCode: undefined, + }, + ], + }); + downloadTranscriptMock.mockResolvedValue({ + outcome: 'filled', + content: transcriptContent, + }); + const client = buildClient([ + buildStuckRecordingNode({ + status: 'PROCESSING', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + externalRecordingId: 'recall-recording-1', + transcript: { + recallTranscriptId: 'legacy-pending-transcript', + status: 'PENDING', + requestedAt: '2026-06-09T14:05:30.000Z', + }, + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + }), + ]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(downloadTranscriptMock).toHaveBeenCalledWith({ + transcriptId: 'recall-transcript-1', + }); + 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-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + }); + expect(result.requestedTranscriptCallRecordingIds).toEqual([]); + }); + + it('marks the call recording failed when Recall has a failed transcript artifact', 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', + }, + ], + }, + }); + listRecallTranscriptsMock.mockResolvedValue({ + ok: true, + transcripts: [ + { + id: 'recall-transcript-1', + createdAt: '2026-06-09T14:06:00.000Z', + downloadUrl: undefined, + provider: 'recallai_async', + statusCode: 'failed', + statusSubCode: 'audio_missing', + }, + ], + }); + const client = buildClient([ + buildStuckRecordingNode({ + status: 'PROCESSING', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + externalRecordingId: 'recall-recording-1', + }), + ]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(downloadTranscriptMock).not.toHaveBeenCalled(); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'FAILED_UNKNOWN', transcript: { recallTranscriptId: 'recall-transcript-1', - status: 'PENDING', - requestedAt: NOW.toISOString(), + status: 'FAILED', + subCode: 'audio_missing', }, }, }, ]); + expect(result.requestedTranscriptCallRecordingIds).toEqual([]); }); it('does not mutate a record the bot state agrees with', async () => { 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 4f536dfe146e4..fb03c55e1bfd4 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 @@ -4,6 +4,7 @@ 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 listRecallTranscriptsMock = vi.hoisted(() => vi.fn()); const createAsyncRecallTranscriptMock = vi.hoisted(() => vi.fn()); const retrieveRecallTranscriptMock = vi.hoisted(() => vi.fn()); const ingestCallRecordingMediaMock = vi.hoisted(() => vi.fn()); @@ -13,6 +14,10 @@ vi.mock('src/logic-functions/recall-api/get-recall-bot.util', () => ({ getRecallBot: getRecallBotMock, })); +vi.mock('src/logic-functions/recall-api/list-recall-transcripts.util', () => ({ + listRecallTranscripts: listRecallTranscriptsMock, +})); + vi.mock( 'src/logic-functions/recall-api/create-async-recall-transcript.util', () => ({ @@ -128,6 +133,11 @@ describe('handleRecallWebhook', () => { status: null, errorMessage: 'bot fetch disabled in test', }); + listRecallTranscriptsMock.mockReset(); + listRecallTranscriptsMock.mockResolvedValue({ + ok: true, + transcripts: [], + }); createAsyncRecallTranscriptMock.mockReset(); createAsyncRecallTranscriptMock.mockResolvedValue({ ok: false, @@ -731,36 +741,34 @@ describe('handleRecallWebhook', () => { expect(createAsyncRecallTranscriptMock).toHaveBeenCalledTimes(1); expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ externalRecordingId: 'recall-recording-1', + callRecordingId: 'call-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 () => { + listRecallTranscriptsMock.mockResolvedValue({ + ok: true, + transcripts: [ + { + id: 'recall-transcript-1', + createdAt: '2026-01-01T14:06:00.000Z', + downloadUrl: undefined, + provider: 'recallai_async', + statusCode: 'processing', + statusSubCode: undefined, + }, + ], + }); const client = new FakeCoreApiClient([ { id: 'call-recording-1', @@ -789,6 +797,9 @@ describe('handleRecallWebhook', () => { }); expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(listRecallTranscriptsMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-1', + }); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -844,30 +855,15 @@ describe('handleRecallWebhook', () => { }); expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ externalRecordingId: 'recall-recording-9', + callRecordingId: 'call-recording-1', }); 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), - }, }, }, ]); @@ -971,29 +967,17 @@ describe('handleRecallWebhook', () => { }, }); + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-1', + callRecordingId: 'call-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' }], }, }, 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 deleted file mode 100644 index b2fa642417798..0000000000000 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-pending-transcripts.test.ts +++ /dev/null @@ -1,342 +0,0 @@ -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/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 ac98c7da13f26..38ae2b5124200 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,4 +1,4 @@ -import { isNonEmptyArray, isNull, isUndefined } from '@sniptt/guards'; +import { isNonEmptyArray, isUndefined } from '@sniptt/guards'; import { CoreApiClient } from 'twenty-client-sdk/core'; import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status'; @@ -18,7 +18,7 @@ import { ingestCallRecordingMedia } from 'src/logic-functions/flows/ingest-call- 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 { reconcileCallRecordingTranscriptArtifact } from 'src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util'; import { shouldCompleteCallRecordingIngestion } from 'src/logic-functions/domain/should-complete-call-recording-ingestion.util'; import { updateCallRecording, @@ -67,6 +67,7 @@ export type ConvergeDivergedCallRecordingsResult = { candidateCount: number; updatedCallRecordingIds: string[]; markedFailedCallRecordingIds: string[]; + requestedTranscriptCallRecordingIds: string[]; unconvergeableCallRecordingIds: string[]; skippedNotStartedCallRecordingIds: string[]; }; @@ -87,6 +88,7 @@ export const convergeDivergedCallRecordings = async ({ candidateCount: candidates.length, updatedCallRecordingIds: [], markedFailedCallRecordingIds: [], + requestedTranscriptCallRecordingIds: [], unconvergeableCallRecordingIds: [], skippedNotStartedCallRecordingIds: [], }; @@ -117,7 +119,6 @@ export const convergeDivergedCallRecordings = async ({ client, candidate, externalBotId: candidate.externalBotId, - now, result, }); } @@ -228,13 +229,11 @@ const convergeCallRecording = async ({ client, candidate, externalBotId, - now, result, }: { client: CoreApiClient; candidate: DivergedCallRecordingCandidate; externalBotId: string; - now: Date; result: ConvergeDivergedCallRecordingsResult; }): Promise => { const botResult = await getRecallBot({ externalBotId }); @@ -265,20 +264,18 @@ const convergeCallRecording = async ({ candidate.externalRecordingId ?? convergence.externalRecordingId; if (convergence.isRecallRecordingDone && !isUndefined(externalRecordingId)) { - if (isUndefined(candidate.transcript)) { - const transcriptMarker = await requestTranscript({ + const transcriptArtifactResult = + await reconcileCallRecordingTranscriptArtifact({ + callRecordingId: candidate.id, + currentStatus: candidate.status, externalRecordingId, - requestedAt: now.toISOString(), + transcript: candidate.transcript, }); - if (!isNull(transcriptMarker)) { - updateData.transcript = transcriptMarker; - updateData.externalRecordingId = externalRecordingId; - await updateCallRecording(client, { - id: candidate.id, - data: { transcript: transcriptMarker, externalRecordingId }, - }); - } + Object.assign(updateData, transcriptArtifactResult.updateData); + + if (transcriptArtifactResult.requestedTranscript) { + result.requestedTranscriptCallRecordingIds.push(candidate.id); } Object.assign( 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 85ea0751c8524..7d41e0d643683 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 @@ -19,7 +19,7 @@ import { } from 'src/logic-functions/recall-api/parse-recall-webhook-event.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 { requestTranscript } from 'src/logic-functions/flows/request-transcript.util'; +import { reconcileCallRecordingTranscriptArtifact } from 'src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util'; import { updateCallRecording, type CallRecordingUpdateFields, @@ -138,20 +138,13 @@ const handleRecallStatusEvent = async ({ }; if (isRecallRecordingDoneSignal({ event, statusCode })) { - if (isTranscriptUnset(callRecording)) { - const transcriptRequestUpdate = await buildTranscriptRequestUpdate({ + Object.assign( + updateData, + await buildTranscriptArtifactUpdate({ callRecording, webhookEvent, - }); - - if (Object.keys(transcriptRequestUpdate).length > 0) { - await updateCallRecording(client, { - id: callRecording.id, - data: transcriptRequestUpdate, - }); - Object.assign(updateData, transcriptRequestUpdate); - } - } + }), + ); Object.assign( updateData, @@ -335,7 +328,7 @@ const buildMediaIngestionUpdate = async ({ }); }; -const buildTranscriptRequestUpdate = async ({ +const buildTranscriptArtifactUpdate = async ({ callRecording, webhookEvent, }: { @@ -349,24 +342,25 @@ const buildTranscriptRequestUpdate = async ({ if (isUndefined(externalRecordingId)) { console.warn( - `[twenty-meeting-bot] cannot request transcript for call recording ${callRecording.id}: no Recall recording id available`, + `[twenty-meeting-bot] cannot reconcile 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 {}; - } + const transcriptArtifactResult = + await reconcileCallRecordingTranscriptArtifact({ + callRecordingId: callRecording.id, + currentStatus: callRecording.status, + externalRecordingId, + transcript: callRecording.transcript, + }); return { - transcript: transcriptMarker, - externalRecordingId, + ...(isUndefined(callRecording.externalRecordingId) + ? { externalRecordingId } + : {}), + ...transcriptArtifactResult.updateData, }; }; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts new file mode 100644 index 0000000000000..c0ec71fe7e536 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts @@ -0,0 +1,220 @@ +import { isNull, isUndefined } from '@sniptt/guards'; + +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; +import { type CallRecordingUpdateFields } from 'src/logic-functions/data/update-call-recording.util'; +import { buildFailedTranscriptMarker } from 'src/logic-functions/domain/build-failed-transcript-marker.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 { createAsyncRecallTranscript } from 'src/logic-functions/recall-api/create-async-recall-transcript.util'; +import { + listRecallTranscripts, + type RecallTranscriptSummary, +} from 'src/logic-functions/recall-api/list-recall-transcripts.util'; +import { downloadTranscript } from 'src/logic-functions/flows/download-transcript.util'; + +type CallRecordingTranscriptArtifactUpdateFields = Pick< + CallRecordingUpdateFields, + 'status' | 'transcript' +>; + +export type ReconcileCallRecordingTranscriptArtifactResult = { + updateData: CallRecordingTranscriptArtifactUpdateFields; + requestedTranscript: boolean; +}; + +export const reconcileCallRecordingTranscriptArtifact = async ({ + callRecordingId, + currentStatus, + externalRecordingId, + transcript, +}: { + callRecordingId: string; + currentStatus: string | undefined; + externalRecordingId: string; + transcript: unknown; +}): Promise => { + const existingTranscriptMarker = parseTranscriptMarker(transcript); + + if ( + !isNull(transcript) && + !isUndefined(transcript) && + isUndefined(existingTranscriptMarker) + ) { + return buildEmptyTranscriptArtifactResult(); + } + + if (existingTranscriptMarker?.status === 'FAILED') { + return buildEmptyTranscriptArtifactResult(); + } + + const listResult = await listRecallTranscripts({ externalRecordingId }); + + if (!listResult.ok) { + console.warn( + `[twenty-meeting-bot] failed to list Recall transcripts for recording ${externalRecordingId}: ${listResult.errorMessage}`, + ); + + return buildEmptyTranscriptArtifactResult(); + } + + const transcriptArtifact = selectRecallTranscriptArtifact( + listResult.transcripts, + ); + + if (isUndefined(transcriptArtifact)) { + const createResult = await createAsyncRecallTranscript({ + externalRecordingId, + callRecordingId, + }); + + if (!createResult.ok) { + console.warn( + `[twenty-meeting-bot] failed to request transcript for Recall recording ${externalRecordingId}: ${createResult.errorMessage}`, + ); + + return buildEmptyTranscriptArtifactResult(); + } + + return { updateData: {}, requestedTranscript: true }; + } + + if ( + transcriptArtifact.statusCode === 'failed' || + transcriptArtifact.statusCode === 'error' + ) { + return { + updateData: buildTranscriptFailureUpdate({ + currentStatus, + transcriptId: transcriptArtifact.id, + subCode: transcriptArtifact.statusSubCode ?? null, + }), + requestedTranscript: false, + }; + } + + if (transcriptArtifact.statusCode !== 'done') { + return buildEmptyTranscriptArtifactResult(); + } + + const downloadResult = await downloadTranscript({ + transcriptId: transcriptArtifact.id, + }); + + if (downloadResult.outcome === 'filled') { + return { + updateData: { + transcript: downloadResult.content as Record, + }, + requestedTranscript: false, + }; + } + + if (downloadResult.outcome === 'failed') { + return { + updateData: buildTranscriptFailureUpdate({ + currentStatus, + transcriptId: transcriptArtifact.id, + subCode: downloadResult.subCode, + }), + requestedTranscript: false, + }; + } + + if (downloadResult.outcome === 'error') { + console.warn( + `[twenty-meeting-bot] could not fill transcript for call recording ${callRecordingId}: ${downloadResult.errorMessage}`, + ); + } + + return buildEmptyTranscriptArtifactResult(); +}; + +const buildEmptyTranscriptArtifactResult = + (): ReconcileCallRecordingTranscriptArtifactResult => ({ + updateData: {}, + requestedTranscript: false, + }); + +const selectRecallTranscriptArtifact = ( + transcripts: RecallTranscriptSummary[], +): RecallTranscriptSummary | undefined => + transcripts + .filter((transcript) => transcript.statusCode !== 'deleted') + .sort(compareRecallTranscriptArtifacts)[0]; + +const compareRecallTranscriptArtifacts = ( + firstTranscript: RecallTranscriptSummary, + secondTranscript: RecallTranscriptSummary, +): number => { + const firstProviderPriority = getTranscriptProviderPriority(firstTranscript); + const secondProviderPriority = getTranscriptProviderPriority(secondTranscript); + + if (firstProviderPriority !== secondProviderPriority) { + return firstProviderPriority - secondProviderPriority; + } + + const firstStatusPriority = getTranscriptStatusPriority(firstTranscript); + const secondStatusPriority = getTranscriptStatusPriority(secondTranscript); + + if (firstStatusPriority !== secondStatusPriority) { + return firstStatusPriority - secondStatusPriority; + } + + return ( + getTranscriptCreatedAtTime(secondTranscript) - + getTranscriptCreatedAtTime(firstTranscript) + ); +}; + +const getTranscriptProviderPriority = ({ + provider, +}: RecallTranscriptSummary): number => (provider === 'recallai_async' ? 0 : 1); + +const getTranscriptStatusPriority = ({ + statusCode, +}: RecallTranscriptSummary): number => { + switch (statusCode) { + case 'done': + return 0; + case 'processing': + return 1; + case 'failed': + case 'error': + return 2; + default: + return 3; + } +}; + +const getTranscriptCreatedAtTime = ({ + createdAt, +}: RecallTranscriptSummary): number => { + if (isUndefined(createdAt)) { + return 0; + } + + const createdAtTime = new Date(createdAt).getTime(); + + return Number.isNaN(createdAtTime) ? 0 : createdAtTime; +}; + +const buildTranscriptFailureUpdate = ({ + currentStatus, + transcriptId, + subCode, +}: { + currentStatus: string | undefined; + transcriptId: string; + subCode: string | null; +}): CallRecordingTranscriptArtifactUpdateFields => ({ + transcript: buildFailedTranscriptMarker({ + recallTranscriptId: transcriptId, + subCode, + }), + ...(isCallRecordingStatusDowngrade({ + fromStatus: currentStatus, + toStatus: CallRecordingStatus.FAILED_UNKNOWN, + }) + ? {} + : { status: CallRecordingStatus.FAILED_UNKNOWN }), +}); 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 deleted file mode 100644 index 3d2e738204320..0000000000000 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-pending-transcripts.util.ts +++ /dev/null @@ -1,224 +0,0 @@ -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 deleted file mode 100644 index 18dc1d5289cdf..0000000000000 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/request-transcript.util.ts +++ /dev/null @@ -1,26 +0,0 @@ -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__/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 4e7a72324ab3c..4cbea87c96914 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 @@ -4,6 +4,7 @@ import { cancelRecallBot } from 'src/logic-functions/recall-api/cancel-recall-bo 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 { listRecallTranscripts } from 'src/logic-functions/recall-api/list-recall-transcripts.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'; @@ -354,6 +355,136 @@ describe('recall bot api', () => { }); }); + it('lists transcripts for a recording id and normalizes Recall artifact fields', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + next: null, + results: [ + { + id: 'recall-transcript-id', + created_at: '2026-01-01T13:05:00.000Z', + provider: { recallai_async: { language_code: 'auto' } }, + status: { code: 'done', sub_code: null }, + data: { + download_url: + 'https://recall-transcripts.example.com/transcript', + }, + }, + ], + }), + }); + + const result = await listRecallTranscripts({ + externalRecordingId: 'recall-recording-id', + }); + + expect(result).toEqual({ + ok: true, + transcripts: [ + { + id: 'recall-transcript-id', + createdAt: '2026-01-01T13:05:00.000Z', + downloadUrl: 'https://recall-transcripts.example.com/transcript', + provider: 'recallai_async', + statusCode: 'done', + statusSubCode: undefined, + }, + ], + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://ap-northeast-1.recall.ai/api/v1/transcript/?recording_id=recall-recording-id', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('follows transcript list pagination within the configured Recall region', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + next: 'https://ap-northeast-1.recall.ai/api/v1/transcript/?cursor=page-2', + results: [ + { + id: 'recall-transcript-id-1', + provider: { recallai_async: {} }, + status: { code: 'processing' }, + data: {}, + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + next: null, + results: [ + { + id: 'recall-transcript-id-2', + provider: { assembly_ai_async: {} }, + status: { code: 'failed', sub_code: 'audio_missing' }, + data: {}, + }, + ], + }), + }); + + const result = await listRecallTranscripts({ + externalRecordingId: 'recall-recording-id', + }); + + expect(result).toEqual({ + ok: true, + transcripts: [ + { + id: 'recall-transcript-id-1', + createdAt: undefined, + downloadUrl: undefined, + provider: 'recallai_async', + statusCode: 'processing', + statusSubCode: undefined, + }, + { + id: 'recall-transcript-id-2', + createdAt: undefined, + downloadUrl: undefined, + provider: 'assembly_ai_async', + statusCode: 'failed', + statusSubCode: 'audio_missing', + }, + ], + }); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://ap-northeast-1.recall.ai/api/v1/transcript/?cursor=page-2', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('rejects malformed transcript lists', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + next: null, + results: [{}], + }), + }); + + const result = await listRecallTranscripts({ + externalRecordingId: 'recall-recording-id', + }); + + expect(result).toEqual({ + ok: false, + status: 200, + errorMessage: 'Recall API returned malformed transcript list', + }); + }); + it('creates an async transcript with the locked provider settings', async () => { fetchMock.mockResolvedValue({ ok: true, @@ -376,6 +507,46 @@ describe('recall bot api', () => { }); }); + it('adds call recording metadata when convergence creates an async transcript', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ id: 'recall-transcript-id' }), + }); + + const result = await createAsyncRecallTranscript({ + externalRecordingId: 'recall-recording-id', + callRecordingId: 'call-recording-id', + }); + + expect(result).toEqual({ ok: true, transcriptId: 'recall-transcript-id' }); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ + provider: { recallai_async: { language_code: 'auto' } }, + diarization: { use_separate_streams_when_available: true }, + metadata: { twentyCallRecordingId: 'call-recording-id' }, + }); + }); + + it('does not retry async transcript creation failures', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 503, + json: async () => ({ detail: 'service unavailable' }), + }); + + const result = await createAsyncRecallTranscript({ + externalRecordingId: 'recall-recording-id', + }); + + expect(result).toEqual({ + ok: false, + status: 503, + errorMessage: + 'Recall API responded with HTTP 503: {"detail":"service unavailable"}', + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it('fails when the transcript creation response has no id', async () => { fetchMock.mockResolvedValue({ ok: true, 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 index 3802448d11d6c..c52ed514ee741 100644 --- 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 @@ -10,8 +10,10 @@ type CreateAsyncRecallTranscriptResult = export const createAsyncRecallTranscript = async ({ externalRecordingId, + callRecordingId, }: { externalRecordingId: string; + callRecordingId?: string; }): Promise => { const configResult = getRecallApiConfig(); @@ -26,7 +28,11 @@ export const createAsyncRecallTranscript = async ({ body: { provider: { recallai_async: { language_code: 'auto' } }, diarization: { use_separate_streams_when_available: true }, + ...(callRecordingId === undefined + ? {} + : { metadata: { twentyCallRecordingId: callRecordingId } }), }, + maxAttempts: 1, }); if (!result.ok) { diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/list-recall-transcripts.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/list-recall-transcripts.util.ts new file mode 100644 index 0000000000000..969b5706a0cbd --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/list-recall-transcripts.util.ts @@ -0,0 +1,166 @@ +import { isArray, 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 { getString } from 'src/logic-functions/utils/get-string.util'; +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'; + +export type RecallTranscriptSummary = { + id: string; + createdAt: string | undefined; + downloadUrl: string | undefined; + provider: string | undefined; + statusCode: string | undefined; + statusSubCode: string | undefined; +}; + +type ListRecallTranscriptsResult = + | { ok: true; transcripts: RecallTranscriptSummary[] } + | RecallBotOperationFailure; + +type RecallTranscriptListResponse = { + next?: unknown; + results?: unknown; +}; + +const RECALL_TRANSCRIPT_LIST_MAX_PAGES = 10; + +export const listRecallTranscripts = async ({ + externalRecordingId, +}: { + externalRecordingId: string; +}): Promise => { + const configResult = getRecallApiConfig(); + + if (!configResult.success) { + return { ok: false, status: null, errorMessage: configResult.error }; + } + + const transcripts: RecallTranscriptSummary[] = []; + let path: string | undefined = buildListRecallTranscriptsPath({ + externalRecordingId, + }); + + for ( + let pageIndex = 0; + !isUndefined(path) && pageIndex < RECALL_TRANSCRIPT_LIST_MAX_PAGES; + pageIndex++ + ) { + const result = await recallBotApiRequest({ + config: configResult.config, + path, + method: 'GET', + }); + + if (!result.ok) { + return result; + } + + const pageTranscripts = extractRecallTranscriptSummaries(result.data); + + if (isUndefined(pageTranscripts)) { + return { + ok: false, + status: result.status, + errorMessage: 'Recall API returned malformed transcript list', + }; + } + + transcripts.push(...pageTranscripts); + path = extractNextPath(result.data, configResult.config.baseUrl); + } + + if (!isUndefined(path)) { + return { + ok: false, + status: null, + errorMessage: `Recall transcript list exceeded ${RECALL_TRANSCRIPT_LIST_MAX_PAGES} pages`, + }; + } + + return { ok: true, transcripts }; +}; + +const buildListRecallTranscriptsPath = ({ + externalRecordingId, +}: { + externalRecordingId: string; +}): string => { + const searchParams = new URLSearchParams({ + recording_id: externalRecordingId, + }); + + return `/transcript/?${searchParams.toString()}`; +}; + +const extractRecallTranscriptSummaries = ( + response: RecallTranscriptListResponse | undefined, +): RecallTranscriptSummary[] | undefined => { + if (!isArray(response?.results)) { + return undefined; + } + + const transcripts: RecallTranscriptSummary[] = []; + + for (const result of response.results) { + const transcript = extractRecallTranscriptSummary(result); + + if (isUndefined(transcript)) { + return undefined; + } + + transcripts.push(transcript); + } + + return transcripts; +}; + +const extractRecallTranscriptSummary = ( + transcript: unknown, +): RecallTranscriptSummary | undefined => { + const transcriptRecord = asRecord(transcript); + const transcriptId = getString(transcriptRecord?.id); + + if (isUndefined(transcriptRecord) || isUndefined(transcriptId)) { + return undefined; + } + + const data = asRecord(transcriptRecord.data); + const status = asRecord(transcriptRecord.status); + + return { + id: transcriptId, + createdAt: getString(transcriptRecord.created_at), + downloadUrl: getString(data?.download_url), + provider: extractTranscriptProvider(transcriptRecord.provider), + statusCode: getString(status?.code), + statusSubCode: getString(status?.sub_code), + }; +}; + +const extractTranscriptProvider = (provider: unknown): string | undefined => { + const providerRecord = asRecord(provider); + + if (isUndefined(providerRecord)) { + return undefined; + } + + return Object.entries(providerRecord).find( + ([, providerConfiguration]) => + !isUndefined(providerConfiguration) && providerConfiguration !== null, + )?.[0]; +}; + +const extractNextPath = ( + response: RecallTranscriptListResponse | undefined, + baseUrl: string, +): string | undefined => { + const nextPage = getString(response?.next); + + if (isUndefined(nextPage) || !nextPage.startsWith(baseUrl)) { + return undefined; + } + + return nextPage.slice(baseUrl.length); +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/recall-bot-api-request.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/recall-bot-api-request.util.ts index 07b842ba20f28..62374f7f4bf2d 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/recall-bot-api-request.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/recall-bot-api-request.util.ts @@ -10,6 +10,7 @@ type RecallBotApiRequestArgs = { method: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: unknown; allowNotFound?: boolean; + maxAttempts?: number; }; type RecallBotApiRequestResult = @@ -24,15 +25,18 @@ type RecallBotApiRequestResult = errorMessage: string; }; -// Retried creates can duplicate bots; duplicates stay unclaimed and get reaped. +// Bot creates tolerate retries because duplicates stay unclaimed and get reaped. +// Callers that cannot retry idempotently can lower maxAttempts. export const recallBotApiRequest = async ( requestArgs: RecallBotApiRequestArgs, ): Promise> => { + const maxAttempts = requestArgs.maxAttempts ?? RECALL_API_MAX_ATTEMPTS; + for (let attemptNumber = 1; ; attemptNumber++) { const { result, isRetryable } = await performRecallBotApiRequestAttempt(requestArgs); - if (!isRetryable || attemptNumber >= RECALL_API_MAX_ATTEMPTS) { + if (!isRetryable || attemptNumber >= maxAttempts) { return result; } 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 769d2a4438893..54d8ee83d7327 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,10 +15,6 @@ 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; @@ -42,16 +38,11 @@ export const reconcileStaleBotStateHandler = async (): Promise => { client, now, ); - const pendingTranscriptResult = await reconcilePendingTranscriptsSafely( - client, - now, - ); return { botlessHealResult, orphanedBotReapingResult, statusConvergenceResult, - pendingTranscriptResult, }; }; @@ -96,17 +87,6 @@ const convergeDivergedCallRecordingsSafely = async ( } }; -const reconcilePendingTranscriptsSafely = async ( - client: CoreApiClient, - now: Date, -): Promise => { - try { - return await reconcilePendingTranscripts({ client, now }); - } catch (error) { - return buildStepFailure('pending transcript reconciliation', error); - } -}; - const buildStepFailure = (stepLabel: string, error: unknown): StepFailure => { const errorMessage = error instanceof Error ? error.message : String(error); From ac777ead6903aa98d4e8665a21ee16d073dbd49d Mon Sep 17 00:00:00 2001 From: ehconitin Date: Thu, 18 Jun 2026 18:52:53 +0530 Subject: [PATCH 3/4] Simplify Recall transcript artifact selection --- .../converge-diverged-call-recordings.test.ts | 9 --- ...call-recording-transcript-artifact.util.ts | 64 +------------------ .../__tests__/recall-bot-api.test.ts | 21 +----- .../list-recall-transcripts.util.ts | 17 ----- .../recall-transcript-summary.type.ts | 3 - 5 files changed, 3 insertions(+), 111 deletions(-) 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 e3cab1b65c2d3..3dbbbd7841725 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 @@ -504,9 +504,6 @@ describe('convergeDivergedCallRecordings', () => { transcripts: [ { id: 'recall-transcript-1', - createdAt: '2026-06-09T14:06:00.000Z', - downloadUrl: undefined, - provider: 'recallai_async', statusCode: 'processing', statusSubCode: undefined, }, @@ -563,9 +560,6 @@ describe('convergeDivergedCallRecordings', () => { transcripts: [ { id: 'recall-transcript-1', - createdAt: '2026-06-09T14:06:00.000Z', - downloadUrl: 'https://recall-transcripts.example.com/transcript', - provider: 'recallai_async', statusCode: 'done', statusSubCode: undefined, }, @@ -639,9 +633,6 @@ describe('convergeDivergedCallRecordings', () => { transcripts: [ { id: 'recall-transcript-1', - createdAt: '2026-06-09T14:06:00.000Z', - downloadUrl: undefined, - provider: 'recallai_async', statusCode: 'failed', statusSubCode: 'audio_missing', }, diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts index 669af5359d2e0..b41c451c0722d 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts @@ -6,9 +6,7 @@ import { buildPendingTranscriptMarker } from 'src/logic-functions/domain/build-p 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 { createAsyncRecallTranscript } from 'src/logic-functions/recall-api/create-async-recall-transcript.util'; -import { - listRecallTranscripts, -} from 'src/logic-functions/recall-api/list-recall-transcripts.util'; +import { listRecallTranscripts } from 'src/logic-functions/recall-api/list-recall-transcripts.util'; import { type RecallTranscriptSummary } from 'src/logic-functions/recall-api/recall-transcript-summary.type'; import { downloadTranscript } from 'src/logic-functions/flows/download-transcript.util'; import { type ReconcileCallRecordingTranscriptArtifactResult } from 'src/logic-functions/flows/reconcile-call-recording-transcript-artifact-result.type'; @@ -159,65 +157,7 @@ const buildEmptyTranscriptArtifactResult = const selectRecallTranscriptArtifact = ( transcripts: RecallTranscriptSummary[], ): RecallTranscriptSummary | undefined => - transcripts - .filter((transcript) => transcript.statusCode !== 'deleted') - .sort(compareRecallTranscriptArtifacts)[0]; - -const compareRecallTranscriptArtifacts = ( - firstTranscript: RecallTranscriptSummary, - secondTranscript: RecallTranscriptSummary, -): number => { - const firstProviderPriority = getTranscriptProviderPriority(firstTranscript); - const secondProviderPriority = getTranscriptProviderPriority(secondTranscript); - - if (firstProviderPriority !== secondProviderPriority) { - return firstProviderPriority - secondProviderPriority; - } - - const firstStatusPriority = getTranscriptStatusPriority(firstTranscript); - const secondStatusPriority = getTranscriptStatusPriority(secondTranscript); - - if (firstStatusPriority !== secondStatusPriority) { - return firstStatusPriority - secondStatusPriority; - } - - return ( - getTranscriptCreatedAtTime(secondTranscript) - - getTranscriptCreatedAtTime(firstTranscript) - ); -}; - -const getTranscriptProviderPriority = ({ - provider, -}: RecallTranscriptSummary): number => (provider === 'recallai_async' ? 0 : 1); - -const getTranscriptStatusPriority = ({ - statusCode, -}: RecallTranscriptSummary): number => { - switch (statusCode) { - case 'done': - return 0; - case 'processing': - return 1; - case 'failed': - case 'error': - return 2; - default: - return 3; - } -}; - -const getTranscriptCreatedAtTime = ({ - createdAt, -}: RecallTranscriptSummary): number => { - if (isUndefined(createdAt)) { - return 0; - } - - const createdAtTime = new Date(createdAt).getTime(); - - return Number.isNaN(createdAtTime) ? 0 : createdAtTime; -}; + transcripts.find((transcript) => transcript.statusCode !== 'deleted'); const buildTranscriptFailureUpdate = ({ currentStatus, 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 4cbea87c96914..0f279241ed57d 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 @@ -355,7 +355,7 @@ describe('recall bot api', () => { }); }); - it('lists transcripts for a recording id and normalizes Recall artifact fields', async () => { + it('lists transcripts for a recording id and normalizes status fields', async () => { fetchMock.mockResolvedValue({ ok: true, status: 200, @@ -364,13 +364,7 @@ describe('recall bot api', () => { results: [ { id: 'recall-transcript-id', - created_at: '2026-01-01T13:05:00.000Z', - provider: { recallai_async: { language_code: 'auto' } }, status: { code: 'done', sub_code: null }, - data: { - download_url: - 'https://recall-transcripts.example.com/transcript', - }, }, ], }), @@ -385,9 +379,6 @@ describe('recall bot api', () => { transcripts: [ { id: 'recall-transcript-id', - createdAt: '2026-01-01T13:05:00.000Z', - downloadUrl: 'https://recall-transcripts.example.com/transcript', - provider: 'recallai_async', statusCode: 'done', statusSubCode: undefined, }, @@ -409,9 +400,7 @@ describe('recall bot api', () => { results: [ { id: 'recall-transcript-id-1', - provider: { recallai_async: {} }, status: { code: 'processing' }, - data: {}, }, ], }), @@ -424,9 +413,7 @@ describe('recall bot api', () => { results: [ { id: 'recall-transcript-id-2', - provider: { assembly_ai_async: {} }, status: { code: 'failed', sub_code: 'audio_missing' }, - data: {}, }, ], }), @@ -441,17 +428,11 @@ describe('recall bot api', () => { transcripts: [ { id: 'recall-transcript-id-1', - createdAt: undefined, - downloadUrl: undefined, - provider: 'recallai_async', statusCode: 'processing', statusSubCode: undefined, }, { id: 'recall-transcript-id-2', - createdAt: undefined, - downloadUrl: undefined, - provider: 'assembly_ai_async', statusCode: 'failed', statusSubCode: 'audio_missing', }, diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/list-recall-transcripts.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/list-recall-transcripts.util.ts index 4ceb4d3405fe0..21c6d925f2104 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/list-recall-transcripts.util.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/list-recall-transcripts.util.ts @@ -118,32 +118,15 @@ const extractRecallTranscriptSummary = ( return undefined; } - const data = asRecord(transcriptRecord.data); const status = asRecord(transcriptRecord.status); return { id: transcriptId, - createdAt: getString(transcriptRecord.created_at), - downloadUrl: getString(data?.download_url), - provider: extractTranscriptProvider(transcriptRecord.provider), statusCode: getString(status?.code), statusSubCode: getString(status?.sub_code), }; }; -const extractTranscriptProvider = (provider: unknown): string | undefined => { - const providerRecord = asRecord(provider); - - if (isUndefined(providerRecord)) { - return undefined; - } - - return Object.entries(providerRecord).find( - ([, providerConfiguration]) => - !isUndefined(providerConfiguration) && providerConfiguration !== null, - )?.[0]; -}; - const extractNextPath = ( response: RecallTranscriptListResponse | undefined, baseUrl: string, diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/recall-transcript-summary.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/recall-transcript-summary.type.ts index 2a315a3991077..32f2683c897d9 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/recall-transcript-summary.type.ts +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/recall-transcript-summary.type.ts @@ -1,8 +1,5 @@ export type RecallTranscriptSummary = { id: string; - createdAt: string | undefined; - downloadUrl: string | undefined; - provider: string | undefined; statusCode: string | undefined; statusSubCode: string | undefined; }; From 188a8a8634f5d331e5f92bc2a5ab56fe3abbfea1 Mon Sep 17 00:00:00 2001 From: ehconitin Date: Thu, 18 Jun 2026 18:59:14 +0530 Subject: [PATCH 4/4] version bump --- packages/twenty-apps/internal/twenty-meeting-bot/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/package.json b/packages/twenty-apps/internal/twenty-meeting-bot/package.json index 7e5bd9676747c..6d2af62411041 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/package.json +++ b/packages/twenty-apps/internal/twenty-meeting-bot/package.json @@ -1,6 +1,6 @@ { "name": "twenty-meeting-bot", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "engines": { "node": "^24.5.0",