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:
+46
-4
@@ -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' });
|
||||
});
|
||||
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
// Injected by the platform into every logic function execution.
|
||||
export const APPLICATION_ID_ENV_VAR_NAME = 'APPLICATION_ID';
|
||||
+39
@@ -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);
|
||||
});
|
||||
});
|
||||
+36
@@ -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;
|
||||
}
|
||||
};
|
||||
+3
-12
@@ -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 }),
|
||||
};
|
||||
};
|
||||
|
||||
+28
-38
@@ -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({
|
||||
|
||||
+15
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
+55
-40
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+10
@@ -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:
|
||||
|
||||
+16
-1
@@ -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) {
|
||||
|
||||
+27
-24
@@ -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 = ({
|
||||
|
||||
+16
-1
@@ -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) {
|
||||
|
||||
+6
@@ -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',
|
||||
|
||||
+34
-9
@@ -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
-2
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user