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", 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..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 @@ -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, })); @@ -82,7 +92,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, }); @@ -90,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(); @@ -135,39 +155,32 @@ 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([ - { + expect.objectContaining({ id: 'call-recording-1', - data: { - transcript: { - recallTranscriptId: 'recall-transcript-1', - status: 'PENDING', - requestedAt: NOW.toISOString(), - }, - externalRecordingId: 'recall-recording-1', - }, - }, - { - id: 'call-recording-1', - data: { + data: expect.objectContaining({ status: 'PROCESSING', 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'], markedFailedCallRecordingIds: [], + requestedTranscriptCallRecordingIds: ['call-recording-1'], unconvergeableCallRecordingIds: [], + skippedNotStartedCallRecordingIds: [], }); }); @@ -207,6 +220,7 @@ describe('convergeDivergedCallRecordings', () => { }); expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(listRecallTranscriptsMock).not.toHaveBeenCalled(); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -229,14 +243,19 @@ describe('convergeDivergedCallRecordings', () => { candidateCount: 1, updatedCallRecordingIds: ['call-recording-1'], markedFailedCallRecordingIds: [], + requestedTranscriptCallRecordingIds: [], 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 +266,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 () => { @@ -397,7 +455,7 @@ describe('convergeDivergedCallRecordings', () => { }), ]); - await convergeDivergedCallRecordings({ + const result = await convergeDivergedCallRecordings({ client: client as unknown as CoreApiClient, now: NOW, }); @@ -405,32 +463,211 @@ 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: { + endedAt: '2026-06-09T14:00:00.000Z', transcript: { recallTranscriptId: 'recall-transcript-1', status: 'PENDING', requestedAt: NOW.toISOString(), }, - externalRecordingId: 'recall-recording-1', }, }, + ]); + 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', + 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', + 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', + 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..430c20cf4b012 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,19 +741,9 @@ 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: { @@ -760,7 +760,7 @@ describe('handleRecallWebhook', () => { ]); }); - it('does not re-request a transcript on a redelivered done event', async () => { + it('does not re-request a transcript on a redelivered done event while Recall list is stale', async () => { const client = new FakeCoreApiClient([ { id: 'call-recording-1', @@ -789,6 +789,12 @@ describe('handleRecallWebhook', () => { }); expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(listRecallTranscriptsMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-1', + }); + expect(retrieveRecallTranscriptMock).toHaveBeenCalledWith({ + transcriptId: 'recall-transcript-1', + }); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -844,32 +850,17 @@ 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', - }, - }, - { + expect.objectContaining({ id: 'call-recording-1', - data: { + data: expect.objectContaining({ status: 'PROCESSING', externalBotId: 'recall-bot-1', externalRecordingId: 'recall-recording-9', - transcript: { - recallTranscriptId: 'recall-transcript-9', - status: 'PENDING', - requestedAt: expect.any(String), - }, - }, - }, + }), + }), ]); }); @@ -971,32 +962,20 @@ describe('handleRecallWebhook', () => { }, }); + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-1', + callRecordingId: 'call-recording-1', + }); expect(client.mutations).toEqual([ - { + expect.objectContaining({ 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: { + data: expect.objectContaining({ 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(); }); 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-result.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/converge-diverged-call-recordings-result.type.ts new file mode 100644 index 0000000000000..d76740720d778 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/converge-diverged-call-recordings-result.type.ts @@ -0,0 +1,8 @@ +export type ConvergeDivergedCallRecordingsResult = { + candidateCount: number; + updatedCallRecordingIds: string[]; + markedFailedCallRecordingIds: string[]; + requestedTranscriptCallRecordingIds: string[]; + unconvergeableCallRecordingIds: string[]; + skippedNotStartedCallRecordingIds: string[]; +}; 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..5f0576fd9c61b 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,8 @@ 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 { type ConvergeDivergedCallRecordingsResult } from 'src/logic-functions/flows/converge-diverged-call-recordings-result.type'; import { shouldCompleteCallRecordingIngestion } from 'src/logic-functions/domain/should-complete-call-recording-ingestion.util'; import { updateCallRecording, @@ -26,7 +27,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 +46,7 @@ type DivergedCallRecordingCandidate = { audio: FilesFieldValue | undefined; video: FilesFieldValue | undefined; createdAt: string | undefined; + calendarEventStartsAt: string | undefined; calendarEventEndsAt: string | undefined; }; @@ -60,14 +61,7 @@ type DivergedCallRecordingNode = { audio?: FilesFieldValue | null; video?: FilesFieldValue | null; createdAt?: string | null; - calendarEvent?: { endsAt?: string | null } | null; -}; - -export type ConvergeDivergedCallRecordingsResult = { - candidateCount: number; - updatedCallRecordingIds: string[]; - markedFailedCallRecordingIds: string[]; - unconvergeableCallRecordingIds: string[]; + calendarEvent?: { startsAt?: string | null; endsAt?: string | null } | null; }; // Webhook deliveries get lost; this pull pass re-derives state from Recall. @@ -82,15 +76,13 @@ 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: [], + requestedTranscriptCallRecordingIds: [], unconvergeableCallRecordingIds: [], + skippedNotStartedCallRecordingIds: [], }; for (const candidate of candidates) { @@ -110,7 +102,8 @@ export const convergeDivergedCallRecordings = async ({ continue; } - if (isPossiblyStillLive(candidate, liveMeetingCutoff)) { + if (isBeforeMeetingStart(candidate, now)) { + result.skippedNotStartedCallRecordingIds.push(candidate.id); continue; } @@ -169,6 +162,7 @@ const fetchDivergedCallRecordingCandidates = async ( video: { fileId: true }, createdAt: true, calendarEvent: { + startsAt: true, endsAt: true, }, }, @@ -197,6 +191,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 +210,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, @@ -266,20 +259,19 @@ 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..b75d0c6023ee4 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,26 @@ 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, + requestedAt: new Date().toISOString(), + 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-result.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact-result.type.ts new file mode 100644 index 0000000000000..15c4a279167ca --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact-result.type.ts @@ -0,0 +1,11 @@ +import { type CallRecordingUpdateFields } from 'src/logic-functions/data/update-call-recording.util'; + +type CallRecordingTranscriptArtifactUpdateFields = Pick< + CallRecordingUpdateFields, + 'status' | 'transcript' +>; + +export type ReconcileCallRecordingTranscriptArtifactResult = { + updateData: CallRecordingTranscriptArtifactUpdateFields; + requestedTranscript: boolean; +}; 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..b41c451c0722d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts @@ -0,0 +1,181 @@ +import { isNull, isUndefined } from '@sniptt/guards'; + +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; +import { buildFailedTranscriptMarker } from 'src/logic-functions/domain/build-failed-transcript-marker.util'; +import { buildPendingTranscriptMarker } from 'src/logic-functions/domain/build-pending-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 } 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'; + +type CallRecordingTranscriptArtifactUpdateFields = + ReconcileCallRecordingTranscriptArtifactResult['updateData']; + +export const reconcileCallRecordingTranscriptArtifact = async ({ + callRecordingId, + currentStatus, + externalRecordingId, + requestedAt, + transcript, +}: { + callRecordingId: string; + currentStatus: string | undefined; + externalRecordingId: string; + requestedAt: 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, + ); + const pendingTranscriptMarkerRecallTranscriptId = + existingTranscriptMarker?.status === 'PENDING' + ? (existingTranscriptMarker.recallTranscriptId ?? undefined) + : undefined; + const transcriptIdToDownload = + transcriptArtifact?.id ?? pendingTranscriptMarkerRecallTranscriptId; + + if ( + isUndefined(transcriptArtifact) && + isUndefined(pendingTranscriptMarkerRecallTranscriptId) + ) { + 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: { + transcript: buildPendingTranscriptMarker({ + recallTranscriptId: createResult.transcriptId, + requestedAt, + }), + }, + requestedTranscript: true, + }; + } + + if ( + !isUndefined(transcriptArtifact) && + (transcriptArtifact.statusCode === 'failed' || + transcriptArtifact.statusCode === 'error') + ) { + return { + updateData: buildTranscriptFailureUpdate({ + currentStatus, + transcriptId: transcriptArtifact.id, + subCode: transcriptArtifact.statusSubCode ?? null, + }), + requestedTranscript: false, + }; + } + + if ( + !isUndefined(transcriptArtifact) && + transcriptArtifact.statusCode !== 'done' + ) { + return buildEmptyTranscriptArtifactResult(); + } + + if (isUndefined(transcriptIdToDownload)) { + return buildEmptyTranscriptArtifactResult(); + } + + const downloadResult = await downloadTranscript({ + transcriptId: transcriptIdToDownload, + }); + + if (downloadResult.outcome === 'filled') { + return { + updateData: { + transcript: downloadResult.content as Record, + }, + requestedTranscript: false, + }; + } + + if (downloadResult.outcome === 'failed') { + return { + updateData: buildTranscriptFailureUpdate({ + currentStatus, + transcriptId: transcriptIdToDownload, + 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.find((transcript) => transcript.statusCode !== 'deleted'); + +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 d8d21dcc42f25..79951b16b7e1a 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'; @@ -323,6 +324,117 @@ describe('recall bot api', () => { }); }); + it('lists transcripts for a recording id and normalizes status fields', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + next: null, + results: [ + { + id: 'recall-transcript-id', + status: { code: 'done', sub_code: null }, + }, + ], + }), + }); + + const result = await listRecallTranscripts({ + externalRecordingId: 'recall-recording-id', + }); + + expect(result).toEqual({ + ok: true, + transcripts: [ + { + id: 'recall-transcript-id', + 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', + status: { code: 'processing' }, + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + next: null, + results: [ + { + id: 'recall-transcript-id-2', + status: { code: 'failed', sub_code: 'audio_missing' }, + }, + ], + }), + }); + + const result = await listRecallTranscripts({ + externalRecordingId: 'recall-recording-id', + }); + + expect(result).toEqual({ + ok: true, + transcripts: [ + { + id: 'recall-transcript-id-1', + statusCode: 'processing', + statusSubCode: undefined, + }, + { + id: 'recall-transcript-id-2', + 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, @@ -345,6 +457,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..21c6d925f2104 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/list-recall-transcripts.util.ts @@ -0,0 +1,141 @@ +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'; +import { type RecallTranscriptSummary } from 'src/logic-functions/recall-api/recall-transcript-summary.type'; + +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 status = asRecord(transcriptRecord.status); + + return { + id: transcriptId, + statusCode: getString(status?.code), + statusSubCode: getString(status?.sub_code), + }; +}; + +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/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 new file mode 100644 index 0000000000000..32f2683c897d9 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/recall-transcript-summary.type.ts @@ -0,0 +1,5 @@ +export type RecallTranscriptSummary = { + id: string; + statusCode: string | undefined; + statusSubCode: string | undefined; +}; 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 7215bbcb61db5..e83b36477d712 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 @@ -5,8 +5,8 @@ import { STALE_BOT_STATE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constan import { STALE_BOT_STATE_CRON_PATTERN } from 'src/logic-functions/constants/stale-bot-state-cron-pattern'; import { convergeDivergedCallRecordings, - type ConvergeDivergedCallRecordingsResult, } from 'src/logic-functions/flows/converge-diverged-call-recordings.util'; +import { type ConvergeDivergedCallRecordingsResult } from 'src/logic-functions/flows/converge-diverged-call-recordings-result.type'; import { healCallRecordingsMissingBot, type HealCallRecordingsMissingBotResult, @@ -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; @@ -26,7 +22,7 @@ const REAPER_JOIN_AT_LOOKAHEAD_HOURS = 24; type StepFailure = { error: string }; -export const reconcileStaleBotStateHandler = async (): Promise => { +const reconcileStaleBotStateHandler = async (): Promise => { const now = new Date(); const client = new CoreApiClient(); @@ -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);