From f1d4d6aeafa6a81cbeade960c6f8b9e163d1a752 Mon Sep 17 00:00:00 2001
From: nitin <142569587+ehconitin@users.noreply.github.com>
Date: Thu, 18 Jun 2026 19:04:25 +0530
Subject: [PATCH] Converge Recall transcript artifacts on meeting start
(#21788)
## what
- converge stuck call recordings after the meeting starts, not based on
scheduled end
- replace the pending-transcript cron with Recall transcript artifact
reconciliation
- list existing Recall transcripts before creating one, so we avoid
duplicate requests
- keep a local pending marker as an idempotency latch for
stale/redelivered events
---
.../internal/twenty-meeting-bot/package.json | 2 +-
.../converge-diverged-call-recordings.test.ts | 311 ++++++++++++++--
.../__tests__/handle-recall-webhook.test.ts | 83 ++---
.../reconcile-pending-transcripts.test.ts | 342 ------------------
...ge-diverged-call-recordings-result.type.ts | 8 +
.../converge-diverged-call-recordings.util.ts | 58 ++-
.../flows/handle-recall-webhook.util.ts | 45 +--
...cording-transcript-artifact-result.type.ts | 11 +
...call-recording-transcript-artifact.util.ts | 181 +++++++++
.../reconcile-pending-transcripts.util.ts | 224 ------------
.../flows/request-transcript.util.ts | 26 --
.../__tests__/recall-bot-api.test.ts | 152 ++++++++
.../create-async-recall-transcript.util.ts | 6 +
.../list-recall-transcripts.util.ts | 141 ++++++++
.../recall-api/recall-bot-api-request.util.ts | 8 +-
.../recall-transcript-summary.type.ts | 5 +
.../reconcile-stale-bot-state.ts | 24 +-
17 files changed, 863 insertions(+), 764 deletions(-)
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/converge-diverged-call-recordings-result.type.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact-result.type.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
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/recall-transcript-summary.type.ts
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/package.json b/packages/twenty-apps/internal/twenty-meeting-bot/package.json
index 7e5bd96767..6d2af62411 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 ad1caf3f03..3dbbbd7841 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,24 +463,13 @@ 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',
- },
- },
{
id: 'call-recording-1',
data: {
endedAt: '2026-06-09T14:00:00.000Z',
- externalRecordingId: 'recall-recording-1',
transcript: {
recallTranscriptId: 'recall-transcript-1',
status: 'PENDING',
@@ -431,6 +478,196 @@ describe('convergeDivergedCallRecordings', () => {
},
},
]);
+ 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: '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 4f536dfe14..430c20cf4b 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([
- {
+ expect.objectContaining({
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: {
+ 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 b2fa642417..0000000000
--- 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 0000000000..d76740720d
--- /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 e89f36a30e..5f0576fd9c 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 85ea0751c8..b75d0c6023 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 0000000000..15c4a27916
--- /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 0000000000..b41c451c07
--- /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 3d2e738204..0000000000
--- 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 18dc1d5289..0000000000
--- 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 d8d21dcc42..79951b16b7 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 3802448d11..c52ed514ee 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 0000000000..21c6d925f2
--- /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 07b842ba20..62374f7f4b 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 0000000000..32f2683c89
--- /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 7215bbcb61..e83b36477d 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