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; };