Add stale Recall bot reconciliation (#21720)

This PR adds a scheduled reconciliation pass for the Twenty Meeting Bot
app so call recording state does not depend only on event-driven updates
from calendar changes and Recall webhooks.
Why we need this -- 

- A call recording row can be created, but the process can fail before
the Recall bot id is written back.
- Recall webhooks can be missed or delivered late, leaving Twenty stuck
in an older local state.
- A bot can disappear from Recall, leaving Twenty with a stale
externalBotId.
- A cancellation can fail locally, leaving an app-managed Recall bot
that would still join the meeting.

What this adds -- 

- A cron logic function that heals botless scheduled call recordings.
- A convergence pass that pulls Recall bot state for stale local rows,
including SCHEDULED rows.
- Orphaned bot cleanup for app-managed Recall bots that are no longer
claimed by an open call recording.
- Guards so destructive bot cleanup does not affect bots claimed by
another app registration.
- Tests for the new stale-state, missed-webhook, and orphan cleanup
behavior.

Not included -- 

- Media ingestion.
- Transcript pipeline.
- Billing.
- Marking call recordings as COMPLETED.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21720?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-17 21:12:03 +05:30
committed by GitHub
parent 9c249c766b
commit d4e0b78d20
21 changed files with 2359 additions and 64 deletions
@@ -0,0 +1,2 @@
export const STALE_BOT_STATE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'e362aa9b-52c6-4b7e-bb20-927e0e8d7cbe';
@@ -0,0 +1 @@
export const STALE_BOT_STATE_CRON_PATTERN = '*/15 * * * *';
@@ -0,0 +1,14 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type';
import { findCallRecordingsByFilter } from 'src/logic-functions/data/find-call-recordings-by-filter.util';
export const findOpenScheduledCallRecordings = async (
client: CoreApiClient,
): Promise<CallRecordingRecord[]> =>
findCallRecordingsByFilter(client, {
recordingRequestStatus: { eq: CallRecordingRequestStatus.REQUESTED },
status: { eq: CallRecordingStatus.SCHEDULED },
});
@@ -0,0 +1,323 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
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());
vi.mock('src/logic-functions/recall-api/get-recall-bot.util', () => ({
getRecallBot: getRecallBotMock,
}));
const NOW = new Date('2026-06-10T12:00:00.000Z');
type CallRecordingNode = Record<string, unknown>;
class FakeCoreApiClient {
mutations: Array<{ id: string; data: Record<string, unknown> }> = [];
constructor(private callRecordingNodes: CallRecordingNode[]) {}
async query(query: any): Promise<any> {
const callRecordingFilter = query.callRecordings.__args.filter
.or[0] as Record<string, any>;
const requestedRecordingRequestStatus =
callRecordingFilter.recordingRequestStatus.eq;
const requestedCallRecordingStatuses = callRecordingFilter.status.in;
const requiresExternalBotId =
callRecordingFilter.externalBotId.is === 'NOT_NULL';
const matchingCallRecordingNodes = this.callRecordingNodes.filter(
(callRecordingNode) =>
callRecordingNode.recordingRequestStatus ===
requestedRecordingRequestStatus &&
requestedCallRecordingStatuses.includes(callRecordingNode.status) &&
(!requiresExternalBotId ||
(callRecordingNode.externalBotId !== null &&
callRecordingNode.externalBotId !== undefined)),
);
return {
callRecordings: {
pageInfo: { hasNextPage: false, endCursor: undefined },
edges: matchingCallRecordingNodes.map((node) => ({ node })),
},
};
}
async mutation(mutation: any): Promise<any> {
const { id, data } = mutation.updateCallRecording.__args;
this.mutations.push({ id, data });
return { updateCallRecording: { id } };
}
}
const buildClient = (callRecordingNodes: CallRecordingNode[]) =>
new FakeCoreApiClient(callRecordingNodes);
const buildStuckRecordingNode = (
overrides: CallRecordingNode = {},
): CallRecordingNode => ({
id: 'call-recording-1',
status: 'RECORDING',
recordingRequestStatus: 'REQUESTED',
startedAt: null,
endedAt: null,
externalBotId: 'recall-bot-1',
externalRecordingId: null,
createdAt: '2026-06-09T12:00:00.000Z',
calendarEvent: { endsAt: '2026-06-09T13:00:00.000Z' },
...overrides,
});
describe('convergeDivergedCallRecordings', () => {
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
getRecallBotMock.mockReset();
});
it('fills timestamps and recording id for a stuck RECORDING record from the Recall bot state', async () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'in_call_recording', created_at: '2026-06-09T13:02:30.000Z' },
{ code: 'call_ended', created_at: '2026-06-09T14:00:30.000Z' },
{ 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',
},
],
},
});
const client = buildClient([buildStuckRecordingNode()]);
const result = await convergeDivergedCallRecordings({
client: client as unknown as CoreApiClient,
now: NOW,
});
expect(getRecallBotMock).toHaveBeenCalledWith({
externalBotId: 'recall-bot-1',
});
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).toEqual({
candidateCount: 1,
updatedCallRecordingIds: ['call-recording-1'],
markedFailedCallRecordingIds: [],
unconvergeableCallRecordingIds: [],
});
});
it('converges a SCHEDULED record when Recall moved forward but webhooks were missed', async () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'joining_call', created_at: '2026-06-09T13:01:00.000Z' },
{ code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' },
],
},
});
const client = buildClient([
buildStuckRecordingNode({ status: 'SCHEDULED' }),
]);
const result = await convergeDivergedCallRecordings({
client: client as unknown as CoreApiClient,
now: NOW,
});
expect(getRecallBotMock).toHaveBeenCalledWith({
externalBotId: 'recall-bot-1',
});
expect(client.mutations).toEqual([
{
id: 'call-recording-1',
data: {
status: 'RECORDING',
startedAt: '2026-06-09T13:02:00.000Z',
},
},
]);
expect(result.updatedCallRecordingIds).toEqual(['call-recording-1']);
});
it('skips records whose meeting may still be live', async () => {
const client = buildClient([
buildStuckRecordingNode({
calendarEvent: { endsAt: '2026-06-10T11:50:00.000Z' },
}),
]);
const result = await convergeDivergedCallRecordings({
client: client as unknown as CoreApiClient,
now: NOW,
});
expect(getRecallBotMock).not.toHaveBeenCalled();
expect(client.mutations).toEqual([]);
expect(result.candidateCount).toBe(1);
});
it('marks FAILED_UNKNOWN without clearing the bot id when Recall returns 404', async () => {
getRecallBotMock.mockResolvedValue({
ok: false,
status: 404,
errorMessage: 'Recall API responded with HTTP 404',
});
const client = buildClient([buildStuckRecordingNode()]);
const result = await convergeDivergedCallRecordings({
client: client as unknown as CoreApiClient,
now: NOW,
});
expect(client.mutations).toEqual([
{
id: 'call-recording-1',
data: { status: 'FAILED_UNKNOWN' },
},
]);
expect(result.markedFailedCallRecordingIds).toEqual(['call-recording-1']);
expect(console.warn).toHaveBeenCalled();
});
it('does not select COMPLETED records as convergence candidates', async () => {
getRecallBotMock.mockResolvedValue({
ok: false,
status: 404,
errorMessage: 'Recall API responded with HTTP 404',
});
const client = buildClient([
buildStuckRecordingNode({
status: 'COMPLETED',
startedAt: '2026-06-09T13:02:00.000Z',
}),
]);
const result = await convergeDivergedCallRecordings({
client: client as unknown as CoreApiClient,
now: NOW,
});
expect(client.mutations).toEqual([]);
expect(result.candidateCount).toBe(0);
expect(result.unconvergeableCallRecordingIds).toEqual([]);
expect(getRecallBotMock).not.toHaveBeenCalled();
});
it('logs candidates whose meeting ended before the lookback bound instead of converging them', async () => {
const client = buildClient([
buildStuckRecordingNode({
calendarEvent: { endsAt: '2026-06-01T13:00:00.000Z' },
}),
]);
const result = await convergeDivergedCallRecordings({
client: client as unknown as CoreApiClient,
now: NOW,
});
expect(getRecallBotMock).not.toHaveBeenCalled();
expect(client.mutations).toEqual([]);
expect(result.unconvergeableCallRecordingIds).toEqual(['call-recording-1']);
expect(console.warn).toHaveBeenCalled();
});
it('converges candidates created long before a recently ended meeting', async () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' },
],
},
});
const client = buildClient([
buildStuckRecordingNode({
createdAt: '2026-06-01T12:00:00.000Z',
startedAt: '2026-06-09T13:02:00.000Z',
}),
]);
const result = await convergeDivergedCallRecordings({
client: client as unknown as CoreApiClient,
now: NOW,
});
expect(getRecallBotMock).toHaveBeenCalledWith({
externalBotId: 'recall-bot-1',
});
expect(result.unconvergeableCallRecordingIds).toEqual([]);
});
it('applies the downgrade guard to pulled statuses while still filling timestamps', async () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' },
],
recordings: [
{ id: 'recall-recording-1', started_at: '2026-06-09T13:02:00.000Z' },
],
},
});
const client = buildClient([
buildStuckRecordingNode({ status: 'PROCESSING' }),
]);
await convergeDivergedCallRecordings({
client: client as unknown as CoreApiClient,
now: NOW,
});
expect(client.mutations).toEqual([
{
id: 'call-recording-1',
data: {
startedAt: '2026-06-09T13:02:00.000Z',
externalRecordingId: 'recall-recording-1',
},
},
]);
});
it('does not mutate a record the bot state agrees with', async () => {
getRecallBotMock.mockResolvedValue({
ok: true,
bot: {
status_changes: [
{ code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' },
],
},
});
const client = buildClient([
buildStuckRecordingNode({ startedAt: '2026-06-09T13:02:00.000Z' }),
]);
const result = await convergeDivergedCallRecordings({
client: client as unknown as CoreApiClient,
now: NOW,
});
expect(client.mutations).toEqual([]);
expect(result.updatedCallRecordingIds).toEqual([]);
});
});
@@ -0,0 +1,210 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { healCallRecordingsMissingBot } from 'src/logic-functions/flows/heal-call-recordings-missing-bot.util';
const scheduleRecallBotMock = vi.hoisted(() => vi.fn());
vi.mock('src/logic-functions/recall-api/schedule-recall-bot.util', () => ({
scheduleRecallBot: scheduleRecallBotMock,
}));
const NOW = new Date('2026-01-01T12:00:00.000Z');
const UPCOMING_STARTS_AT = '2026-01-01T13:00:00.000Z';
const UPCOMING_ENDS_AT = '2026-01-01T14:00:00.000Z';
const PAST_STARTS_AT = '2026-01-01T10:00:00.000Z';
const PAST_ENDS_AT = '2026-01-01T11:00:00.000Z';
type CallRecordingNode = {
id: string;
status?: string;
recordingRequestStatus?: string | null;
calendarEventId?: string | null;
externalBotId?: string | null;
};
type CalendarEventNode = {
id: string;
startsAt?: string | null;
endsAt?: string | null;
iCalUid?: string | null;
conferenceLink?: { primaryLinkUrl?: string | null } | null;
};
class FakeCoreApiClient {
callRecordings: CallRecordingNode[];
calendarEvents: CalendarEventNode[];
constructor({
callRecordings = [],
calendarEvents = [],
}: {
callRecordings?: CallRecordingNode[];
calendarEvents?: CalendarEventNode[];
}) {
this.callRecordings = callRecordings;
this.calendarEvents = calendarEvents;
}
async query(query: any): Promise<any> {
if (query.callRecordings !== undefined) {
const filter = query.callRecordings.__args.filter;
const matches =
filter.id?.in !== undefined
? this.callRecordings.filter((callRecording) =>
filter.id.in.includes(callRecording.id),
)
: this.callRecordings.filter(
(callRecording) =>
callRecording.recordingRequestStatus ===
filter.recordingRequestStatus.eq &&
callRecording.status === filter.status.eq,
);
return { callRecordings: buildConnection(matches) };
}
if (query.calendarEvents !== undefined) {
const calendarEventIds = query.calendarEvents.__args.filter.id.in;
return {
calendarEvents: buildConnection(
this.calendarEvents.filter((calendarEvent) =>
calendarEventIds.includes(calendarEvent.id),
),
),
};
}
throw new Error(`Unhandled query: ${JSON.stringify(query)}`);
}
async mutation(mutation: any): Promise<any> {
if (mutation.updateCallRecording !== undefined) {
const { id, data } = mutation.updateCallRecording.__args;
const callRecording = this.callRecordings.find(
(candidate) => candidate.id === id,
);
if (callRecording !== undefined) {
Object.assign(callRecording, data);
}
return { updateCallRecording: { id } };
}
throw new Error(`Unhandled mutation: ${JSON.stringify(mutation)}`);
}
}
const buildConnection = <Node>(nodes: Node[]) => ({
pageInfo: { hasNextPage: false, endCursor: undefined },
edges: nodes.map((node) => ({ node })),
});
const buildBotlessCallRecording = (
overrides: Partial<CallRecordingNode> = {},
): CallRecordingNode => ({
id: 'call-recording-1',
status: 'SCHEDULED',
recordingRequestStatus: 'REQUESTED',
calendarEventId: 'calendar-event-1',
externalBotId: null,
...overrides,
});
const buildCalendarEvent = (
overrides: Partial<CalendarEventNode> = {},
): CalendarEventNode => ({
id: 'calendar-event-1',
startsAt: UPCOMING_STARTS_AT,
endsAt: UPCOMING_ENDS_AT,
iCalUid: 'calendar-event-uid',
conferenceLink: { primaryLinkUrl: 'https://meet.example.com/customer-sync' },
...overrides,
});
describe('healCallRecordingsMissingBot', () => {
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
scheduleRecallBotMock.mockReset();
scheduleRecallBotMock.mockResolvedValue({
ok: true,
externalBotId: 'recall-bot-1',
});
});
it('schedules a bot and writes the id for an upcoming botless recording', async () => {
const client = new FakeCoreApiClient({
callRecordings: [buildBotlessCallRecording()],
calendarEvents: [buildCalendarEvent()],
});
const result = await healCallRecordingsMissingBot({
client: client as unknown as CoreApiClient,
now: NOW,
});
expect(result.scheduledCallRecordingIds).toEqual(['call-recording-1']);
expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1);
expect(client.callRecordings[0].externalBotId).toBe('recall-bot-1');
});
it('does not report a recording as scheduled when Recall scheduling fails', async () => {
scheduleRecallBotMock.mockResolvedValue({
ok: false,
status: 500,
errorMessage: 'Recall API responded with HTTP 500',
});
const client = new FakeCoreApiClient({
callRecordings: [buildBotlessCallRecording()],
calendarEvents: [buildCalendarEvent()],
});
const result = await healCallRecordingsMissingBot({
client: client as unknown as CoreApiClient,
now: NOW,
});
expect(result.scheduledCallRecordingIds).toEqual([]);
expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1);
expect(client.callRecordings[0].externalBotId).toBeNull();
});
it('skips a recording whose meeting has already ended', async () => {
const client = new FakeCoreApiClient({
callRecordings: [buildBotlessCallRecording()],
calendarEvents: [
buildCalendarEvent({
startsAt: PAST_STARTS_AT,
endsAt: PAST_ENDS_AT,
}),
],
});
const result = await healCallRecordingsMissingBot({
client: client as unknown as CoreApiClient,
now: NOW,
});
expect(result.scheduledCallRecordingIds).toEqual([]);
expect(scheduleRecallBotMock).not.toHaveBeenCalled();
});
it('does nothing when every scheduled recording already has a bot', async () => {
const client = new FakeCoreApiClient({
callRecordings: [
buildBotlessCallRecording({ externalBotId: 'recall-bot-existing' }),
],
calendarEvents: [buildCalendarEvent()],
});
const result = await healCallRecordingsMissingBot({
client: client as unknown as CoreApiClient,
now: NOW,
});
expect(result.scheduledCallRecordingIds).toEqual([]);
expect(scheduleRecallBotMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,410 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { APPLICATION_ID_ENV_VAR_NAME } from 'src/logic-functions/constants/application-id-env-var-name';
import { reapOrphanedMeetingBots } from 'src/logic-functions/flows/reap-orphaned-meeting-bots.util';
const listScheduledRecallBotsMock = vi.hoisted(() => vi.fn());
const cancelRecallBotMock = vi.hoisted(() => vi.fn());
const ejectRecallBotMock = vi.hoisted(() => vi.fn());
vi.mock(
'src/logic-functions/recall-api/list-scheduled-recall-bots.util',
() => ({
listScheduledRecallBots: listScheduledRecallBotsMock,
}),
);
vi.mock('src/logic-functions/recall-api/cancel-recall-bot.util', () => ({
cancelRecallBot: cancelRecallBotMock,
}));
vi.mock('src/logic-functions/recall-api/eject-recall-bot.util', () => ({
ejectRecallBot: ejectRecallBotMock,
}));
const JOIN_AT_AFTER = '2026-01-01T08:00:00.000Z';
const JOIN_AT_BEFORE = '2026-01-02T12:00:00.000Z';
const CURRENT_APPLICATION_ID = 'current-application-id';
const ORIGINAL_APPLICATION_ID = process.env[APPLICATION_ID_ENV_VAR_NAME];
type CallRecordingNode = {
id: string;
recordingRequestStatus?: string | null;
externalBotId?: string | null;
};
class FakeCoreApiClient {
constructor(private callRecordings: CallRecordingNode[]) {}
async query(query: any): Promise<any> {
const callRecordingIds = query.callRecordings.__args.filter.id.in;
return {
callRecordings: {
pageInfo: { hasNextPage: false, endCursor: undefined },
edges: this.callRecordings
.filter((callRecording) =>
callRecordingIds.includes(callRecording.id),
)
.map((node) => ({ node })),
},
};
}
}
const buildClient = (callRecordings: CallRecordingNode[]): CoreApiClient =>
new FakeCoreApiClient(callRecordings) as unknown as CoreApiClient;
const restoreOriginalApplicationId = () => {
if (ORIGINAL_APPLICATION_ID === undefined) {
delete process.env[APPLICATION_ID_ENV_VAR_NAME];
return;
}
process.env[APPLICATION_ID_ENV_VAR_NAME] = ORIGINAL_APPLICATION_ID;
};
const buildBot = ({
id,
twentyCallRecordingId,
twentyApplicationId,
}: {
id: string;
twentyCallRecordingId?: string;
twentyApplicationId?: string;
}) => ({
id,
metadata: {
...(twentyCallRecordingId === undefined ? {} : { twentyCallRecordingId }),
...(twentyApplicationId === undefined ? {} : { twentyApplicationId }),
},
});
const buildCurrentApplicationBot = ({
id,
twentyCallRecordingId,
}: {
id: string;
twentyCallRecordingId: string;
}) =>
buildBot({
id,
twentyCallRecordingId,
twentyApplicationId: CURRENT_APPLICATION_ID,
});
describe('reapOrphanedMeetingBots', () => {
beforeEach(() => {
restoreOriginalApplicationId();
process.env[APPLICATION_ID_ENV_VAR_NAME] = CURRENT_APPLICATION_ID;
vi.spyOn(console, 'warn').mockImplementation(() => {});
listScheduledRecallBotsMock.mockReset();
cancelRecallBotMock.mockReset();
cancelRecallBotMock.mockResolvedValue({ ok: true });
ejectRecallBotMock.mockReset();
ejectRecallBotMock.mockResolvedValue({ ok: true });
});
afterEach(() => {
restoreOriginalApplicationId();
});
it('keeps bots that their call recording still references', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [
buildCurrentApplicationBot({
id: 'claimed-bot',
twentyCallRecordingId: 'call-recording-1',
}),
],
});
const result = await reapOrphanedMeetingBots({
client: buildClient([
{
id: 'call-recording-1',
recordingRequestStatus: 'REQUESTED',
externalBotId: 'claimed-bot',
},
]),
joinAtAfter: JOIN_AT_AFTER,
joinAtBefore: JOIN_AT_BEFORE,
});
expect(result).toEqual({
scannedBotCount: 1,
canceledExternalBotIds: [],
});
expect(cancelRecallBotMock).not.toHaveBeenCalled();
});
it('cancels bots whose call recording request was canceled locally', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [
buildCurrentApplicationBot({
id: 'stale-cancel-bot',
twentyCallRecordingId: 'call-recording-1',
}),
],
});
const result = await reapOrphanedMeetingBots({
client: buildClient([
{
id: 'call-recording-1',
recordingRequestStatus: 'CANCELED',
externalBotId: 'stale-cancel-bot',
},
]),
joinAtAfter: JOIN_AT_AFTER,
joinAtBefore: JOIN_AT_BEFORE,
});
expect(result).toEqual({
scannedBotCount: 1,
canceledExternalBotIds: ['stale-cancel-bot'],
});
expect(cancelRecallBotMock).toHaveBeenCalledWith({
externalBotId: 'stale-cancel-bot',
});
});
it('cancels bots whose call recording references another bot', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [
buildCurrentApplicationBot({
id: 'superseded-bot',
twentyCallRecordingId: 'call-recording-1',
}),
buildCurrentApplicationBot({
id: 'claimed-bot',
twentyCallRecordingId: 'call-recording-1',
}),
],
});
const result = await reapOrphanedMeetingBots({
client: buildClient([
{
id: 'call-recording-1',
recordingRequestStatus: 'REQUESTED',
externalBotId: 'claimed-bot',
},
]),
joinAtAfter: JOIN_AT_AFTER,
joinAtBefore: JOIN_AT_BEFORE,
});
expect(result).toEqual({
scannedBotCount: 2,
canceledExternalBotIds: ['superseded-bot'],
});
expect(cancelRecallBotMock).toHaveBeenCalledTimes(1);
expect(cancelRecallBotMock).toHaveBeenCalledWith({
externalBotId: 'superseded-bot',
});
});
it('cancels bots whose call recording no longer exists', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [
buildCurrentApplicationBot({
id: 'orphan-bot',
twentyCallRecordingId: 'call-recording-gone',
}),
],
});
const result = await reapOrphanedMeetingBots({
client: buildClient([]),
joinAtAfter: JOIN_AT_AFTER,
joinAtBefore: JOIN_AT_BEFORE,
});
expect(result).toEqual({
scannedBotCount: 1,
canceledExternalBotIds: ['orphan-bot'],
});
});
it('grants a grace round to requested recordings without a bot id yet', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [
buildCurrentApplicationBot({
id: 'pending-bot',
twentyCallRecordingId: 'call-recording-1',
}),
],
});
const result = await reapOrphanedMeetingBots({
client: buildClient([
{
id: 'call-recording-1',
recordingRequestStatus: 'REQUESTED',
externalBotId: null,
},
]),
joinAtAfter: JOIN_AT_AFTER,
joinAtBefore: JOIN_AT_BEFORE,
});
expect(result).toEqual({
scannedBotCount: 1,
canceledExternalBotIds: [],
});
expect(cancelRecallBotMock).not.toHaveBeenCalled();
});
it('ignores bots that were not created by this app', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [buildBot({ id: 'unrelated-bot' })],
});
const result = await reapOrphanedMeetingBots({
client: buildClient([]),
joinAtAfter: JOIN_AT_AFTER,
joinAtBefore: JOIN_AT_BEFORE,
});
expect(result).toEqual({
scannedBotCount: 1,
canceledExternalBotIds: [],
});
expect(cancelRecallBotMock).not.toHaveBeenCalled();
});
it('ignores untagged bots even when they carry call recording metadata', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [
buildBot({
id: 'untagged-bot',
twentyCallRecordingId: 'call-recording-gone',
}),
],
});
const result = await reapOrphanedMeetingBots({
client: buildClient([]),
joinAtAfter: JOIN_AT_AFTER,
joinAtBefore: JOIN_AT_BEFORE,
});
expect(result).toEqual({
scannedBotCount: 1,
canceledExternalBotIds: [],
});
expect(cancelRecallBotMock).not.toHaveBeenCalled();
});
it('ignores bots claimed by another application registration', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [
buildBot({
id: 'other-app-bot',
twentyCallRecordingId: 'call-recording-gone',
twentyApplicationId: 'other-application-id',
}),
],
});
const result = await reapOrphanedMeetingBots({
client: buildClient([]),
joinAtAfter: JOIN_AT_AFTER,
joinAtBefore: JOIN_AT_BEFORE,
});
expect(result).toEqual({
scannedBotCount: 1,
canceledExternalBotIds: [],
});
expect(cancelRecallBotMock).not.toHaveBeenCalled();
});
it('cancels orphaned bots claimed by this application registration', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [
buildCurrentApplicationBot({
id: 'same-app-bot',
twentyCallRecordingId: 'call-recording-gone',
}),
],
});
const result = await reapOrphanedMeetingBots({
client: buildClient([]),
joinAtAfter: JOIN_AT_AFTER,
joinAtBefore: JOIN_AT_BEFORE,
});
expect(result).toEqual({
scannedBotCount: 1,
canceledExternalBotIds: ['same-app-bot'],
});
expect(cancelRecallBotMock).toHaveBeenCalledWith({
externalBotId: 'same-app-bot',
});
});
it('ejects an orphaned bot that already joined when deletion is rejected', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [
buildCurrentApplicationBot({
id: 'in-call-orphan',
twentyCallRecordingId: 'call-recording-gone',
}),
],
});
cancelRecallBotMock.mockResolvedValue({
ok: false,
status: 409,
errorMessage: 'Recall API responded with HTTP 409',
});
const result = await reapOrphanedMeetingBots({
client: buildClient([]),
joinAtAfter: JOIN_AT_AFTER,
joinAtBefore: JOIN_AT_BEFORE,
});
expect(result).toEqual({
scannedBotCount: 1,
canceledExternalBotIds: ['in-call-orphan'],
});
expect(ejectRecallBotMock).toHaveBeenCalledWith({
externalBotId: 'in-call-orphan',
});
});
it('reports nothing reaped when listing Recall bots fails', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: false,
status: 500,
errorMessage: 'Recall API responded with HTTP 500',
});
const result = await reapOrphanedMeetingBots({
client: buildClient([]),
joinAtAfter: JOIN_AT_AFTER,
joinAtBefore: JOIN_AT_BEFORE,
});
expect(result).toEqual({
scannedBotCount: 0,
canceledExternalBotIds: [],
});
expect(cancelRecallBotMock).not.toHaveBeenCalled();
});
});
@@ -821,7 +821,7 @@ describe('reconcileMeetingBotForCalendarEventIds', () => {
]);
});
it('recreates the bot when the existing Recall bot no longer exists', async () => {
it('clears the stale bot id for the stale-state cron to re-create when the existing Recall bot no longer exists', async () => {
rescheduleRecallBotMock.mockResolvedValue({
ok: false,
status: 404,
@@ -859,20 +859,12 @@ describe('reconcileMeetingBotForCalendarEventIds', () => {
expect(rescheduleRecallBotMock).toHaveBeenCalledWith(
expect.objectContaining({ externalBotId: 'recall-bot-stale' }),
);
expect(scheduleRecallBotMock).toHaveBeenCalledWith({
meetingUrl: 'https://meet.example.com/customer-sync',
joinAt: FUTURE_STARTS_AT,
metadata: {
twentyCallRecordingId: buildCustomerSyncCallRecordingId(),
twentyCalendarEventId: 'calendar-event-1',
twentyRealMeetingKey:
'link:meet.example.com/customer-sync:2026-01-01T13:00:00.000Z',
},
});
// The event path no longer re-creates the bot; the stale id is cleared and the cron heals the botless row.
expect(scheduleRecallBotMock).not.toHaveBeenCalled();
expect(client.callRecordings).toEqual([
expect.objectContaining({
id: buildCustomerSyncCallRecordingId(),
externalBotId: 'recall-bot-1',
externalBotId: null,
}),
]);
});
@@ -6,7 +6,7 @@ import { type CallRecordingRecord } from 'src/logic-functions/types/call-recordi
import { cancelRecallBot } from 'src/logic-functions/recall-api/cancel-recall-bot.util';
import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util';
// TODO: Add the stale-state cron in the next split so it can finish the Recall half when this call fails.
// Intent-first: the stale-state cron finishes the Recall half when this call fails.
export const cancelCallRecordingRequest = async ({
client,
callRecording,
@@ -31,7 +31,7 @@ export const cancelCallRecordingRequest = async ({
if (!cancelResult.ok) {
console.warn(
`[twenty-meeting-bot] failed to cancel Recall bot for callRecording ${callRecording.id}, leaving it for the planned stale-state cron: ${cancelResult.errorMessage}`,
`[twenty-meeting-bot] failed to cancel Recall bot for callRecording ${callRecording.id}, leaving it for the stale-state cron: ${cancelResult.errorMessage}`,
);
return;
@@ -0,0 +1,323 @@
import { isUndefined } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size';
import {
extractRecallBotConvergence,
type RecallBotConvergence,
} from 'src/logic-functions/recall-api/extract-recall-bot-convergence.util';
import {
fetchAllNodes,
type ConnectionPage,
} from 'src/logic-functions/data/fetch-all-nodes.util';
import { getRecallBot } from 'src/logic-functions/recall-api/get-recall-bot.util';
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 {
updateCallRecording,
type CallRecordingUpdateFields,
} 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,
CallRecordingStatus.JOINING,
CallRecordingStatus.RECORDING,
CallRecordingStatus.PROCESSING,
];
type DivergedCallRecordingCandidate = {
id: string;
status: string | undefined;
startedAt: string | undefined;
endedAt: string | undefined;
externalBotId: string | undefined;
externalRecordingId: string | undefined;
createdAt: string | undefined;
calendarEventEndsAt: string | undefined;
};
type DivergedCallRecordingNode = {
id: string;
status?: string | null;
startedAt?: string | null;
endedAt?: string | null;
externalBotId?: string | null;
externalRecordingId?: string | null;
createdAt?: string | null;
calendarEvent?: { endsAt?: string | null } | null;
};
export type ConvergeDivergedCallRecordingsResult = {
candidateCount: number;
updatedCallRecordingIds: string[];
markedFailedCallRecordingIds: string[];
unconvergeableCallRecordingIds: string[];
};
// Webhook deliveries get lost; this pull pass re-derives state from Recall.
export const convergeDivergedCallRecordings = async ({
client,
now,
}: {
client: CoreApiClient;
now: Date;
}): Promise<ConvergeDivergedCallRecordingsResult> => {
const candidates = await fetchDivergedCallRecordingCandidates(client);
const convergenceLowerBound = new Date(
now.getTime() - CONVERGENCE_LOOKBACK_DAYS * 24 * 60 * 60 * 1000,
);
const liveMeetingCutoff = new Date(
now.getTime() - LIVE_MEETING_GRACE_MINUTES * 60 * 1000,
);
const result: ConvergeDivergedCallRecordingsResult = {
candidateCount: candidates.length,
updatedCallRecordingIds: [],
markedFailedCallRecordingIds: [],
unconvergeableCallRecordingIds: [],
};
for (const candidate of candidates) {
if (isOutsideConvergenceBound(candidate, convergenceLowerBound)) {
console.warn(
`[twenty-meeting-bot] call recording ${candidate.id} diverged but its meeting ended more than ${CONVERGENCE_LOOKBACK_DAYS} days ago; it will not converge automatically`,
);
result.unconvergeableCallRecordingIds.push(candidate.id);
continue;
}
if (isUndefined(candidate.externalBotId)) {
console.warn(
`[twenty-meeting-bot] call recording ${candidate.id} diverged but has no Recall bot id; it will not converge automatically`,
);
result.unconvergeableCallRecordingIds.push(candidate.id);
continue;
}
if (isPossiblyStillLive(candidate, liveMeetingCutoff)) {
continue;
}
await convergeCallRecording({
client,
candidate,
externalBotId: candidate.externalBotId,
result,
});
}
return result;
};
const fetchDivergedCallRecordingCandidates = async (
client: CoreApiClient,
): Promise<DivergedCallRecordingCandidate[]> => {
// No createdAt bound: older-than-lookback candidates must surface in logs.
const filter: Record<string, unknown> = {
or: [
{
recordingRequestStatus: { eq: CallRecordingRequestStatus.REQUESTED },
status: { in: NON_TERMINAL_CALL_RECORDING_STATUSES },
externalBotId: { is: 'NOT_NULL' },
},
],
};
const candidateNodes = await fetchAllNodes<DivergedCallRecordingNode>(
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,
externalBotId: true,
externalRecordingId: true,
createdAt: true,
calendarEvent: {
endsAt: true,
},
},
},
},
});
return queryResult.callRecordings as
| ConnectionPage<DivergedCallRecordingNode>
| undefined;
},
);
return candidateNodes.map((node) => ({
id: node.id,
status: node.status ?? undefined,
startedAt: node.startedAt ?? undefined,
endedAt: node.endedAt ?? undefined,
externalBotId: isNonEmptyString(node.externalBotId)
? node.externalBotId
: undefined,
externalRecordingId: isNonEmptyString(node.externalRecordingId)
? node.externalRecordingId
: undefined,
createdAt: node.createdAt ?? undefined,
calendarEventEndsAt: node.calendarEvent?.endsAt ?? undefined,
}));
};
// Anchored to meeting end: createdAt is scheduling time and can predate the meeting by weeks.
const isOutsideConvergenceBound = (
candidate: DivergedCallRecordingCandidate,
convergenceLowerBound: Date,
): boolean => {
const meetingEndReference =
candidate.calendarEventEndsAt ?? candidate.createdAt;
return (
!isUndefined(meetingEndReference) &&
new Date(meetingEndReference).getTime() < convergenceLowerBound.getTime()
);
};
// Inside the grace period the meeting may still be recording; webhooks own it.
const isPossiblyStillLive = (
candidate: DivergedCallRecordingCandidate,
liveMeetingCutoff: Date,
): boolean =>
!isUndefined(candidate.calendarEventEndsAt) &&
new Date(candidate.calendarEventEndsAt).getTime() >
liveMeetingCutoff.getTime();
const convergeCallRecording = async ({
client,
candidate,
externalBotId,
result,
}: {
client: CoreApiClient;
candidate: DivergedCallRecordingCandidate;
externalBotId: string;
result: ConvergeDivergedCallRecordingsResult;
}): Promise<void> => {
const botResult = await getRecallBot({ externalBotId });
if (!botResult.ok) {
if (botResult.status === 404) {
await markCallRecordingFailedAfterBotLoss({
client,
candidate,
externalBotId,
result,
});
return;
}
console.warn(
`[twenty-meeting-bot] failed to fetch Recall bot ${externalBotId} for call recording ${candidate.id}: ${botResult.errorMessage}`,
);
return;
}
const convergence = extractRecallBotConvergence(botResult.bot);
const updateData = buildConvergenceFieldUpdates({ candidate, convergence });
if (Object.keys(updateData).length === 0) {
return;
}
await updateCallRecording(client, {
id: candidate.id,
data: updateData,
});
result.updatedCallRecordingIds.push(candidate.id);
};
// Pure merge: fill only unset candidate fields and never downgrade status.
const buildConvergenceFieldUpdates = ({
candidate,
convergence,
}: {
candidate: DivergedCallRecordingCandidate;
convergence: RecallBotConvergence;
}): CallRecordingUpdateFields => {
const updateData: CallRecordingUpdateFields = {};
if (
!isUndefined(convergence.status) &&
convergence.status !== candidate.status &&
!isCallRecordingStatusDowngrade({
fromStatus: candidate.status,
toStatus: convergence.status,
})
) {
updateData.status = convergence.status;
}
if (isUndefined(candidate.startedAt) && !isUndefined(convergence.startedAt)) {
updateData.startedAt = convergence.startedAt;
}
if (isUndefined(candidate.endedAt) && !isUndefined(convergence.endedAt)) {
updateData.endedAt = convergence.endedAt;
}
if (
isUndefined(candidate.externalRecordingId) &&
!isUndefined(convergence.externalRecordingId)
) {
updateData.externalRecordingId = convergence.externalRecordingId;
}
return updateData;
};
const markCallRecordingFailedAfterBotLoss = async ({
client,
candidate,
externalBotId,
result,
}: {
client: CoreApiClient;
candidate: DivergedCallRecordingCandidate;
externalBotId: string;
result: ConvergeDivergedCallRecordingsResult;
}): Promise<void> => {
// externalBotId is kept for audit even though the bot is gone at Recall.
console.warn(
`[twenty-meeting-bot] Recall bot ${externalBotId} for call recording ${candidate.id} no longer exists; it will not converge automatically`,
);
if (
isCallRecordingStatusDowngrade({
fromStatus: candidate.status,
toStatus: CallRecordingStatus.FAILED_UNKNOWN,
})
) {
result.unconvergeableCallRecordingIds.push(candidate.id);
return;
}
await updateCallRecording(client, {
id: candidate.id,
data: { status: CallRecordingStatus.FAILED_UNKNOWN },
});
result.markedFailedCallRecordingIds.push(candidate.id);
};
@@ -8,17 +8,16 @@ import { findCallRecordingsByIds } from 'src/logic-functions/data/find-call-reco
import { scheduleRecallBot } from 'src/logic-functions/recall-api/schedule-recall-bot.util';
import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util';
// The sole place a Recall bot is created. The deterministic-create winner and active update path call it in this split.
// TODO: Add the convergence cron in the next split so botless REQUESTED rows are healed by the same writer.
// The sole place a Recall bot is created. Only the deterministic-create winner and the stale-state cron call it, so one writer per meeting POSTs exactly one bot.
export const ensureMeetingBot = async (
client: CoreApiClient,
{ callRecording, calendarEvent }: MeetingRecording,
): Promise<void> => {
): Promise<boolean> => {
const meetingUrl = calendarEvent.conferenceLinkUrl;
const joinAt = calendarEvent.startsAt;
if (isUndefined(meetingUrl) || isUndefined(joinAt)) {
return;
return false;
}
const freshCallRecording = (
@@ -31,7 +30,7 @@ export const ensureMeetingBot = async (
CallRecordingRequestStatus.REQUESTED ||
!isUndefined(freshCallRecording.externalBotId)
) {
return;
return false;
}
const scheduleResult = await scheduleRecallBot({
@@ -45,11 +44,13 @@ export const ensureMeetingBot = async (
`[twenty-meeting-bot] failed to schedule Recall bot for callRecording ${callRecording.id}: ${scheduleResult.errorMessage}`,
);
return;
return false;
}
await updateCallRecording(client, {
id: callRecording.id,
data: { externalBotId: scheduleResult.externalBotId },
});
return true;
};
@@ -0,0 +1,82 @@
import { isUndefined } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type';
import { ensureMeetingBot } from 'src/logic-functions/flows/ensure-meeting-bot.util';
import { fetchCalendarEventsByIds } from 'src/logic-functions/data/fetch-calendar-events-by-ids.util';
import { findOpenScheduledCallRecordings } from 'src/logic-functions/data/find-open-scheduled-call-recordings.util';
import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util';
export type HealCallRecordingsMissingBotResult = {
scheduledCallRecordingIds: string[];
};
// Closes the create-winner crash gap: a run that inserted the row but died before POSTing leaves a botless recording, and the cron is the single writer that re-POSTs it.
export const healCallRecordingsMissingBot = async ({
client,
now,
}: {
client: CoreApiClient;
now: Date;
}): Promise<HealCallRecordingsMissingBotResult> => {
const botlessCallRecordings = (
await findOpenScheduledCallRecordings(client)
).filter((callRecording) => isUndefined(callRecording.externalBotId));
if (botlessCallRecordings.length === 0) {
return { scheduledCallRecordingIds: [] };
}
const calendarEventsById = new Map(
(
await fetchCalendarEventsByIds(
client,
getUniqueSortedIds(
botlessCallRecordings.map(
(callRecording) => callRecording.calendarEventId,
),
),
)
).map((calendarEvent) => [calendarEvent.id, calendarEvent]),
);
const scheduledCallRecordingIds: string[] = [];
for (const callRecording of botlessCallRecordings) {
const calendarEvent = isUndefined(callRecording.calendarEventId)
? undefined
: calendarEventsById.get(callRecording.calendarEventId);
if (isUndefined(calendarEvent) || hasMeetingEnded({ calendarEvent, now })) {
continue;
}
const didScheduleMeetingBot = await ensureMeetingBot(client, {
callRecording,
calendarEvent,
});
if (didScheduleMeetingBot) {
scheduledCallRecordingIds.push(callRecording.id);
}
}
return { scheduledCallRecordingIds };
};
const hasMeetingEnded = ({
calendarEvent,
now,
}: {
calendarEvent: CalendarEventRecord;
now: Date;
}): boolean => {
const reference = calendarEvent.endsAt ?? calendarEvent.startsAt;
if (isUndefined(reference)) {
return false;
}
const referenceTime = new Date(reference).getTime();
return !Number.isNaN(referenceTime) && referenceTime <= now.getTime();
};
@@ -0,0 +1,182 @@
import { isNull, isUndefined } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { APPLICATION_ID_ENV_VAR_NAME } from 'src/logic-functions/constants/application-id-env-var-name';
import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type';
import { cancelRecallBot } from 'src/logic-functions/recall-api/cancel-recall-bot.util';
import { ejectRecallBot } from 'src/logic-functions/recall-api/eject-recall-bot.util';
import { findCallRecordingsByIds } from 'src/logic-functions/data/find-call-recordings-by-ids.util';
import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util';
import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
import {
listScheduledRecallBots,
type RecallScheduledBot,
} from 'src/logic-functions/recall-api/list-scheduled-recall-bots.util';
export type ReapOrphanedMeetingBotsResult = {
scannedBotCount: number;
canceledExternalBotIds: string[];
};
// Bots no open CallRecording request claims would still join; cancel them on Recall.
export const reapOrphanedMeetingBots = async ({
client,
joinAtAfter,
joinAtBefore,
}: {
client: CoreApiClient;
joinAtAfter: string;
joinAtBefore: string;
}): Promise<ReapOrphanedMeetingBotsResult> => {
const listResult = await listScheduledRecallBots({
joinAtAfter,
joinAtBefore,
});
if (!listResult.ok) {
console.warn(
`[twenty-meeting-bot] failed to list Recall bots for orphan reaping: ${listResult.errorMessage}`,
);
return { scannedBotCount: 0, canceledExternalBotIds: [] };
}
const currentApplicationId = getCurrentApplicationId();
const appManagedBots = listResult.bots.filter((bot) =>
isCurrentApplicationManagedBot({ bot, currentApplicationId }),
);
if (appManagedBots.length === 0) {
return {
scannedBotCount: listResult.bots.length,
canceledExternalBotIds: [],
};
}
const callRecordings = await findCallRecordingsByIds(
client,
getUniqueSortedIds(
appManagedBots.map((bot) => getClaimedCallRecordingId(bot)),
),
);
const callRecordingsById = new Map(
callRecordings.map((callRecording) => [callRecording.id, callRecording]),
);
const canceledExternalBotIds: string[] = [];
for (const bot of appManagedBots) {
const claimedCallRecordingId = getClaimedCallRecordingId(bot);
const callRecording = isUndefined(claimedCallRecordingId)
? undefined
: callRecordingsById.get(claimedCallRecordingId);
if (isBotClaimed({ bot, callRecording })) {
continue;
}
console.warn(
`[twenty-meeting-bot] canceling orphaned Recall bot ${bot.id} (claimed callRecording: ${claimedCallRecordingId})`,
);
if (await cancelOrEjectRecallBot(bot.id)) {
canceledExternalBotIds.push(bot.id);
}
}
return {
scannedBotCount: listResult.bots.length,
canceledExternalBotIds,
};
};
const getClaimedCallRecordingId = (
bot: RecallScheduledBot,
): string | undefined => {
const claimedCallRecordingId = bot.metadata.twentyCallRecordingId;
return normalizeOptionalString(claimedCallRecordingId);
};
const getClaimedApplicationId = (
bot: RecallScheduledBot,
): string | undefined => {
const claimedApplicationId = bot.metadata.twentyApplicationId;
return normalizeOptionalString(claimedApplicationId);
};
const getCurrentApplicationId = (): string | undefined =>
normalizeOptionalString(
getApplicationVariableValue(APPLICATION_ID_ENV_VAR_NAME),
);
const isCurrentApplicationManagedBot = ({
bot,
currentApplicationId,
}: {
bot: RecallScheduledBot;
currentApplicationId: string | undefined;
}): boolean => {
if (isUndefined(getClaimedCallRecordingId(bot))) {
return false;
}
const claimedApplicationId = getClaimedApplicationId(bot);
return (
!isUndefined(currentApplicationId) &&
claimedApplicationId === currentApplicationId
);
};
const isBotClaimed = ({
bot,
callRecording,
}: {
bot: RecallScheduledBot;
callRecording: CallRecordingRecord | undefined;
}): boolean => {
if (
callRecording?.recordingRequestStatus !==
CallRecordingRequestStatus.REQUESTED
) {
return false;
}
if (callRecording.externalBotId === bot.id) {
return true;
}
// An id-less REQUESTED recording may have a bot-id write-back in flight; spare its bot.
return isUndefined(callRecording.externalBotId);
};
const cancelOrEjectRecallBot = async (
externalBotId: string,
): Promise<boolean> => {
const cancelResult = await cancelRecallBot({ externalBotId });
if (cancelResult.ok) {
return true;
}
// Deleting only works for not-yet-joined bots; eject the ones already in a call.
if (!isNull(cancelResult.status)) {
const ejectResult = await ejectRecallBot({ externalBotId });
if (ejectResult.ok) {
return true;
}
}
console.warn(
`[twenty-meeting-bot] failed to cancel orphaned Recall bot ${externalBotId}: ${cancelResult.errorMessage}`,
);
return false;
};
const normalizeOptionalString = (value: unknown): string | undefined =>
isNonEmptyString(value) ? value.trim() : undefined;
@@ -298,10 +298,6 @@ const updatePolicyManagedCallRecording = async ({
callRecording: existingCallRecording,
calendarEvent: representativeCalendarEvent,
});
await ensureMeetingBot(client, {
callRecording: existingCallRecording,
calendarEvent: representativeCalendarEvent,
});
return {
action: 'UPDATED',
@@ -36,7 +36,7 @@ export const rescheduleCallRecordingBot = async (
return;
}
// The caller re-runs ensureMeetingBot so this botless REQUESTED row is re-created by the single writer.
// The bot vanished externally; drop the id so the stale-state cron re-creates it as the single writer.
if (rescheduleResult.status === RECALL_BOT_NOT_FOUND_STATUS) {
await updateCallRecording(client, {
id: callRecording.id,
@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import { extractRecallBotConvergence } from 'src/logic-functions/recall-api/extract-recall-bot-convergence.util';
describe('extractRecallBotConvergence', () => {
it('maps the latest status change code to a call recording status', () => {
const convergence = extractRecallBotConvergence({
status_changes: [
{ code: 'joining_call', created_at: '2026-01-01T12:58:00.000Z' },
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
{ code: 'call_ended', created_at: '2026-01-01T14:00:00.000Z' },
{ code: 'done', created_at: '2026-01-01T14:05:00.000Z' },
],
});
// COMPLETED is reserved for full artifact ingestion, never bot state.
expect(convergence.status).toBe('PROCESSING');
});
it('uses created_at to find the latest status when Recall returns status changes out of order', () => {
const convergence = extractRecallBotConvergence({
status_changes: [
{ code: 'done', created_at: '2026-01-01T14:05:00.000Z' },
{ code: 'joining_call', created_at: '2026-01-01T12:58:00.000Z' },
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
],
});
expect(convergence.status).toBe('PROCESSING');
});
it('prefers recording-object timestamps over status change entries', () => {
const convergence = extractRecallBotConvergence({
status_changes: [
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:30.000Z' },
{ code: 'call_ended', created_at: '2026-01-01T14:00:30.000Z' },
],
recordings: [
{
id: 'recall-recording-1',
started_at: '2026-01-01T13:02:00.000Z',
completed_at: '2026-01-01T14:00:00.000Z',
},
],
});
expect(convergence).toEqual({
status: 'PROCESSING',
startedAt: '2026-01-01T13:02:00.000Z',
endedAt: '2026-01-01T14:00:00.000Z',
externalRecordingId: 'recall-recording-1',
});
});
it('falls back to status change timestamps when recordings carry none', () => {
const convergence = extractRecallBotConvergence({
status_changes: [
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
{ code: 'call_ended', created_at: '2026-01-01T14:00:00.000Z' },
],
recordings: [{ id: 'recall-recording-1' }],
});
expect(convergence).toEqual({
status: 'PROCESSING',
startedAt: '2026-01-01T13:02:00.000Z',
endedAt: '2026-01-01T14:00:00.000Z',
externalRecordingId: 'recall-recording-1',
});
});
it('normalizes microsecond-precision Recall timestamps to millisecond ISO', () => {
const convergence = extractRecallBotConvergence({
status_changes: [
{ code: 'done', created_at: '2026-06-10T12:20:00.123456+00:00' },
],
recordings: [
{
id: 'recall-recording-1',
started_at: '2026-06-10T11:02:28.281597+00:00',
completed_at: '2026-06-10T12:17:28.281597+00:00',
},
],
});
expect(convergence.startedAt).toBe('2026-06-10T11:02:28.281Z');
expect(convergence.endedAt).toBe('2026-06-10T12:17:28.281Z');
});
it('returns nothing derivable from an empty bot response', () => {
expect(extractRecallBotConvergence({})).toEqual({
status: undefined,
startedAt: undefined,
endedAt: undefined,
externalRecordingId: undefined,
});
});
it('skips malformed status change entries', () => {
const convergence = extractRecallBotConvergence({
status_changes: [
null,
'not-an-object',
{ created_at: '2026-01-01T13:00:00.000Z' },
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
],
recordings: 'not-an-array',
});
expect(convergence).toEqual({
status: 'RECORDING',
startedAt: '2026-01-01T13:02:00.000Z',
endedAt: undefined,
externalRecordingId: undefined,
});
});
it('leaves the status undefined for unknown latest codes', () => {
const convergence = extractRecallBotConvergence({
status_changes: [
{ code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' },
{ code: 'some_future_code', created_at: '2026-01-01T13:30:00.000Z' },
],
});
expect(convergence.status).toBeUndefined();
expect(convergence.startedAt).toBe('2026-01-01T13:02:00.000Z');
});
});
@@ -1,6 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cancelRecallBot } from 'src/logic-functions/recall-api/cancel-recall-bot.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 { 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 { scheduleRecallBot } from 'src/logic-functions/recall-api/schedule-recall-bot.util';
@@ -101,24 +104,6 @@ describe('recall bot api', () => {
);
});
it('cancels a scheduled Recall bot request', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 204,
json: async () => ({}),
});
const result = await cancelRecallBot({
externalBotId: 'recall-bot-id',
});
expect(result).toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledWith(
'https://ap-northeast-1.recall.ai/api/v1/bot/recall-bot-id/',
expect.objectContaining({ method: 'DELETE' }),
);
});
it('fails when the create response does not include a bot id', async () => {
fetchMock.mockResolvedValue({
ok: true,
@@ -200,6 +185,189 @@ describe('recall bot api', () => {
);
});
it('lists scheduled bots in a join-at window and follows pagination', async () => {
fetchMock
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
next: 'https://ap-northeast-1.recall.ai/api/v1/bot/?cursor=page-2',
results: [
{ id: 'bot-1', metadata: { twentyCallRecordingId: 'recording-1' } },
],
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
next: null,
results: [{ id: 'bot-2' }],
}),
});
const result = await listScheduledRecallBots({
joinAtAfter: '2026-01-01T08:00:00.000Z',
joinAtBefore: '2026-01-02T12:00:00.000Z',
});
expect(result).toEqual({
ok: true,
bots: [
{ id: 'bot-1', metadata: { twentyCallRecordingId: 'recording-1' } },
{ id: 'bot-2', metadata: {} },
],
});
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'https://ap-northeast-1.recall.ai/api/v1/bot/?join_at_after=2026-01-01T08%3A00%3A00.000Z&join_at_before=2026-01-02T12%3A00%3A00.000Z',
expect.objectContaining({ method: 'GET' }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'https://ap-northeast-1.recall.ai/api/v1/bot/?cursor=page-2',
expect.objectContaining({ method: 'GET' }),
);
});
it('fails the scheduled bot list when the pagination cap would truncate results', async () => {
for (let pageIndex = 1; pageIndex <= 10; pageIndex++) {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
next: `https://ap-northeast-1.recall.ai/api/v1/bot/?cursor=page-${pageIndex + 1}`,
results: [{ id: `bot-${pageIndex}` }],
}),
});
}
const result = await listScheduledRecallBots({
joinAtAfter: '2026-01-01T08:00:00.000Z',
joinAtBefore: '2026-01-02T12:00:00.000Z',
});
expect(result).toEqual({
ok: false,
status: null,
errorMessage: 'Recall bot list exceeded 10 pages',
});
expect(fetchMock).toHaveBeenCalledTimes(10);
});
it('stops paginating when the next link points outside the configured region', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
next: 'https://evil.example.com/api/v1/bot/?cursor=page-2',
results: [{ id: 'bot-1' }],
}),
});
const result = await listScheduledRecallBots({
joinAtAfter: '2026-01-01T08:00:00.000Z',
joinAtBefore: '2026-01-02T12:00:00.000Z',
});
expect(result).toEqual({
ok: true,
bots: [{ id: 'bot-1', metadata: {} }],
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('cancels a scheduled Recall bot request', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 204,
json: async () => ({}),
});
const result = await cancelRecallBot({
externalBotId: 'recall-bot-id',
});
expect(result).toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledWith(
'https://ap-northeast-1.recall.ai/api/v1/bot/recall-bot-id/',
expect.objectContaining({ method: 'DELETE' }),
);
});
it('ejects a bot through the leave_call endpoint', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ id: 'recall-bot-id' }),
});
const result = await ejectRecallBot({
externalBotId: 'recall-bot-id',
});
expect(result).toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledWith(
'https://ap-northeast-1.recall.ai/api/v1/bot/recall-bot-id/leave_call/',
expect.objectContaining({ method: 'POST' }),
);
});
it('fetches a single bot and returns the raw response', async () => {
const botResponse = {
id: 'recall-bot-id',
status_changes: [{ code: 'done' }],
recordings: [{ id: 'recall-recording-id' }],
};
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => botResponse,
});
const result = await getRecallBot({ externalBotId: 'recall-bot-id' });
expect(result).toEqual({ ok: true, bot: botResponse });
expect(fetchMock).toHaveBeenCalledWith(
'https://ap-northeast-1.recall.ai/api/v1/bot/recall-bot-id/',
expect.objectContaining({ method: 'GET' }),
);
});
it('fails when fetching a bot returns an empty response payload', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => null,
});
const result = await getRecallBot({ externalBotId: 'recall-bot-id' });
expect(result).toEqual({
ok: false,
status: 200,
errorMessage: 'Recall API returned an empty bot response',
});
});
it('reports the HTTP status when fetching a bot that no longer exists', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 404,
json: async () => ({ detail: 'Not found.' }),
});
const result = await getRecallBot({ externalBotId: 'recall-bot-gone' });
expect(result).toEqual({
ok: false,
status: 404,
errorMessage:
'Recall API responded with HTTP 404: {"detail":"Not found."}',
});
});
describe('transient failure retries', () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -217,22 +385,36 @@ describe('recall bot api', () => {
json: async () => ({ id: 'recall-bot-id' }),
});
const resultPromise = rescheduleRecallBot({
externalBotId: 'recall-bot-id',
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-01T13:00:00.000Z',
metadata: {
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
},
});
const resultPromise = getRecallBot({ externalBotId: 'recall-bot-id' });
await vi.runAllTimersAsync();
expect(await resultPromise).toEqual({
ok: true,
externalBotId: 'recall-bot-id',
bot: { id: 'recall-bot-id' },
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('retries a 503 response and succeeds on the next attempt', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 503,
json: async () => ({ detail: 'service unavailable' }),
});
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ id: 'recall-bot-id' }),
});
const resultPromise = getRecallBot({ externalBotId: 'recall-bot-id' });
await vi.runAllTimersAsync();
expect(await resultPromise).toEqual({
ok: true,
bot: { id: 'recall-bot-id' },
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
@@ -244,16 +426,7 @@ describe('recall bot api', () => {
json: async () => ({ detail: 'server error' }),
});
const resultPromise = rescheduleRecallBot({
externalBotId: 'recall-bot-id',
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-01T13:00:00.000Z',
metadata: {
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
},
});
const resultPromise = getRecallBot({ externalBotId: 'recall-bot-id' });
await vi.runAllTimersAsync();
@@ -266,6 +439,24 @@ describe('recall bot api', () => {
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('does not retry client errors', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ detail: 'bad request' }),
});
const result = await getRecallBot({ externalBotId: 'recall-bot-id' });
expect(result).toEqual({
ok: false,
status: 400,
errorMessage:
'Recall API responded with HTTP 400: {"detail":"bad request"}',
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('does not retry an allowed 404 on cancel', async () => {
fetchMock.mockResolvedValue({
ok: false,
@@ -280,5 +471,20 @@ describe('recall bot api', () => {
expect(result).toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('does not retry an allowed 404 on eject', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 404,
json: async () => ({ detail: 'not found' }),
});
const result = await ejectRecallBot({
externalBotId: 'recall-bot-id',
});
expect(result).toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,28 @@
import { type RecallBotRemovalResult } from 'src/logic-functions/types/recall-bot-operation-result.type';
import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util';
import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util';
export const ejectRecallBot = async ({
externalBotId,
}: {
externalBotId: string;
}): Promise<RecallBotRemovalResult> => {
const configResult = getRecallApiConfig();
if (!configResult.success) {
return { ok: false, status: null, errorMessage: configResult.error };
}
const result = await recallBotApiRequest<undefined>({
config: configResult.config,
path: `/bot/${externalBotId}/leave_call/`,
method: 'POST',
allowNotFound: true,
});
if (!result.ok) {
return result;
}
return { ok: true };
};
@@ -0,0 +1,137 @@
import { isArray, isUndefined } from '@sniptt/guards';
import { type CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import { asRecord } from 'src/logic-functions/utils/as-record.util';
import { getString } from 'src/logic-functions/utils/get-string.util';
import { mapRecallStatusCodeToCallRecordingStatus } from 'src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util';
import { normalizeRecallTimestamp } from 'src/logic-functions/recall-api/normalize-recall-timestamp.util';
export type RecallBotConvergence = {
status: CallRecordingStatus | undefined;
startedAt: string | undefined;
endedAt: string | undefined;
externalRecordingId: string | undefined;
};
type RecallBotStatusChange = {
code: string;
createdAt: string | undefined;
};
// Derives the state a full webhook history would have produced from GET /bot.
export const extractRecallBotConvergence = (
bot: Record<string, unknown>,
): RecallBotConvergence => {
const statusChanges = extractStatusChanges(bot);
const latestStatusChange = getLatestStatusChange(statusChanges);
const recording = extractFirstRecording(bot);
return {
status: mapRecallStatusCodeToCallRecordingStatus(latestStatusChange?.code),
startedAt: normalizeRecallTimestamp(
recording?.startedAt ??
findStatusChangeTimestamp(statusChanges, 'in_call_recording'),
),
endedAt: normalizeRecallTimestamp(
recording?.completedAt ??
findStatusChangeTimestamp(statusChanges, 'call_ended'),
),
externalRecordingId: recording?.id,
};
};
const extractStatusChanges = (
bot: Record<string, unknown>,
): RecallBotStatusChange[] => {
if (!isArray(bot.status_changes)) {
return [];
}
return bot.status_changes.flatMap((statusChange: unknown) => {
const code = getString(asRecord(statusChange)?.code);
if (isUndefined(code)) {
return [];
}
return [{ code, createdAt: getString(asRecord(statusChange)?.created_at) }];
});
};
const getLatestStatusChange = (
statusChanges: RecallBotStatusChange[],
): RecallBotStatusChange | undefined =>
statusChanges.reduce<RecallBotStatusChange | undefined>(
(latestStatusChange, statusChange) => {
if (isUndefined(latestStatusChange)) {
return statusChange;
}
const statusChangeTime = getStatusChangeTime(statusChange);
const latestStatusChangeTime = getStatusChangeTime(latestStatusChange);
if (
isUndefined(statusChangeTime) &&
isUndefined(latestStatusChangeTime)
) {
return statusChange;
}
if (isUndefined(statusChangeTime)) {
return latestStatusChange;
}
if (isUndefined(latestStatusChangeTime)) {
return statusChange;
}
return statusChangeTime >= latestStatusChangeTime
? statusChange
: latestStatusChange;
},
undefined,
);
const getStatusChangeTime = (
statusChange: RecallBotStatusChange,
): number | undefined => {
const normalizedTimestamp = normalizeRecallTimestamp(statusChange.createdAt);
if (isUndefined(normalizedTimestamp)) {
return undefined;
}
return new Date(normalizedTimestamp).getTime();
};
const extractFirstRecording = (
bot: Record<string, unknown>,
):
| {
id: string | undefined;
startedAt: string | undefined;
completedAt: string | undefined;
}
| undefined => {
if (!isArray(bot.recordings)) {
return undefined;
}
const recording = asRecord(bot.recordings[0]);
if (isUndefined(recording)) {
return undefined;
}
return {
id: getString(recording.id),
startedAt: getString(recording.started_at),
completedAt: getString(recording.completed_at),
};
};
const findStatusChangeTimestamp = (
statusChanges: RecallBotStatusChange[],
code: string,
): string | undefined =>
statusChanges.find((statusChange) => statusChange.code === code)?.createdAt;
@@ -0,0 +1,42 @@
import { type RecallBotOperationFailure } from 'src/logic-functions/types/recall-bot-operation-result.type';
import { asRecord } from 'src/logic-functions/utils/as-record.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';
type GetRecallBotResult =
| { ok: true; bot: Record<string, unknown> }
| RecallBotOperationFailure;
export const getRecallBot = async ({
externalBotId,
}: {
externalBotId: string;
}): Promise<GetRecallBotResult> => {
const configResult = getRecallApiConfig();
if (!configResult.success) {
return { ok: false, status: null, errorMessage: configResult.error };
}
const result = await recallBotApiRequest<Record<string, unknown>>({
config: configResult.config,
path: `/bot/${externalBotId}/`,
method: 'GET',
});
if (!result.ok) {
return result;
}
const bot = asRecord(result.data);
if (bot === undefined) {
return {
ok: false,
status: result.status,
errorMessage: 'Recall API returned an empty bot response',
};
}
return { ok: true, bot };
};
@@ -0,0 +1,106 @@
import { isString, 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 { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util';
import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util';
export type RecallScheduledBot = {
id: string;
metadata: Record<string, unknown>;
};
type RecallBotListResponse = {
next?: unknown;
results?: unknown;
};
type ListScheduledRecallBotsResult =
| { ok: true; bots: RecallScheduledBot[] }
| RecallBotOperationFailure;
const RECALL_BOT_LIST_MAX_PAGES = 10;
export const listScheduledRecallBots = async ({
joinAtAfter,
joinAtBefore,
}: {
joinAtAfter: string;
joinAtBefore: string;
}): Promise<ListScheduledRecallBotsResult> => {
const configResult = getRecallApiConfig();
if (!configResult.success) {
return { ok: false, status: null, errorMessage: configResult.error };
}
const bots: RecallScheduledBot[] = [];
let path: string | undefined = `/bot/?join_at_after=${encodeURIComponent(
joinAtAfter,
)}&join_at_before=${encodeURIComponent(joinAtBefore)}`;
for (
let pageIndex = 0;
!isUndefined(path) && pageIndex < RECALL_BOT_LIST_MAX_PAGES;
pageIndex++
) {
const result = await recallBotApiRequest<RecallBotListResponse>({
config: configResult.config,
path,
method: 'GET',
});
if (!result.ok) {
return result;
}
bots.push(...extractRecallBots(result.data));
path = extractNextPath(result.data, configResult.config.baseUrl);
}
if (!isUndefined(path)) {
return {
ok: false,
status: null,
errorMessage: `Recall bot list exceeded ${RECALL_BOT_LIST_MAX_PAGES} pages`,
};
}
return { ok: true, bots };
};
const extractRecallBots = (
response: RecallBotListResponse | undefined,
): RecallScheduledBot[] => {
if (!Array.isArray(response?.results)) {
return [];
}
return response.results.flatMap((candidate: unknown) => {
const bot = asRecord(candidate);
if (isUndefined(bot) || !isString(bot.id)) {
return [];
}
return [
{
id: bot.id,
metadata: asRecord(bot.metadata) ?? {},
},
];
});
};
const extractNextPath = (
response: RecallBotListResponse | undefined,
baseUrl: string,
): string | undefined => {
const next = response?.next;
if (!isString(next) || !next.startsWith(baseUrl)) {
return undefined;
}
return next.slice(baseUrl.length);
};
@@ -0,0 +1,111 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import { STALE_BOT_STATE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/stale-bot-state-logic-function-universal-identifier';
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 {
healCallRecordingsMissingBot,
type HealCallRecordingsMissingBotResult,
} from 'src/logic-functions/flows/heal-call-recordings-missing-bot.util';
import {
reapOrphanedMeetingBots,
type ReapOrphanedMeetingBotsResult,
} from 'src/logic-functions/flows/reap-orphaned-meeting-bots.util';
// Every unwanted bot passes through this join_at window before it can attend.
const REAPER_JOIN_AT_LOOKBACK_HOURS = 4;
const REAPER_JOIN_AT_LOOKAHEAD_HOURS = 24;
type StepFailure = { error: string };
export const reconcileStaleBotStateHandler = async (): Promise<object> => {
const now = new Date();
const client = new CoreApiClient();
const botlessHealResult = await healCallRecordingsMissingBotSafely(
client,
now,
);
const orphanedBotReapingResult = await reapOrphanedMeetingBotsInJoinAtWindow(
client,
now,
);
const statusConvergenceResult = await convergeDivergedCallRecordingsSafely(
client,
now,
);
return {
botlessHealResult,
orphanedBotReapingResult,
statusConvergenceResult,
};
};
const healCallRecordingsMissingBotSafely = async (
client: CoreApiClient,
now: Date,
): Promise<HealCallRecordingsMissingBotResult | StepFailure> => {
try {
return await healCallRecordingsMissingBot({ client, now });
} catch (error) {
return buildStepFailure('botless call recording healing', error);
}
};
const reapOrphanedMeetingBotsInJoinAtWindow = async (
client: CoreApiClient,
now: Date,
): Promise<ReapOrphanedMeetingBotsResult | StepFailure> => {
try {
return await reapOrphanedMeetingBots({
client,
joinAtAfter: new Date(
now.getTime() - REAPER_JOIN_AT_LOOKBACK_HOURS * 60 * 60 * 1000,
).toISOString(),
joinAtBefore: new Date(
now.getTime() + REAPER_JOIN_AT_LOOKAHEAD_HOURS * 60 * 60 * 1000,
).toISOString(),
});
} catch (error) {
return buildStepFailure('orphaned bot reaping', error);
}
};
const convergeDivergedCallRecordingsSafely = async (
client: CoreApiClient,
now: Date,
): Promise<ConvergeDivergedCallRecordingsResult | StepFailure> => {
try {
return await convergeDivergedCallRecordings({ client, now });
} catch (error) {
return buildStepFailure('call recording status convergence', error);
}
};
const buildStepFailure = (stepLabel: string, error: unknown): StepFailure => {
const errorMessage = error instanceof Error ? error.message : String(error);
if (process.env.NODE_ENV !== 'test') {
console.error(`[twenty-meeting-bot] ${stepLabel} failed: ${errorMessage}`);
}
return { error: `${stepLabel} failed` };
};
export default defineLogicFunction({
universalIdentifier: STALE_BOT_STATE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'reconcile-stale-bot-state',
description:
'Converges call recordings with Recall on a schedule: pulls stale bot statuses, finishes failed cancellations, schedules bots for recordings still missing one, and reaps unclaimed bots. Reads calendar events only to heal already-decided recordings, never to discover meetings.',
// Pulling bot statuses for many recordings is the dominant cost.
timeoutSeconds: 300,
handler: reconcileStaleBotStateHandler,
cronTriggerSettings: {
pattern: STALE_BOT_STATE_CRON_PATTERN,
},
});