Route Recall webhooks by workspace metadata (#21991)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21991?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-23 13:57:52 +05:30
committed by GitHub
parent ec3b9beae5
commit c7ad1ff8ee
15 changed files with 332 additions and 133 deletions
@@ -2,7 +2,9 @@ import { createHmac } from 'crypto';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { recallWebhookRouteHandler } from 'src/logic-functions/recall-webhook';
import recallWebhookLogicFunction, {
recallWebhookRouteHandler,
} from 'src/logic-functions/recall-webhook';
const getApplicationVariableValueMock = vi.hoisted(() => vi.fn());
const handleRecallWebhookMock = vi.hoisted(() => vi.fn());
@@ -24,6 +26,7 @@ vi.mock('twenty-client-sdk/core', () => ({
const SECRET_BYTES = Buffer.from('entry-test-secret');
const SECRET = `whsec_${SECRET_BYTES.toString('base64')}`;
const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
type RecallWebhookRoutePayload = Parameters<
typeof recallWebhookRouteHandler
@@ -51,6 +54,21 @@ const buildSignedHeaders = (rawBody: string): Record<string, string> => {
};
};
const buildRecordingDoneWebhookBody = () => ({
event: 'recording.done',
data: {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
},
},
recording: {
id: 'recall-recording-1',
},
},
});
describe('recallWebhookRouteHandler', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -60,6 +78,29 @@ describe('recallWebhookRouteHandler', () => {
handleRecallWebhookMock.mockResolvedValue({ status: 'updated' });
});
it('declares a server webhook resolver for Recall bot workspace metadata', () => {
expect(recallWebhookLogicFunction.success).toBe(true);
expect(
recallWebhookLogicFunction.config.httpRouteTriggerSettings,
).toBeUndefined();
expect(
recallWebhookLogicFunction.config.serverWebhookTriggerSettings,
).toEqual({
workspaceIdResolver: {
source: 'body',
path: 'data.bot.metadata.twentyWorkspaceId',
},
forwardedRequestHeaders: [
'webhook-id',
'webhook-timestamp',
'webhook-signature',
'svix-id',
'svix-timestamp',
'svix-signature',
],
});
});
it('responds 500 when the webhook secret is not configured', async () => {
getApplicationVariableValueMock.mockReturnValue(undefined);
@@ -133,19 +174,20 @@ describe('recallWebhookRouteHandler', () => {
});
it('dispatches a correctly signed payload to the handler', async () => {
const rawBody = JSON.stringify({ event: 'recording.done' });
const body = buildRecordingDoneWebhookBody();
const rawBody = JSON.stringify(body);
const result = await recallWebhookRouteHandler(
buildRoutePayload({
rawBody,
body: { event: 'recording.done' },
body,
headers: buildSignedHeaders(rawBody),
}),
);
expect(handleRecallWebhookMock).toHaveBeenCalledTimes(1);
expect(handleRecallWebhookMock).toHaveBeenCalledWith(
expect.objectContaining({ body: { event: 'recording.done' } }),
expect.objectContaining({ body }),
);
expect(result).toEqual({ status: 'updated' });
});
@@ -1,2 +0,0 @@
// Injected by the platform into every logic function execution.
export const APPLICATION_ID_ENV_VAR_NAME = 'APPLICATION_ID';
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it } from 'vitest';
import { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.util';
const APP_ACCESS_TOKEN_ENV_VAR_NAME = 'TWENTY_APP_ACCESS_TOKEN';
const ORIGINAL_APP_ACCESS_TOKEN =
process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME];
const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
const restoreOriginalAppAccessToken = () => {
if (ORIGINAL_APP_ACCESS_TOKEN === undefined) {
delete process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME];
return;
}
process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME] = ORIGINAL_APP_ACCESS_TOKEN;
};
const buildAccessToken = (payload: Record<string, unknown>): string =>
[
Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'),
Buffer.from(JSON.stringify(payload)).toString('base64url'),
'signature',
].join('.');
describe('getCurrentWorkspaceId', () => {
afterEach(() => {
restoreOriginalAppAccessToken();
});
it('reads the workspace id from the app access token payload', () => {
process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME] = buildAccessToken({
workspaceId: WORKSPACE_ID,
});
expect(getCurrentWorkspaceId()).toBe(WORKSPACE_ID);
});
});
@@ -0,0 +1,36 @@
import { isUndefined } from '@sniptt/guards';
import { asRecord } from 'src/logic-functions/utils/as-record.util';
import { getString } from 'src/logic-functions/utils/get-string.util';
const APP_ACCESS_TOKEN_ENV_VAR_NAME = 'TWENTY_APP_ACCESS_TOKEN';
export const getCurrentWorkspaceId = (): string | undefined => {
const accessToken = getString(process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME]);
if (isUndefined(accessToken)) {
return undefined;
}
return getWorkspaceIdFromAccessToken(accessToken);
};
const getWorkspaceIdFromAccessToken = (
accessToken: string,
): string | undefined => {
const encodedPayload = accessToken.split('.')[1];
if (isUndefined(encodedPayload)) {
return undefined;
}
try {
const payload = asRecord(
JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')),
);
return getString(payload?.workspaceId);
} catch {
return undefined;
}
};
@@ -1,20 +1,14 @@
import { isUndefined } from '@sniptt/guards';
import { APPLICATION_ID_ENV_VAR_NAME } from 'src/logic-functions/constants/application-id-env-var-name';
import { type MeetingRecording } from 'src/logic-functions/types/meeting-recording.type';
import { type RecallBotMetadata } from 'src/logic-functions/types/recall-bot-metadata.type';
import { computeRealMeetingKey } from 'src/logic-functions/domain/compute-real-meeting-key.util';
import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util';
export const buildRecallBotMetadata = ({
callRecording,
calendarEvent,
}: MeetingRecording): RecallBotMetadata => {
const applicationId = getApplicationVariableValue(
APPLICATION_ID_ENV_VAR_NAME,
);
workspaceId,
}: MeetingRecording & { workspaceId: string }): RecallBotMetadata => {
return {
twentyWorkspaceId: workspaceId,
twentyCallRecordingId: callRecording.id,
twentyCalendarEventId: calendarEvent.id,
twentyRealMeetingKey: computeRealMeetingKey({
@@ -23,8 +17,5 @@ export const buildRecallBotMetadata = ({
iCalUid: calendarEvent.iCalUid,
startsAt: calendarEvent.startsAt,
}),
...(isUndefined(applicationId)
? {}
: { twentyApplicationId: applicationId }),
};
};
@@ -3,6 +3,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { handleRecallWebhook } from 'src/logic-functions/flows/handle-recall-webhook.util';
const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
const buildRecordingDoneWebhookBody = () => ({
event: 'recording.done',
data: {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
},
},
recording: {
id: 'recall-recording-1',
},
},
});
const getRecallBotMock = vi.hoisted(() => vi.fn());
const listRecallTranscriptsMock = vi.hoisted(() => vi.fn());
const createAsyncRecallTranscriptMock = vi.hoisted(() => vi.fn());
@@ -331,7 +348,7 @@ describe('handleRecallWebhook', () => {
]);
});
it('falls back to external bot id matching when metadata is absent', async () => {
it('falls back to external bot id matching when call recording metadata is absent', async () => {
const client = new FakeCoreApiClient([
{
id: 'call-recording-1',
@@ -345,7 +362,12 @@ describe('handleRecallWebhook', () => {
body: {
event: 'recording.done',
data: {
bot_id: 'recall-bot-1',
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
},
},
recording: {
id: 'recall-recording-1',
},
@@ -727,15 +749,7 @@ describe('handleRecallWebhook', () => {
await handleRecallWebhook({
client: client as unknown as CoreApiClient,
body: {
event: 'recording.done',
data: {
bot_id: 'recall-bot-1',
recording: {
id: 'recall-recording-1',
},
},
},
body: buildRecordingDoneWebhookBody(),
});
expect(createAsyncRecallTranscriptMock).toHaveBeenCalledTimes(1);
@@ -777,15 +791,7 @@ describe('handleRecallWebhook', () => {
await handleRecallWebhook({
client: client as unknown as CoreApiClient,
body: {
event: 'recording.done',
data: {
bot_id: 'recall-bot-1',
recording: {
id: 'recall-recording-1',
},
},
},
body: buildRecordingDoneWebhookBody(),
});
expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled();
@@ -887,15 +893,7 @@ describe('handleRecallWebhook', () => {
await handleRecallWebhook({
client: client as unknown as CoreApiClient,
body: {
event: 'recording.done',
data: {
bot_id: 'recall-bot-1',
recording: {
id: 'recall-recording-1',
},
},
},
body: buildRecordingDoneWebhookBody(),
});
expect(ingestCallRecordingMediaMock).toHaveBeenCalledWith({
@@ -951,15 +949,7 @@ describe('handleRecallWebhook', () => {
await handleRecallWebhook({
client: client as unknown as CoreApiClient,
body: {
event: 'recording.done',
data: {
bot_id: 'recall-bot-1',
recording: {
id: 'recall-recording-1',
},
},
},
body: buildRecordingDoneWebhookBody(),
});
expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({
@@ -4,12 +4,18 @@ 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());
const getCurrentWorkspaceIdMock = vi.hoisted(() => vi.fn());
vi.mock('src/logic-functions/data/get-current-workspace-id.util', () => ({
getCurrentWorkspaceId: getCurrentWorkspaceIdMock,
}));
vi.mock('src/logic-functions/recall-api/schedule-recall-bot.util', () => ({
scheduleRecallBot: scheduleRecallBotMock,
}));
const NOW = new Date('2026-01-01T12:00:00.000Z');
const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
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';
@@ -127,6 +133,8 @@ const buildCalendarEvent = (
describe('healCallRecordingsMissingBot', () => {
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
getCurrentWorkspaceIdMock.mockReset();
getCurrentWorkspaceIdMock.mockReturnValue(WORKSPACE_ID);
scheduleRecallBotMock.mockReset();
scheduleRecallBotMock.mockResolvedValue({
ok: true,
@@ -147,6 +155,13 @@ describe('healCallRecordingsMissingBot', () => {
expect(result.scheduledCallRecordingIds).toEqual(['call-recording-1']);
expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1);
expect(scheduleRecallBotMock).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
twentyWorkspaceId: WORKSPACE_ID,
}),
}),
);
expect(client.callRecordings[0].externalBotId).toBe('recall-bot-1');
});
@@ -1,12 +1,16 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { 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());
const getCurrentWorkspaceIdMock = vi.hoisted(() => vi.fn());
vi.mock('src/logic-functions/data/get-current-workspace-id.util', () => ({
getCurrentWorkspaceId: getCurrentWorkspaceIdMock,
}));
vi.mock(
'src/logic-functions/recall-api/list-scheduled-recall-bots.util',
@@ -25,8 +29,8 @@ vi.mock('src/logic-functions/recall-api/eject-recall-bot.util', () => ({
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];
const CURRENT_WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
const OTHER_WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174999';
type CallRecordingNode = {
id: string;
@@ -56,33 +60,23 @@ class FakeCoreApiClient {
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,
twentyWorkspaceId,
}: {
id: string;
twentyCallRecordingId?: string;
twentyApplicationId?: string;
twentyWorkspaceId?: string;
}) => ({
id,
metadata: {
...(twentyCallRecordingId === undefined ? {} : { twentyCallRecordingId }),
...(twentyApplicationId === undefined ? {} : { twentyApplicationId }),
...(twentyWorkspaceId === undefined ? {} : { twentyWorkspaceId }),
},
});
const buildCurrentApplicationBot = ({
const buildCurrentWorkspaceBot = ({
id,
twentyCallRecordingId,
}: {
@@ -92,14 +86,14 @@ const buildCurrentApplicationBot = ({
buildBot({
id,
twentyCallRecordingId,
twentyApplicationId: CURRENT_APPLICATION_ID,
twentyWorkspaceId: CURRENT_WORKSPACE_ID,
});
describe('reapOrphanedMeetingBots', () => {
beforeEach(() => {
restoreOriginalApplicationId();
process.env[APPLICATION_ID_ENV_VAR_NAME] = CURRENT_APPLICATION_ID;
vi.spyOn(console, 'warn').mockImplementation(() => {});
getCurrentWorkspaceIdMock.mockReset();
getCurrentWorkspaceIdMock.mockReturnValue(CURRENT_WORKSPACE_ID);
listScheduledRecallBotsMock.mockReset();
cancelRecallBotMock.mockReset();
cancelRecallBotMock.mockResolvedValue({ ok: true });
@@ -107,15 +101,11 @@ describe('reapOrphanedMeetingBots', () => {
ejectRecallBotMock.mockResolvedValue({ ok: true });
});
afterEach(() => {
restoreOriginalApplicationId();
});
it('keeps bots that their call recording still references', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [
buildCurrentApplicationBot({
buildCurrentWorkspaceBot({
id: 'claimed-bot',
twentyCallRecordingId: 'call-recording-1',
}),
@@ -145,7 +135,7 @@ describe('reapOrphanedMeetingBots', () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [
buildCurrentApplicationBot({
buildCurrentWorkspaceBot({
id: 'stale-cancel-bot',
twentyCallRecordingId: 'call-recording-1',
}),
@@ -177,11 +167,11 @@ describe('reapOrphanedMeetingBots', () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [
buildCurrentApplicationBot({
buildCurrentWorkspaceBot({
id: 'superseded-bot',
twentyCallRecordingId: 'call-recording-1',
}),
buildCurrentApplicationBot({
buildCurrentWorkspaceBot({
id: 'claimed-bot',
twentyCallRecordingId: 'call-recording-1',
}),
@@ -214,7 +204,7 @@ describe('reapOrphanedMeetingBots', () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [
buildCurrentApplicationBot({
buildCurrentWorkspaceBot({
id: 'orphan-bot',
twentyCallRecordingId: 'call-recording-gone',
}),
@@ -237,7 +227,7 @@ describe('reapOrphanedMeetingBots', () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [
buildCurrentApplicationBot({
buildCurrentWorkspaceBot({
id: 'pending-bot',
twentyCallRecordingId: 'call-recording-1',
}),
@@ -306,14 +296,14 @@ describe('reapOrphanedMeetingBots', () => {
expect(cancelRecallBotMock).not.toHaveBeenCalled();
});
it('ignores bots claimed by another application registration', async () => {
it('ignores bots claimed by another workspace', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [
buildBot({
id: 'other-app-bot',
id: 'other-workspace-bot',
twentyCallRecordingId: 'call-recording-gone',
twentyApplicationId: 'other-application-id',
twentyWorkspaceId: OTHER_WORKSPACE_ID,
}),
],
});
@@ -331,12 +321,12 @@ describe('reapOrphanedMeetingBots', () => {
expect(cancelRecallBotMock).not.toHaveBeenCalled();
});
it('cancels orphaned bots claimed by this application registration', async () => {
it('cancels orphaned bots claimed by this workspace', async () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [
buildCurrentApplicationBot({
id: 'same-app-bot',
buildCurrentWorkspaceBot({
id: 'same-workspace-bot',
twentyCallRecordingId: 'call-recording-gone',
}),
],
@@ -350,10 +340,10 @@ describe('reapOrphanedMeetingBots', () => {
expect(result).toEqual({
scannedBotCount: 1,
canceledExternalBotIds: ['same-app-bot'],
canceledExternalBotIds: ['same-workspace-bot'],
});
expect(cancelRecallBotMock).toHaveBeenCalledWith({
externalBotId: 'same-app-bot',
externalBotId: 'same-workspace-bot',
});
});
@@ -361,7 +351,7 @@ describe('reapOrphanedMeetingBots', () => {
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [
buildCurrentApplicationBot({
buildCurrentWorkspaceBot({
id: 'in-call-orphan',
twentyCallRecordingId: 'call-recording-gone',
}),
@@ -407,4 +397,29 @@ describe('reapOrphanedMeetingBots', () => {
});
expect(cancelRecallBotMock).not.toHaveBeenCalled();
});
it('skips reaping when the current workspace cannot be resolved', async () => {
getCurrentWorkspaceIdMock.mockReturnValue(undefined);
listScheduledRecallBotsMock.mockResolvedValue({
ok: true,
bots: [
buildCurrentWorkspaceBot({
id: 'same-workspace-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();
});
});
@@ -7,6 +7,11 @@ import { reconcileMeetingBotForCalendarEventIds } from 'src/logic-functions/flow
const scheduleRecallBotMock = vi.hoisted(() => vi.fn());
const rescheduleRecallBotMock = vi.hoisted(() => vi.fn());
const cancelRecallBotMock = vi.hoisted(() => vi.fn());
const getCurrentWorkspaceIdMock = vi.hoisted(() => vi.fn());
vi.mock('src/logic-functions/data/get-current-workspace-id.util', () => ({
getCurrentWorkspaceId: getCurrentWorkspaceIdMock,
}));
vi.mock('src/logic-functions/recall-api/schedule-recall-bot.util', () => ({
scheduleRecallBot: scheduleRecallBotMock,
@@ -21,6 +26,7 @@ vi.mock('src/logic-functions/recall-api/cancel-recall-bot.util', () => ({
}));
const NOW = new Date('2026-01-01T12:00:00.000Z');
const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
const FUTURE_STARTS_AT = '2026-01-01T13:00:00.000Z';
const FUTURE_RECALL_BOT_JOIN_AT = '2026-01-01T12:59:00.000Z';
const FUTURE_ENDS_AT = '2026-01-01T14:00:00.000Z';
@@ -207,6 +213,8 @@ describe('reconcileMeetingBotForCalendarEventIds', () => {
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
getCurrentWorkspaceIdMock.mockReset();
getCurrentWorkspaceIdMock.mockReturnValue(WORKSPACE_ID);
scheduleRecallBotMock.mockReset();
scheduleRecallBotMock.mockResolvedValue({
ok: true,
@@ -255,6 +263,7 @@ describe('reconcileMeetingBotForCalendarEventIds', () => {
meetingUrl: 'https://meet.example.com/customer-sync',
joinAt: FUTURE_RECALL_BOT_JOIN_AT,
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: buildCustomerSyncCallRecordingId(),
twentyCalendarEventId: 'calendar-event-1',
twentyRealMeetingKey:
@@ -405,6 +414,7 @@ describe('reconcileMeetingBotForCalendarEventIds', () => {
meetingUrl: 'https://meet.example.com/customer-sync',
joinAt: FUTURE_RECALL_BOT_JOIN_AT,
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: buildCustomerSyncCallRecordingId(),
twentyCalendarEventId: 'calendar-event-1',
twentyRealMeetingKey:
@@ -6,6 +6,7 @@ import { type MeetingRecording } from 'src/logic-functions/types/meeting-recordi
import { buildRecallBotMetadata } from 'src/logic-functions/domain/build-recall-bot-metadata.util';
import { computeRecallBotJoinAt } from 'src/logic-functions/domain/compute-recall-bot-join-at.util';
import { findCallRecordingsByIds } from 'src/logic-functions/data/find-call-recordings-by-ids.util';
import { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.util';
import { scheduleRecallBot } from 'src/logic-functions/recall-api/schedule-recall-bot.util';
import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util';
@@ -36,10 +37,24 @@ export const ensureMeetingBot = async (
return false;
}
const workspaceId = getCurrentWorkspaceId();
if (isUndefined(workspaceId)) {
console.error(
`[twenty-meeting-bot] cannot schedule Recall bot for callRecording ${callRecording.id}: workspace id unavailable, the shared webhook could not be routed back`,
);
return false;
}
const scheduleResult = await scheduleRecallBot({
meetingUrl,
joinAt,
metadata: buildRecallBotMetadata({ callRecording, calendarEvent }),
metadata: buildRecallBotMetadata({
callRecording,
calendarEvent,
workspaceId,
}),
});
if (!scheduleResult.ok) {
@@ -1,13 +1,12 @@
import { isNull, isUndefined } from '@sniptt/guards';
import { type 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 { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.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 {
@@ -43,12 +42,24 @@ export const reapOrphanedMeetingBots = async ({
return { scannedBotCount: 0, canceledExternalBotIds: [] };
}
const currentApplicationId = getCurrentApplicationId();
const appManagedBots = listResult.bots.filter((bot) =>
isCurrentApplicationManagedBot({ bot, currentApplicationId }),
const currentWorkspaceId = getCurrentWorkspaceId();
if (isUndefined(currentWorkspaceId)) {
console.warn(
'[twenty-meeting-bot] cannot reap orphaned Recall bots: workspace id unavailable',
);
return {
scannedBotCount: listResult.bots.length,
canceledExternalBotIds: [],
};
}
const workspaceManagedBots = listResult.bots.filter((bot) =>
isCurrentWorkspaceManagedBot({ bot, currentWorkspaceId }),
);
if (appManagedBots.length === 0) {
if (workspaceManagedBots.length === 0) {
return {
scannedBotCount: listResult.bots.length,
canceledExternalBotIds: [],
@@ -58,7 +69,7 @@ export const reapOrphanedMeetingBots = async ({
const callRecordings = await findCallRecordingsByIds(
client,
getUniqueSortedIds(
appManagedBots.map((bot) => getClaimedCallRecordingId(bot)),
workspaceManagedBots.map((bot) => getClaimedCallRecordingId(bot)),
),
);
const callRecordingsById = new Map(
@@ -66,7 +77,7 @@ export const reapOrphanedMeetingBots = async ({
);
const canceledExternalBotIds: string[] = [];
for (const bot of appManagedBots) {
for (const bot of workspaceManagedBots) {
const claimedCallRecordingId = getClaimedCallRecordingId(bot);
const callRecording = isUndefined(claimedCallRecordingId)
? undefined
@@ -99,36 +110,28 @@ const getClaimedCallRecordingId = (
return normalizeOptionalString(claimedCallRecordingId);
};
const getClaimedApplicationId = (
const getClaimedWorkspaceId = (
bot: RecallScheduledBot,
): string | undefined => {
const claimedApplicationId = bot.metadata.twentyApplicationId;
const claimedWorkspaceId = bot.metadata.twentyWorkspaceId;
return normalizeOptionalString(claimedApplicationId);
return normalizeOptionalString(claimedWorkspaceId);
};
const getCurrentApplicationId = (): string | undefined =>
normalizeOptionalString(
getApplicationVariableValue(APPLICATION_ID_ENV_VAR_NAME),
);
const isCurrentApplicationManagedBot = ({
const isCurrentWorkspaceManagedBot = ({
bot,
currentApplicationId,
currentWorkspaceId,
}: {
bot: RecallScheduledBot;
currentApplicationId: string | undefined;
currentWorkspaceId: string;
}): boolean => {
if (isUndefined(getClaimedCallRecordingId(bot))) {
return false;
}
const claimedApplicationId = getClaimedApplicationId(bot);
const claimedWorkspaceId = getClaimedWorkspaceId(bot);
return (
!isUndefined(currentApplicationId) &&
claimedApplicationId === currentApplicationId
);
return claimedWorkspaceId === currentWorkspaceId;
};
const isBotClaimed = ({
@@ -4,6 +4,7 @@ import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type MeetingRecording } from 'src/logic-functions/types/meeting-recording.type';
import { buildRecallBotMetadata } from 'src/logic-functions/domain/build-recall-bot-metadata.util';
import { computeRecallBotJoinAt } from 'src/logic-functions/domain/compute-recall-bot-join-at.util';
import { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.util';
import { rescheduleRecallBot } from 'src/logic-functions/recall-api/reschedule-recall-bot.util';
import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util';
@@ -28,11 +29,25 @@ export const rescheduleCallRecordingBot = async (
const joinAt = computeRecallBotJoinAt(meetingStartsAt);
const workspaceId = getCurrentWorkspaceId();
if (isUndefined(workspaceId)) {
console.warn(
`[twenty-meeting-bot] cannot reschedule Recall bot for callRecording ${callRecording.id}: workspace id unavailable`,
);
return;
}
const rescheduleResult = await rescheduleRecallBot({
externalBotId,
meetingUrl,
joinAt,
metadata: buildRecallBotMetadata({ callRecording, calendarEvent }),
metadata: buildRecallBotMetadata({
callRecording,
calendarEvent,
workspaceId,
}),
});
if (rescheduleResult.ok) {
@@ -11,6 +11,7 @@ import { retrieveRecallTranscript } from 'src/logic-functions/recall-api/retriev
import { scheduleRecallBot } from 'src/logic-functions/recall-api/schedule-recall-bot.util';
const getRecallApiConfigMock = vi.hoisted(() => vi.fn());
const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
vi.mock('src/logic-functions/recall-api/get-recall-api-config.util', () => ({
getRecallApiConfig: getRecallApiConfigMock,
@@ -43,6 +44,7 @@ describe('recall bot api', () => {
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-01T13:00:00.000Z',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
@@ -69,6 +71,7 @@ describe('recall bot api', () => {
audio_mixed_mp3: {},
},
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
@@ -87,6 +90,7 @@ describe('recall bot api', () => {
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-01T13:00:00.000Z',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
@@ -113,6 +117,7 @@ describe('recall bot api', () => {
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-01T13:00:00.000Z',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
@@ -141,6 +146,7 @@ describe('recall bot api', () => {
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-01T13:00:00.000Z',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
@@ -1,15 +1,31 @@
import { isNull, isUndefined } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import {
defineLogicFunction,
type LogicFunctionConfig,
type RoutePayload,
} from 'twenty-sdk/define';
import { Response } from 'twenty-sdk/logic-function';
import { RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/recall-webhook-logic-function-universal-identifier';
import { RECALL_WEBHOOK_SECRET_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-webhook-secret-env-var-name';
import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util';
import { handleRecallWebhook } from 'src/logic-functions/flows/handle-recall-webhook.util';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
import { type RecallWebhookBody } from 'src/logic-functions/recall-api/parse-recall-webhook-event.util';
import { verifyRecallWebhookSignature } from 'src/logic-functions/recall-api/verify-recall-webhook-signature.util';
import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
type ServerWebhookTriggerSettings = {
workspaceIdResolver: {
source: 'body' | 'query' | 'header';
path: string;
};
forwardedRequestHeaders?: string[];
};
type RecallWebhookLogicFunctionConfig = LogicFunctionConfig & {
serverWebhookTriggerSettings: ServerWebhookTriggerSettings;
};
// Non-2xx makes Svix retry; a returned plain object would 200-ack permanently.
const rejectWebhook = (status: number, error: string): Response => {
@@ -64,17 +80,18 @@ export const recallWebhookRouteHandler = async (
});
};
export default defineLogicFunction({
const recallWebhookLogicFunctionConfig = {
universalIdentifier: RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'recall-webhook',
description:
'Receives Recall.ai webhook events and updates the matching CallRecording lifecycle status.',
timeoutSeconds: 30,
handler: recallWebhookRouteHandler,
httpRouteTriggerSettings: {
path: '/webhook/recall',
httpMethod: 'POST',
isAuthRequired: false,
serverWebhookTriggerSettings: {
workspaceIdResolver: {
source: 'body',
path: 'data.bot.metadata.twentyWorkspaceId',
},
forwardedRequestHeaders: [
'webhook-id',
'webhook-timestamp',
@@ -84,4 +101,12 @@ export default defineLogicFunction({
'svix-signature',
],
},
});
} satisfies RecallWebhookLogicFunctionConfig;
const recallWebhookLogicFunction = defineLogicFunction(
recallWebhookLogicFunctionConfig,
) as ReturnType<typeof defineLogicFunction> & {
config: RecallWebhookLogicFunctionConfig;
};
export default recallWebhookLogicFunction;
@@ -1,7 +1,6 @@
export type RecallBotMetadata = {
twentyWorkspaceId: string;
twentyCallRecordingId: string;
twentyCalendarEventId: string;
twentyRealMeetingKey: string;
// Workspace dispatch key for a future host-level webhook ingress.
twentyApplicationId?: string;
};