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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-meeting-bot",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
|
||||
+274
-37
@@ -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 () => {
|
||||
|
||||
+31
-52
@@ -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();
|
||||
});
|
||||
|
||||
-342
@@ -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);
|
||||
});
|
||||
});
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export type ConvergeDivergedCallRecordingsResult = {
|
||||
candidateCount: number;
|
||||
updatedCallRecordingIds: string[];
|
||||
markedFailedCallRecordingIds: string[];
|
||||
requestedTranscriptCallRecordingIds: string[];
|
||||
unconvergeableCallRecordingIds: string[];
|
||||
skippedNotStartedCallRecordingIds: string[];
|
||||
};
|
||||
+25
-33
@@ -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(
|
||||
|
||||
+20
-25
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+11
@@ -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;
|
||||
};
|
||||
+181
@@ -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 }),
|
||||
});
|
||||
-224
@@ -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,
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
-26
@@ -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,
|
||||
});
|
||||
};
|
||||
+152
@@ -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,
|
||||
|
||||
+6
@@ -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) {
|
||||
|
||||
+141
@@ -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);
|
||||
};
|
||||
+6
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type RecallTranscriptSummary = {
|
||||
id: string;
|
||||
statusCode: string | undefined;
|
||||
statusSubCode: string | undefined;
|
||||
};
|
||||
+2
-22
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user