From c7ad1ff8ee2153ad769b0ff8722bbd22ba8efd2f Mon Sep 17 00:00:00 2001
From: nitin <142569587+ehconitin@users.noreply.github.com>
Date: Tue, 23 Jun 2026 13:57:52 +0530
Subject: [PATCH] Route Recall webhooks by workspace metadata (#21991)
---
.../__tests__/recall-webhook.test.ts | 50 +++++++++-
.../constants/application-id-env-var-name.ts | 2 -
.../get-current-workspace-id.test.ts | 39 ++++++++
.../data/get-current-workspace-id.util.ts | 36 +++++++
.../domain/build-recall-bot-metadata.util.ts | 15 +--
.../__tests__/handle-recall-webhook.test.ts | 66 ++++++-------
.../heal-call-recordings-missing-bot.test.ts | 15 +++
.../reap-orphaned-meeting-bots.test.ts | 95 +++++++++++--------
.../__tests__/reconcile-meeting-bot.test.ts | 10 ++
.../flows/ensure-meeting-bot.util.ts | 17 +++-
.../flows/reap-orphaned-meeting-bots.util.ts | 51 +++++-----
.../reschedule-call-recording-bot.util.ts | 17 +++-
.../__tests__/recall-bot-api.test.ts | 6 ++
.../src/logic-functions/recall-webhook.ts | 43 +++++++--
.../types/recall-bot-metadata.type.ts | 3 +-
15 files changed, 332 insertions(+), 133 deletions(-)
delete mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/application-id-env-var-name.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/get-current-workspace-id.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/get-current-workspace-id.util.ts
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/__tests__/recall-webhook.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/__tests__/recall-webhook.test.ts
index 313c796d6a..3a20df91e5 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/__tests__/recall-webhook.test.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/__tests__/recall-webhook.test.ts
@@ -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 => {
};
};
+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' });
});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/application-id-env-var-name.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/application-id-env-var-name.ts
deleted file mode 100644
index 5d125bc037..0000000000
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/application-id-env-var-name.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// Injected by the platform into every logic function execution.
-export const APPLICATION_ID_ENV_VAR_NAME = 'APPLICATION_ID';
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/get-current-workspace-id.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/get-current-workspace-id.test.ts
new file mode 100644
index 0000000000..4e061d9c29
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/get-current-workspace-id.test.ts
@@ -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 =>
+ [
+ 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);
+ });
+});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/get-current-workspace-id.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/get-current-workspace-id.util.ts
new file mode 100644
index 0000000000..4f0c892200
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/get-current-workspace-id.util.ts
@@ -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;
+ }
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-recall-bot-metadata.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-recall-bot-metadata.util.ts
index 93ec5eb3b0..ce8f6b240a 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-recall-bot-metadata.util.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-recall-bot-metadata.util.ts
@@ -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 }),
};
};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts
index 430c20cf4b..2b1e328970 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts
@@ -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({
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/heal-call-recordings-missing-bot.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/heal-call-recordings-missing-bot.test.ts
index 15f95362b1..4d732bd9c1 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/heal-call-recordings-missing-bot.test.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/heal-call-recordings-missing-bot.test.ts
@@ -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');
});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reap-orphaned-meeting-bots.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reap-orphaned-meeting-bots.test.ts
index d654b29564..e99d6d66a5 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reap-orphaned-meeting-bots.test.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reap-orphaned-meeting-bots.test.ts
@@ -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();
+ });
});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-meeting-bot.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-meeting-bot.test.ts
index f7bebea769..6729dcd74f 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-meeting-bot.test.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-meeting-bot.test.ts
@@ -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:
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/ensure-meeting-bot.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/ensure-meeting-bot.util.ts
index c9b9fc0e2e..165e9a88ae 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/ensure-meeting-bot.util.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/ensure-meeting-bot.util.ts
@@ -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) {
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reap-orphaned-meeting-bots.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reap-orphaned-meeting-bots.util.ts
index 099a5fc6c4..f7c8a35cbb 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reap-orphaned-meeting-bots.util.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reap-orphaned-meeting-bots.util.ts
@@ -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 = ({
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reschedule-call-recording-bot.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reschedule-call-recording-bot.util.ts
index c6a564cc69..f3be32c694 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reschedule-call-recording-bot.util.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reschedule-call-recording-bot.util.ts
@@ -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) {
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts
index 79951b16b7..6f4e912622 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts
@@ -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',
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-webhook.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-webhook.ts
index e1368efa91..4a9c817dd3 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-webhook.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-webhook.ts
@@ -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 & {
+ config: RecallWebhookLogicFunctionConfig;
+};
+
+export default recallWebhookLogicFunction;
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/recall-bot-metadata.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/recall-bot-metadata.type.ts
index 2f62cc5c00..196f08cf02 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/recall-bot-metadata.type.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/recall-bot-metadata.type.ts
@@ -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;
};