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



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21788?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
nitin
2026-06-18 19:04:25 +05:30
committed by GitHub
parent 454758471f
commit f1d4d6aeaf
17 changed files with 863 additions and 764 deletions
@@ -1,6 +1,6 @@
{
"name": "twenty-meeting-bot",
"version": "0.1.1",
"version": "0.1.2",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -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 () => {
@@ -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();
});
@@ -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<string, unknown> }> = [];
constructor(private callRecordingNodes: CallRecordingNode[]) {}
async query(_query: any): Promise<any> {
return {
callRecordings: {
pageInfo: { hasNextPage: false, endCursor: undefined },
edges: this.callRecordingNodes.map((node) => ({ node })),
},
};
}
async mutation(mutation: any): Promise<any> {
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);
});
});
@@ -0,0 +1,8 @@
export type ConvergeDivergedCallRecordingsResult = {
candidateCount: number;
updatedCallRecordingIds: string[];
markedFailedCallRecordingIds: string[];
requestedTranscriptCallRecordingIds: string[];
unconvergeableCallRecordingIds: string[];
skippedNotStartedCallRecordingIds: string[];
};
@@ -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(
@@ -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,
};
};
@@ -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;
};
@@ -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<ReconcileCallRecordingTranscriptArtifactResult> => {
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<string, unknown>,
},
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 }),
});
@@ -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<ReconcilePendingTranscriptsResult> => {
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<string, unknown>,
};
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<PendingTranscriptCallRecording[]> => {
const filter: Record<string, unknown> = {
transcript: { is: 'NOT_NULL' },
updatedAt: {
gte: new Date(
now.getTime() - PENDING_TRANSCRIPT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000,
).toISOString(),
},
};
const callRecordingNodes =
await fetchAllNodes<PendingTranscriptCallRecordingNode>(
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<PendingTranscriptCallRecordingNode>
| 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,
},
];
});
};
@@ -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<TranscriptMarker | null> => {
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,
});
};
@@ -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,
@@ -10,8 +10,10 @@ type CreateAsyncRecallTranscriptResult =
export const createAsyncRecallTranscript = async ({
externalRecordingId,
callRecordingId,
}: {
externalRecordingId: string;
callRecordingId?: string;
}): Promise<CreateAsyncRecallTranscriptResult> => {
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) {
@@ -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<ListRecallTranscriptsResult> => {
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<RecallTranscriptListResponse>({
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);
};
@@ -10,6 +10,7 @@ type RecallBotApiRequestArgs = {
method: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
allowNotFound?: boolean;
maxAttempts?: number;
};
type RecallBotApiRequestResult<TData> =
@@ -24,15 +25,18 @@ type RecallBotApiRequestResult<TData> =
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 <TData>(
requestArgs: RecallBotApiRequestArgs,
): Promise<RecallBotApiRequestResult<TData>> => {
const maxAttempts = requestArgs.maxAttempts ?? RECALL_API_MAX_ATTEMPTS;
for (let attemptNumber = 1; ; attemptNumber++) {
const { result, isRetryable } =
await performRecallBotApiRequestAttempt<TData>(requestArgs);
if (!isRetryable || attemptNumber >= RECALL_API_MAX_ATTEMPTS) {
if (!isRetryable || attemptNumber >= maxAttempts) {
return result;
}
@@ -0,0 +1,5 @@
export type RecallTranscriptSummary = {
id: string;
statusCode: string | undefined;
statusSubCode: string | undefined;
};
@@ -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<object> => {
const reconcileStaleBotStateHandler = async (): Promise<object> => {
const now = new Date();
const client = new CoreApiClient();
@@ -42,16 +38,11 @@ export const reconcileStaleBotStateHandler = async (): Promise<object> => {
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<ReconcilePendingTranscriptsResult | StepFailure> => {
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);