Add call recording scheduling backend (#21629)

This PR adds the backend scheduling slice for call recording. It wires
the `twenty-meeting-bot` internal app to reconcile calendar events,
calendar-channel associations, and workspace member auto-record
preference changes, then schedule, cancel, or reschedule Recall bots
based on the resulting policy.

It also adds the needed calendar-channel owner lookup support, generated
metadata updates, app config/default role updates, unit tests, and CI
for the internal app.

Coming next:
- Recall webhook handling and signature validation
- Stale-state convergence for failed Recall cleanup/recreate cases
- Media, transcript, audio, and video ingestion
- Billing charge flow
- Frontend/settings UI for recording controls

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21629?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
nitin
2026-06-16 14:17:09 +05:30
committed by GitHub
parent 065b6efe11
commit 12b1dba986
82 changed files with 3892 additions and 53 deletions
@@ -0,0 +1,111 @@
name: CI Internal App Twenty Meeting Bot
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/twenty-apps/internal/twenty-meeting-bot/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
packages/twenty-server/**
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
!packages/twenty-server/package.json
internal-app-twenty-meeting-bot:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build SDK packages
run: npx nx build twenty-sdk
# The integration tests import the CoreApiClient that `appDevOnce`
# generates into the app's own node_modules/twenty-client-sdk; without
# this install the import falls back to the workspace stub and throws.
- name: Install app dependencies
working-directory: packages/twenty-apps/internal/twenty-meeting-bot
run: yarn install --immutable
- name: Typecheck
working-directory: packages/twenty-apps/internal/twenty-meeting-bot
run: npx tsc --build tsconfig.json --force
- name: Run unit tests
working-directory: packages/twenty-apps/internal/twenty-meeting-bot
run: npx vitest run --config vitest.unit.config.ts
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: Run integration tests
working-directory: packages/twenty-apps/internal/twenty-meeting-bot
run: npx vitest run
ci-internal-app-twenty-meeting-bot-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, internal-app-twenty-meeting-bot]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
@@ -14,9 +14,12 @@
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:unit": "vitest run --config vitest.unit.config.ts",
"test:unit:watch": "vitest --config vitest.unit.config.ts",
"test:watch": "vitest"
},
"dependencies": {
"@sniptt/guards": "^0.2.0",
"twenty-client-sdk": "2.13.0"
},
"devDependencies": {
@@ -1,8 +1,11 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/application-universal-identifier';
import { describe, expect, it } from 'vitest';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/application-universal-identifier';
import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
@@ -24,21 +27,60 @@ describe('App installation', () => {
});
});
describe('CoreApiClient', () => {
it('should support CRUD on standard objects', async () => {
describe('CallRecording status contract', () => {
it('accepts every status and request status value the app mirrors', async () => {
const client = new CoreApiClient();
const created = await client.mutation({
createNote: {
__args: { data: { title: 'Integration test note' } },
createCallRecording: {
__args: {
data: {
title: 'Integration test recording',
status: CallRecordingStatus.SCHEDULED,
recordingRequestStatus: CallRecordingRequestStatus.REQUESTED,
},
},
id: true,
},
});
expect(created.createNote.id).toBeDefined();
const callRecordingId = created.createCallRecording?.id;
expect(callRecordingId).toBeDefined();
if (callRecordingId === undefined) {
throw new Error('Expected call recording creation to return an id');
}
for (const status of Object.values(CallRecordingStatus)) {
const updated = await client.mutation({
updateCallRecording: {
__args: { id: callRecordingId, data: { status } },
status: true,
},
});
expect(updated.updateCallRecording?.status).toBe(status);
}
for (const recordingRequestStatus of Object.values(
CallRecordingRequestStatus,
)) {
const updated = await client.mutation({
updateCallRecording: {
__args: { id: callRecordingId, data: { recordingRequestStatus } },
recordingRequestStatus: true,
},
});
expect(updated.updateCallRecording?.recordingRequestStatus).toBe(
recordingRequestStatus,
);
}
await client.mutation({
destroyNote: {
__args: { id: created.createNote.id },
destroyCallRecording: {
__args: { id: callRecordingId },
id: true,
},
});
@@ -3,10 +3,36 @@ import { defineApplication } from 'twenty-sdk/define';
import { APP_DESCRIPTION } from 'src/constants/app-description';
import { APP_DISPLAY_NAME } from 'src/constants/app-display-name';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/application-universal-identifier';
import { RECALL_BOT_NAME_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/recall-bot-name-app-variable-universal-identifier';
import { DEFAULT_RECALL_BOT_NAME } from 'src/logic-functions/constants/default-recall-bot-name';
import { DEFAULT_RECALL_REGION } from 'src/logic-functions/constants/default-recall-region';
import { RECALL_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-api-key-env-var-name';
import { RECALL_BOT_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-bot-name-env-var-name';
import { RECALL_REGION_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-region-env-var-name';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
logoUrl: 'public/logo.svg',
applicationVariables: {
[RECALL_BOT_NAME_ENV_VAR_NAME]: {
universalIdentifier: RECALL_BOT_NAME_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
description: 'Display name used when scheduling Recall.ai meeting bots.',
isSecret: false,
value: DEFAULT_RECALL_BOT_NAME,
},
},
serverVariables: {
[RECALL_API_KEY_ENV_VAR_NAME]: {
description:
'Recall.ai API key for the configured region. Set by the server admin on this registration after installation; used to create, update, and cancel scheduled meeting bots.',
isSecret: true,
isRequired: true,
},
[RECALL_REGION_ENV_VAR_NAME]: {
description: `Recall.ai region used for API requests. Defaults to ${DEFAULT_RECALL_REGION} when unset. Asia Pacific Tokyo is ap-northeast-1.`,
isSecret: false,
},
},
});
@@ -0,0 +1,2 @@
export const CALENDAR_EVENT_RECONCILIATION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'1f28c477-6423-4911-85bf-2296ef112be9';
@@ -1,2 +0,0 @@
export const MEETING_BOT_PREFERENCE_AUTO_OPTION_ID =
'72431216-49c4-47c8-99af-de4c3831b0be';
@@ -1,2 +1,2 @@
export const MEETING_BOT_PREFERENCE_ON_OPTION_ID =
'd7b437b1-d6a3-4e99-8bb8-ba632d4a544e';
'72431216-49c4-47c8-99af-de4c3831b0be';
@@ -1,5 +1,4 @@
export enum MeetingBotPreference {
AUTO = 'AUTO',
ON = 'ON',
OFF = 'OFF',
}
@@ -0,0 +1,2 @@
export const RECALL_BOT_NAME_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'c54cbacd-ad10-40b4-9056-7aaf23846d64';
@@ -1,6 +1,5 @@
import {
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
SystemPermissionFlag,
defineApplicationRole,
} from 'twenty-sdk/define';
@@ -11,7 +10,7 @@ export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default role`,
description:
'Reads calendar events, their calendar channel associations, and workspace member auto-record settings to decide whether the meeting bot should attend a meeting; writes and converges the resulting CallRecording records and serves the transcript viewer front component.',
'Reads calendar events to decide whether the meeting bot should attend a meeting; writes the resulting CallRecording records.',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
@@ -29,24 +28,6 @@ export default defineApplicationRole({
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarChannelEventAssociation
.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember
.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
@@ -57,10 +38,4 @@ export default defineApplicationRole({
},
],
fieldPermissions: [],
// UPLOAD_FILE: media ingestion uploads Recall artifacts into FILES fields.
// CONNECTED_ACCOUNTS: calendarChannelOwners resolves whose calendar synced a meeting.
permissionFlagUniversalIdentifiers: [
SystemPermissionFlag.UPLOAD_FILE,
SystemPermissionFlag.CONNECTED_ACCOUNTS,
],
});
@@ -5,10 +5,9 @@ import {
} from 'twenty-sdk/define';
import { MeetingBotPreference } from 'src/constants/meeting-bot-preference';
import { MEETING_BOT_PREFERENCE_AUTO_OPTION_ID } from 'src/constants/meeting-bot-preference-auto-option-id';
import { MEETING_BOT_PREFERENCE_OFF_OPTION_ID } from 'src/constants/meeting-bot-preference-off-option-id';
import { MEETING_BOT_PREFERENCE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/meeting-bot-preference-on-calendar-event-field-universal-identifier';
import { MEETING_BOT_PREFERENCE_ON_OPTION_ID } from 'src/constants/meeting-bot-preference-on-option-id';
import { MEETING_BOT_PREFERENCE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/meeting-bot-preference-on-calendar-event-field-universal-identifier';
export default defineField({
universalIdentifier:
@@ -19,30 +18,23 @@ export default defineField({
name: 'meetingBotPreference',
label: 'Recording Bot',
description:
'Whether the meeting bot records this event. Auto follows the auto-record settings of participating workspace members.',
'Meeting bot recording is on by default when the app is installed. Turn it off for this event when needed.',
icon: 'IconRobot',
isNullable: false,
defaultValue: `'${MeetingBotPreference.AUTO}'`,
defaultValue: `'${MeetingBotPreference.ON}'`,
options: [
{
id: MEETING_BOT_PREFERENCE_AUTO_OPTION_ID,
value: MeetingBotPreference.AUTO,
label: 'Auto',
position: 0,
color: 'gray',
},
{
id: MEETING_BOT_PREFERENCE_ON_OPTION_ID,
value: MeetingBotPreference.ON,
label: 'Recording on',
position: 1,
label: 'On',
position: 0,
color: 'green',
},
{
id: MEETING_BOT_PREFERENCE_OFF_OPTION_ID,
value: MeetingBotPreference.OFF,
label: 'Recording off',
position: 2,
label: 'Off',
position: 1,
color: 'red',
},
],
@@ -0,0 +1,2 @@
// Injected by the platform into every logic function execution.
export const APPLICATION_ID_ENV_VAR_NAME = 'APPLICATION_ID';
@@ -0,0 +1,5 @@
// Mirrors the core select options; guarded by the schema integration test.
export enum CallRecordingRequestStatus {
REQUESTED = 'REQUESTED',
CANCELED = 'CANCELED',
}
@@ -0,0 +1,9 @@
// Mirrors the core select options; guarded by the schema integration test.
export enum CallRecordingStatus {
SCHEDULED = 'SCHEDULED',
JOINING = 'JOINING',
RECORDING = 'RECORDING',
PROCESSING = 'PROCESSING',
COMPLETED = 'COMPLETED',
FAILED_UNKNOWN = 'FAILED_UNKNOWN',
}
@@ -0,0 +1 @@
export const DEFAULT_RECALL_BOT_NAME = 'Twenty Meeting Bot';
@@ -0,0 +1 @@
export const DEFAULT_RECALL_REGION = 'ap-northeast-1';
@@ -0,0 +1 @@
export const RECALL_API_KEY_ENV_VAR_NAME = 'RECALL_API_KEY';
@@ -0,0 +1 @@
export const RECALL_API_MAX_ATTEMPTS = 3;
@@ -0,0 +1 @@
export const RECALL_API_RETRY_DELAY_MS = 500;
@@ -0,0 +1,7 @@
import { RECALL_BOT_NOONE_JOINED_TIMEOUT_SECONDS } from 'src/logic-functions/constants/recall-bot-noone-joined-timeout-seconds';
import { RECALL_BOT_WAITING_ROOM_TIMEOUT_SECONDS } from 'src/logic-functions/constants/recall-bot-waiting-room-timeout-seconds';
export const RECALL_BOT_AUTOMATIC_LEAVE = {
waiting_room_timeout: RECALL_BOT_WAITING_ROOM_TIMEOUT_SECONDS,
noone_joined_timeout: RECALL_BOT_NOONE_JOINED_TIMEOUT_SECONDS,
};
@@ -0,0 +1 @@
export const RECALL_BOT_NAME_ENV_VAR_NAME = 'RECALL_BOT_NAME';
@@ -0,0 +1 @@
export const RECALL_BOT_NOONE_JOINED_TIMEOUT_SECONDS = 20 * 60;
@@ -0,0 +1,5 @@
// Recall only produces artifacts declared at bot creation; both gate COMPLETED.
export const RECALL_BOT_RECORDING_CONFIG = {
video_mixed_mp4: {},
audio_mixed_mp3: {},
};
@@ -0,0 +1 @@
export const RECALL_BOT_WAITING_ROOM_TIMEOUT_SECONDS = 1200;
@@ -0,0 +1 @@
export const RECALL_REGION_ENV_VAR_NAME = 'RECALL_REGION';
@@ -0,0 +1,3 @@
// Mirrors twenty-shared; calendar restrictions write it over title/description.
export const RESTRICTED_FIELD_PLACEHOLDER =
'FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED';
@@ -0,0 +1 @@
export const TWENTY_PAGE_SIZE = 100;
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
import { fetchAllNodes } from 'src/logic-functions/data/fetch-all-nodes.util';
describe('fetchAllNodes', () => {
it('collects nodes across pages until hasNextPage is false', async () => {
const fetchPage = vi
.fn()
.mockResolvedValueOnce({
pageInfo: { hasNextPage: true, endCursor: 'cursor-1' },
edges: [{ node: 'node-1' }, { node: 'node-2' }],
})
.mockResolvedValueOnce({
pageInfo: { hasNextPage: false, endCursor: 'cursor-2' },
edges: [{ node: 'node-3' }],
});
const nodes = await fetchAllNodes<string>(fetchPage);
expect(nodes).toEqual(['node-1', 'node-2', 'node-3']);
expect(fetchPage).toHaveBeenNthCalledWith(1, undefined);
expect(fetchPage).toHaveBeenNthCalledWith(2, 'cursor-1');
});
it('throws when hasNextPage is true without an endCursor', async () => {
const fetchPage = vi.fn().mockResolvedValue({
pageInfo: { hasNextPage: true, endCursor: null },
edges: [{ node: 'node-1' }],
});
await expect(fetchAllNodes<string>(fetchPage)).rejects.toThrow(
'Inconsistent pagination state: hasNextPage is true without an endCursor',
);
});
it('throws when the query returns no connection', async () => {
const fetchPage = vi.fn().mockResolvedValue(undefined);
await expect(fetchAllNodes<string>(fetchPage)).rejects.toThrow(
'Pagination query returned no connection',
);
});
});
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { RESTRICTED_FIELD_PLACEHOLDER } from 'src/logic-functions/constants/restricted-field-placeholder';
import { stripRestrictedFieldValue } from 'src/logic-functions/data/strip-restricted-field-value.util';
describe('stripRestrictedFieldValue', () => {
it('drops the calendar visibility restriction placeholder', () => {
expect(
stripRestrictedFieldValue(RESTRICTED_FIELD_PLACEHOLDER),
).toBeUndefined();
});
it('keeps regular values', () => {
expect(stripRestrictedFieldValue('Customer Discovery Call')).toBe(
'Customer Discovery Call',
);
});
it('keeps undefined', () => {
expect(stripRestrictedFieldValue(undefined)).toBeUndefined();
});
});
@@ -0,0 +1,41 @@
import { isUndefined } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
export type ScheduledCallRecordingFields = {
title: string | null;
status: CallRecordingStatus.SCHEDULED;
recordingRequestStatus: CallRecordingRequestStatus.REQUESTED;
calendarEventId: string;
};
export const createCallRecording = async (
client: CoreApiClient,
{
id,
data,
}: {
id: string;
data: ScheduledCallRecordingFields;
},
): Promise<string> => {
const mutationResult = await client.mutation({
createCallRecording: {
__args: {
data: { id, ...data },
},
id: true,
},
});
const createdCallRecordingId = mutationResult.createCallRecording?.id;
if (isUndefined(createdCallRecordingId)) {
throw new Error(
'createCallRecording mutation did not return a call recording id',
);
}
return createdCallRecordingId;
};
@@ -0,0 +1,44 @@
import { isString, isUndefined } from '@sniptt/guards';
export type ConnectionPage<TNode> = {
pageInfo?: {
hasNextPage?: boolean | null;
endCursor?: string | null;
} | null;
edges?: Array<{ node: TNode }> | null;
};
export const fetchAllNodes = async <TNode>(
fetchPage: (
afterCursor: string | undefined,
) => Promise<ConnectionPage<TNode> | undefined>,
): Promise<TNode[]> => {
const nodes: TNode[] = [];
let hasNextPage = true;
let afterCursor: string | undefined;
while (hasNextPage) {
const connection = await fetchPage(afterCursor);
if (isUndefined(connection)) {
throw new Error('Pagination query returned no connection');
}
for (const edge of connection.edges ?? []) {
nodes.push(edge.node);
}
hasNextPage = connection.pageInfo?.hasNextPage === true;
const endCursor = connection.pageInfo?.endCursor;
if (hasNextPage && !isString(endCursor)) {
throw new Error(
'Inconsistent pagination state: hasNextPage is true without an endCursor',
);
}
afterCursor = isString(endCursor) ? endCursor : undefined;
}
return nodes;
};
@@ -0,0 +1,80 @@
import { isString, isUndefined } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size';
import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type';
import {
fetchAllNodes,
type ConnectionPage,
} from 'src/logic-functions/data/fetch-all-nodes.util';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
import { stripRestrictedFieldValue } from 'src/logic-functions/data/strip-restricted-field-value.util';
type CalendarEventNode = {
id: string;
title?: string | null;
isCanceled?: boolean | null;
startsAt?: string | null;
endsAt?: string | null;
iCalUid?: string | null;
conferenceLink?: { primaryLinkUrl?: string | null } | null;
meetingBotPreference?: string | null;
};
export const fetchCalendarEventsByFilter = async (
client: CoreApiClient,
filter: Record<string, unknown>,
): Promise<CalendarEventRecord[]> => {
const calendarEventNodes = await fetchAllNodes<CalendarEventNode>(
async (afterCursor) => {
const queryResult = await client.query({
calendarEvents: {
__args: {
filter,
first: TWENTY_PAGE_SIZE,
...(isUndefined(afterCursor) ? {} : { after: afterCursor }),
},
pageInfo: {
hasNextPage: true,
endCursor: true,
},
edges: {
node: {
id: true,
title: true,
isCanceled: true,
startsAt: true,
endsAt: true,
iCalUid: true,
conferenceLink: {
primaryLinkUrl: true,
},
meetingBotPreference: true,
},
},
},
});
return queryResult.calendarEvents as
| ConnectionPage<CalendarEventNode>
| undefined;
},
);
return calendarEventNodes.map((calendarEvent) => ({
id: calendarEvent.id,
title: stripRestrictedFieldValue(calendarEvent.title ?? undefined),
isCanceled: calendarEvent.isCanceled ?? false,
startsAt: calendarEvent.startsAt ?? undefined,
endsAt: calendarEvent.endsAt ?? undefined,
iCalUid: calendarEvent.iCalUid ?? undefined,
conferenceLinkUrl: isNonEmptyString(
calendarEvent.conferenceLink?.primaryLinkUrl,
)
? calendarEvent.conferenceLink.primaryLinkUrl
: undefined,
meetingBotPreference: isString(calendarEvent.meetingBotPreference)
? calendarEvent.meetingBotPreference
: undefined,
}));
};
@@ -0,0 +1,20 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type';
import { fetchCalendarEventsByFilter } from 'src/logic-functions/data/fetch-calendar-events-by-filter.util';
import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util';
export const fetchCalendarEventsByIds = async (
client: CoreApiClient,
calendarEventIds: string[],
): Promise<CalendarEventRecord[]> => {
const uniqueCalendarEventIds = getUniqueSortedIds(calendarEventIds);
if (uniqueCalendarEventIds.length === 0) {
return [];
}
return fetchCalendarEventsByFilter(client, {
id: { in: uniqueCalendarEventIds },
});
};
@@ -0,0 +1,19 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type';
import { fetchCalendarEventsByFilter } from 'src/logic-functions/data/fetch-calendar-events-by-filter.util';
export const fetchCalendarEventsByStartsAtValues = async (
client: CoreApiClient,
startsAtValues: string[],
): Promise<CalendarEventRecord[]> => {
const uniqueStartsAtValues = [...new Set(startsAtValues)].sort();
if (uniqueStartsAtValues.length === 0) {
return [];
}
return fetchCalendarEventsByFilter(client, {
startsAt: { in: uniqueStartsAtValues },
});
};
@@ -0,0 +1,17 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type';
import { findCallRecordingsByFilter } from 'src/logic-functions/data/find-call-recordings-by-filter.util';
export const findCallRecordingsByCalendarEventIds = async (
client: CoreApiClient,
calendarEventIds: string[],
): Promise<CallRecordingRecord[]> => {
if (calendarEventIds.length === 0) {
return [];
}
return findCallRecordingsByFilter(client, {
calendarEventId: { in: calendarEventIds },
});
};
@@ -0,0 +1,97 @@
import { isUndefined } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size';
import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type';
import {
fetchAllNodes,
type ConnectionPage,
} from 'src/logic-functions/data/fetch-all-nodes.util';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
type CallRecordingNode = {
id: string;
title?: string | null;
status?: string | null;
recordingRequestStatus?: unknown;
startedAt?: string | null;
endedAt?: string | null;
calendarEventId?: string | null;
externalBotId?: string | null;
externalRecordingId?: string | null;
};
export const findCallRecordingsByFilter = async (
client: CoreApiClient,
filter: Record<string, unknown>,
): Promise<CallRecordingRecord[]> => {
const callRecordingNodes = await fetchAllNodes<CallRecordingNode>(
async (afterCursor) => {
const queryResult = await client.query({
callRecordings: {
__args: {
filter,
first: TWENTY_PAGE_SIZE,
...(isUndefined(afterCursor) ? {} : { after: afterCursor }),
},
pageInfo: {
hasNextPage: true,
endCursor: true,
},
edges: {
node: {
id: true,
title: true,
status: true,
recordingRequestStatus: true,
startedAt: true,
endedAt: true,
calendarEventId: true,
externalBotId: true,
externalRecordingId: true,
},
},
},
});
return queryResult.callRecordings as
| ConnectionPage<CallRecordingNode>
| undefined;
},
);
return callRecordingNodes.map((callRecording) => ({
id: callRecording.id,
title: callRecording.title ?? undefined,
status: callRecording.status ?? undefined,
recordingRequestStatus: normalizeCallRecordingRequestStatus(
callRecording.recordingRequestStatus,
),
startedAt: callRecording.startedAt ?? undefined,
endedAt: callRecording.endedAt ?? undefined,
calendarEventId: callRecording.calendarEventId ?? undefined,
externalBotId: normalizeOptionalString(callRecording.externalBotId),
externalRecordingId: normalizeOptionalString(
callRecording.externalRecordingId,
),
}));
};
const normalizeOptionalString = (
value: string | null | undefined,
): string | undefined => (isNonEmptyString(value) ? value : undefined);
const normalizeCallRecordingRequestStatus = (
recordingRequestStatus: unknown,
): CallRecordingRequestStatus | undefined => {
if (recordingRequestStatus === CallRecordingRequestStatus.REQUESTED) {
return recordingRequestStatus;
}
if (recordingRequestStatus === CallRecordingRequestStatus.CANCELED) {
return recordingRequestStatus;
}
return undefined;
};
@@ -0,0 +1,17 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type';
import { findCallRecordingsByFilter } from 'src/logic-functions/data/find-call-recordings-by-filter.util';
export const findCallRecordingsByIds = async (
client: CoreApiClient,
callRecordingIds: string[],
): Promise<CallRecordingRecord[]> => {
if (callRecordingIds.length === 0) {
return [];
}
return findCallRecordingsByFilter(client, {
id: { in: callRecordingIds },
});
};
@@ -0,0 +1,6 @@
import { RESTRICTED_FIELD_PLACEHOLDER } from 'src/logic-functions/constants/restricted-field-placeholder';
export const stripRestrictedFieldValue = (
value: string | undefined,
): string | undefined =>
value === RESTRICTED_FIELD_PLACEHOLDER ? undefined : value;
@@ -0,0 +1,38 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { type CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { type CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
export type CallRecordingUpdateFields = Partial<{
// null clears a previously synced title when the calendar title disappears.
title: string | null;
status: CallRecordingStatus;
recordingRequestStatus: CallRecordingRequestStatus;
startedAt: string;
endedAt: string;
calendarEventId: string;
// null clears the field on cancel/eject; the only field we ever write null to.
externalBotId: string | null;
externalRecordingId: string;
}>;
export const updateCallRecording = async (
client: CoreApiClient,
{
id,
data,
}: {
id: string;
data: CallRecordingUpdateFields;
},
): Promise<void> => {
await client.mutation({
updateCallRecording: {
__args: {
id,
data,
},
id: true,
},
});
};
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { buildMeetingBotPolicyResult } from 'src/logic-functions/domain/build-meeting-bot-policy-result.util';
import { type MeetingBotPolicyCalendarEventInput } from 'src/logic-functions/types/meeting-bot-policy-calendar-event-input.type';
const NOW = new Date('2026-01-01T12:00:00.000Z');
const buildCalendarEventInput = (
overrides: Partial<MeetingBotPolicyCalendarEventInput>,
): MeetingBotPolicyCalendarEventInput => ({
id: 'calendar-event-1',
isCanceled: false,
startsAt: '2026-01-01T13:00:00.000Z',
endsAt: '2026-01-01T14:00:00.000Z',
iCalUid: 'ical-uid-1',
conferenceLinkUrl: 'https://meet.example.com/customer-sync',
meetingBotPreference: undefined,
...overrides,
});
describe('buildMeetingBotPolicyResult', () => {
it('requests a bot for the ON wire value', () => {
const policyResult = buildMeetingBotPolicyResult(
buildCalendarEventInput({
meetingBotPreference: 'ON',
}),
NOW,
);
expect(policyResult.meetingBotPreference).toBe('ON');
expect(policyResult.shouldRequestBot).toBe(true);
expect(policyResult.reason).toBe('RECORDING_ENABLED');
});
it('does not request a bot for the OFF wire value', () => {
const policyResult = buildMeetingBotPolicyResult(
buildCalendarEventInput({
meetingBotPreference: 'OFF',
}),
NOW,
);
expect(policyResult.meetingBotPreference).toBe('OFF');
expect(policyResult.shouldRequestBot).toBe(false);
expect(policyResult.reason).toBe('PREFERENCE_OFF');
});
});
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { computeCallRecordingIdForMeeting } from 'src/logic-functions/domain/compute-call-recording-id-for-meeting.util';
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
describe('computeCallRecordingIdForMeeting', () => {
it('returns the same id for the same real meeting key', () => {
const realMeetingKey =
'link:meet.example.com/sync:2026-01-01T13:00:00.000Z';
expect(computeCallRecordingIdForMeeting(realMeetingKey)).toBe(
computeCallRecordingIdForMeeting(realMeetingKey),
);
});
it('returns different ids for different real meeting keys', () => {
expect(
computeCallRecordingIdForMeeting(
'link:meet.example.com/sync:2026-01-01T13:00:00.000Z',
),
).not.toBe(
computeCallRecordingIdForMeeting(
'link:meet.example.com/sync:2026-01-02T13:00:00.000Z',
),
);
});
it('returns a v4-shaped uuid', () => {
expect(
computeCallRecordingIdForMeeting(
'ical:some-uid:2026-01-01T13:00:00.000Z',
),
).toMatch(UUID_V4_PATTERN);
});
});
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { computeRealMeetingKey } from 'src/logic-functions/domain/compute-real-meeting-key.util';
const STARTS_AT = '2026-01-01T13:00:00.000Z';
const buildInput = (
overrides: Partial<Parameters<typeof computeRealMeetingKey>[0]> = {},
) => ({
calendarEventId: 'calendar-event-1',
conferenceLinkUrl: 'https://meet.example.com/customer-sync',
iCalUid: 'calendar-event-uid',
startsAt: STARTS_AT,
...overrides,
});
describe('computeRealMeetingKey', () => {
it.each([
[
'strips protocol, query, and fragment',
'https://zoom.us/j/123?pwd=abc#section',
`link:zoom.us/j/123:${STARTS_AT}`,
],
[
'strips www and lowercases',
'HTTPS://WWW.Meet.Example.com/Customer-Sync',
`link:meet.example.com/customer-sync:${STARTS_AT}`,
],
[
'strips trailing slashes',
'https://meet.example.com/customer-sync///',
`link:meet.example.com/customer-sync:${STARTS_AT}`,
],
[
'supports plain http links',
'http://meet.example.com/customer-sync',
`link:meet.example.com/customer-sync:${STARTS_AT}`,
],
])('%s', (_label, conferenceLinkUrl, expectedKey) => {
expect(computeRealMeetingKey(buildInput({ conferenceLinkUrl }))).toBe(
expectedKey,
);
});
it('produces the same key for the same meeting synced from two calendars', () => {
const fromFirstAttendee = computeRealMeetingKey(
buildInput({
calendarEventId: 'calendar-event-1',
conferenceLinkUrl: 'https://zoom.us/j/123?pwd=first-attendee-token',
}),
);
const fromSecondAttendee = computeRealMeetingKey(
buildInput({
calendarEventId: 'calendar-event-2',
conferenceLinkUrl:
'https://www.zoom.us/j/123?pwd=second-attendee-token',
}),
);
expect(fromFirstAttendee).toBe(fromSecondAttendee);
});
it('falls back to the iCal uid when the link is blank', () => {
expect(
computeRealMeetingKey(buildInput({ conferenceLinkUrl: ' ' })),
).toBe(`ical:calendar-event-uid:${STARTS_AT}`);
});
it('falls back to the iCal uid when the link is not a string', () => {
expect(computeRealMeetingKey(buildInput({ conferenceLinkUrl: 42 }))).toBe(
`ical:calendar-event-uid:${STARTS_AT}`,
);
});
it('falls back to the calendar event id when link and iCal uid are missing', () => {
expect(
computeRealMeetingKey(
buildInput({ conferenceLinkUrl: undefined, iCalUid: '' }),
),
).toBe('event:calendar-event-1');
});
it('keeps link keys distinct across start times', () => {
expect(computeRealMeetingKey(buildInput({ startsAt: undefined }))).toBe(
'link:meet.example.com/customer-sync:',
);
});
});
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest';
import { MeetingBotPreference } from 'src/constants/meeting-bot-preference';
import { resolveMeetingBotPolicyResult } from 'src/logic-functions/domain/resolve-meeting-bot-policy-result.util';
const NOW = new Date('2026-01-01T12:00:00.000Z');
const FUTURE_STARTS_AT = '2026-01-01T13:00:00.000Z';
const FUTURE_ENDS_AT = '2026-01-01T14:00:00.000Z';
const PAST_STARTS_AT = '2026-01-01T09:00:00.000Z';
const PAST_ENDS_AT = '2026-01-01T10:00:00.000Z';
describe('resolveMeetingBotPolicyResult', () => {
it('requires a bot when preference is ON and the event is upcoming', () => {
expect(
resolveMeetingBotPolicyResult({
input: {
meetingBotPreference: MeetingBotPreference.ON,
isCanceled: false,
startsAt: FUTURE_STARTS_AT,
endsAt: FUTURE_ENDS_AT,
conferenceLinkUrl: 'https://meet.example.com/team-sync',
},
now: NOW,
}),
).toEqual({
shouldRequestBot: true,
reason: 'RECORDING_ENABLED',
});
});
it('does not request a bot for ON when the meeting has no conference link', () => {
expect(
resolveMeetingBotPolicyResult({
input: {
meetingBotPreference: MeetingBotPreference.ON,
isCanceled: false,
startsAt: FUTURE_STARTS_AT,
endsAt: FUTURE_ENDS_AT,
conferenceLinkUrl: undefined,
},
now: NOW,
}),
).toEqual({
shouldRequestBot: false,
reason: 'MISSING_CONFERENCE_LINK',
});
});
it('requires a bot without an event preference override', () => {
expect(
resolveMeetingBotPolicyResult({
input: {
meetingBotPreference: undefined,
isCanceled: false,
startsAt: FUTURE_STARTS_AT,
endsAt: FUTURE_ENDS_AT,
conferenceLinkUrl: 'https://meet.example.com/team-sync',
},
now: NOW,
}),
).toEqual({
shouldRequestBot: true,
reason: 'RECORDING_ENABLED',
});
});
it('lets an OFF event preference opt out of workspace auto-recording', () => {
expect(
resolveMeetingBotPolicyResult({
input: {
meetingBotPreference: MeetingBotPreference.OFF,
isCanceled: false,
startsAt: FUTURE_STARTS_AT,
endsAt: FUTURE_ENDS_AT,
conferenceLinkUrl: 'https://meet.example.com/team-sync',
},
now: NOW,
}),
).toEqual({
shouldRequestBot: false,
reason: 'PREFERENCE_OFF',
});
});
it('does not request a bot for a meeting that already ended', () => {
expect(
resolveMeetingBotPolicyResult({
input: {
meetingBotPreference: undefined,
isCanceled: false,
startsAt: PAST_STARTS_AT,
endsAt: PAST_ENDS_AT,
conferenceLinkUrl: 'https://meet.example.com/team-sync',
},
now: NOW,
}),
).toEqual({
shouldRequestBot: false,
reason: 'EVENT_NOT_UPCOMING',
});
});
it('does not request a bot for a canceled meeting', () => {
expect(
resolveMeetingBotPolicyResult({
input: {
meetingBotPreference: undefined,
isCanceled: true,
startsAt: FUTURE_STARTS_AT,
endsAt: FUTURE_ENDS_AT,
conferenceLinkUrl: 'https://meet.example.com/team-sync',
},
now: NOW,
}),
).toEqual({
shouldRequestBot: false,
reason: 'EVENT_CANCELED',
});
});
});
@@ -0,0 +1,42 @@
import { type MeetingBotPolicyResultForCalendarEvent } from 'src/logic-functions/types/meeting-bot-policy-result-for-calendar-event.type';
import { type MeetingBotPolicyResultForMeeting } from 'src/logic-functions/types/meeting-bot-policy-result-for-meeting.type';
type MeetingBotPolicyResultForMeetingInput = Pick<
MeetingBotPolicyResultForCalendarEvent,
'calendarEventId' | 'realMeetingKey' | 'shouldRequestBot'
>;
export const aggregateMeetingBotPolicyResultsByMeeting = (
perCalendarEventPolicyResults: MeetingBotPolicyResultForMeetingInput[],
): MeetingBotPolicyResultForMeeting[] => {
const meetingPolicyResultsByMeetingKey = new Map<
string,
MeetingBotPolicyResultForMeeting
>();
for (const {
calendarEventId,
realMeetingKey,
shouldRequestBot,
} of perCalendarEventPolicyResults) {
const meetingPolicyResult = meetingPolicyResultsByMeetingKey.get(
realMeetingKey,
) ?? {
realMeetingKey,
shouldRequestBot: false,
calendarEventIds: [],
requestingCalendarEventIds: [],
};
meetingPolicyResult.calendarEventIds.push(calendarEventId);
if (shouldRequestBot) {
meetingPolicyResult.shouldRequestBot = true;
meetingPolicyResult.requestingCalendarEventIds.push(calendarEventId);
}
meetingPolicyResultsByMeetingKey.set(realMeetingKey, meetingPolicyResult);
}
return [...meetingPolicyResultsByMeetingKey.values()];
};
@@ -0,0 +1,53 @@
import { MeetingBotPreference } from 'src/constants/meeting-bot-preference';
import { type MeetingBotPolicyCalendarEventInput } from 'src/logic-functions/types/meeting-bot-policy-calendar-event-input.type';
import { type MeetingBotPolicyResultForCalendarEvent } from 'src/logic-functions/types/meeting-bot-policy-result-for-calendar-event.type';
import { computeRealMeetingKey } from 'src/logic-functions/domain/compute-real-meeting-key.util';
import { resolveMeetingBotPolicyResult } from 'src/logic-functions/domain/resolve-meeting-bot-policy-result.util';
export const buildMeetingBotPolicyResult = (
calendarEvent: MeetingBotPolicyCalendarEventInput,
now: Date,
): MeetingBotPolicyResultForCalendarEvent => {
const realMeetingKey = computeRealMeetingKey({
calendarEventId: calendarEvent.id,
conferenceLinkUrl: calendarEvent.conferenceLinkUrl,
iCalUid: calendarEvent.iCalUid,
startsAt: calendarEvent.startsAt,
});
const meetingBotPreference = normalizeMeetingBotPreference(
calendarEvent.meetingBotPreference,
);
const policyResult = resolveMeetingBotPolicyResult({
input: {
meetingBotPreference,
isCanceled: calendarEvent.isCanceled,
startsAt: calendarEvent.startsAt,
endsAt: calendarEvent.endsAt,
conferenceLinkUrl: calendarEvent.conferenceLinkUrl,
},
now,
});
return {
calendarEventId: calendarEvent.id,
meetingBotPreference,
realMeetingKey,
...policyResult,
};
};
const normalizeMeetingBotPreference = (
meetingBotPreference: string | undefined,
): MeetingBotPreference | undefined =>
isMeetingBotPreference(meetingBotPreference)
? meetingBotPreference
: undefined;
const isMeetingBotPreference = (
meetingBotPreference: string | undefined,
): meetingBotPreference is MeetingBotPreference =>
Object.values(MeetingBotPreference).some(
(preference) => preference === meetingBotPreference,
);
@@ -0,0 +1,30 @@
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,
);
return {
twentyCallRecordingId: callRecording.id,
twentyCalendarEventId: calendarEvent.id,
twentyRealMeetingKey: computeRealMeetingKey({
calendarEventId: calendarEvent.id,
conferenceLinkUrl: calendarEvent.conferenceLinkUrl,
iCalUid: calendarEvent.iCalUid,
startsAt: calendarEvent.startsAt,
}),
...(isUndefined(applicationId)
? {}
: { twentyApplicationId: applicationId }),
};
};
@@ -0,0 +1,16 @@
import { createHash } from 'crypto';
// Same meeting key → same id: the primary key serializes concurrent creates.
export const computeCallRecordingIdForMeeting = (
realMeetingKey: string,
): string => {
const bytes = createHash('sha256').update(realMeetingKey).digest();
// v4 version/variant bits so server-side UUID validation accepts the hash.
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = bytes.subarray(0, 16).toString('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
};
@@ -0,0 +1,48 @@
import { isUndefined } from '@sniptt/guards';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
type ComputeRealMeetingKeyInput = {
calendarEventId: string;
conferenceLinkUrl: unknown;
iCalUid: string | undefined;
startsAt: string | undefined;
};
export const computeRealMeetingKey = ({
calendarEventId,
conferenceLinkUrl,
iCalUid,
startsAt,
}: ComputeRealMeetingKeyInput): string => {
const normalizedConferenceLink = normalizeConferenceLink(conferenceLinkUrl);
if (!isUndefined(normalizedConferenceLink)) {
return `link:${normalizedConferenceLink}:${startsAt ?? ''}`;
}
if (isNonEmptyString(iCalUid)) {
return `ical:${iCalUid}:${startsAt ?? ''}`;
}
return `event:${calendarEventId}`;
};
const normalizeConferenceLink = (
conferenceLinkUrl: unknown,
): string | undefined => {
if (!isNonEmptyString(conferenceLinkUrl)) {
return undefined;
}
const withoutProtocol = conferenceLinkUrl
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/^www\./, '');
const withoutQueryAndFragment = withoutProtocol.split(/[?#]/)[0];
const withoutTrailingSlash = withoutQueryAndFragment.replace(/\/+$/, '');
return withoutTrailingSlash === '' ? undefined : withoutTrailingSlash;
};
@@ -0,0 +1,72 @@
import { MeetingBotPreference } from 'src/constants/meeting-bot-preference';
import { type MeetingBotPolicyInput } from 'src/logic-functions/types/meeting-bot-policy-input.type';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
import { type MeetingBotPolicyNotRequiredReason } from 'src/logic-functions/types/meeting-bot-policy-not-required-reason.type';
import { type MeetingBotPolicyRequiredReason } from 'src/logic-functions/types/meeting-bot-policy-required-reason.type';
import { type MeetingBotPolicyResult } from 'src/logic-functions/types/meeting-bot-policy-result.type';
type ResolveMeetingBotPolicyResultArgs = {
input: MeetingBotPolicyInput;
now: Date;
};
export const resolveMeetingBotPolicyResult = ({
input,
now,
}: ResolveMeetingBotPolicyResultArgs): MeetingBotPolicyResult => {
if (input.isCanceled) {
return botNotRequired('EVENT_CANCELED');
}
if (input.meetingBotPreference === MeetingBotPreference.OFF) {
return botNotRequired('PREFERENCE_OFF');
}
if (!isNonEmptyString(input.conferenceLinkUrl)) {
return botNotRequired('MISSING_CONFERENCE_LINK');
}
if (
!isCalendarEventInFuture({
startsAt: input.startsAt,
endsAt: input.endsAt,
now,
})
) {
return botNotRequired('EVENT_NOT_UPCOMING');
}
return botRequired('RECORDING_ENABLED');
};
const isCalendarEventInFuture = ({
startsAt,
endsAt,
now,
}: {
startsAt: string | undefined;
endsAt: string | undefined;
now: Date;
}): boolean => {
const reference = endsAt ?? startsAt;
if (!isNonEmptyString(reference)) {
return false;
}
const referenceTime = new Date(reference).getTime();
if (Number.isNaN(referenceTime)) {
return false;
}
return referenceTime > now.getTime();
};
const botRequired = (
reason: MeetingBotPolicyRequiredReason,
): MeetingBotPolicyResult => ({ shouldRequestBot: true, reason });
const botNotRequired = (
reason: MeetingBotPolicyNotRequiredReason,
): MeetingBotPolicyResult => ({ shouldRequestBot: false, reason });
@@ -0,0 +1,46 @@
import { isUndefined } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
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 { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util';
// TODO: Add the stale-state cron in the next split so it can finish the Recall half when this call fails.
export const cancelCallRecordingRequest = async ({
client,
callRecording,
}: {
client: CoreApiClient;
callRecording: CallRecordingRecord;
}): Promise<void> => {
await updateCallRecording(client, {
id: callRecording.id,
data: {
recordingRequestStatus: CallRecordingRequestStatus.CANCELED,
},
});
if (isUndefined(callRecording.externalBotId)) {
return;
}
const cancelResult = await cancelRecallBot({
externalBotId: callRecording.externalBotId,
});
if (!cancelResult.ok) {
console.warn(
`[twenty-meeting-bot] failed to cancel Recall bot for callRecording ${callRecording.id}, leaving it for the planned stale-state cron: ${cancelResult.errorMessage}`,
);
return;
}
await updateCallRecording(client, {
id: callRecording.id,
data: {
externalBotId: null,
},
});
};
@@ -0,0 +1,55 @@
import { isUndefined } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { type MeetingRecording } from 'src/logic-functions/types/meeting-recording.type';
import { buildRecallBotMetadata } from 'src/logic-functions/domain/build-recall-bot-metadata.util';
import { findCallRecordingsByIds } from 'src/logic-functions/data/find-call-recordings-by-ids.util';
import { scheduleRecallBot } from 'src/logic-functions/recall-api/schedule-recall-bot.util';
import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util';
// The sole place a Recall bot is created. The deterministic-create winner and active update path call it in this split.
// TODO: Add the convergence cron in the next split so botless REQUESTED rows are healed by the same writer.
export const ensureMeetingBot = async (
client: CoreApiClient,
{ callRecording, calendarEvent }: MeetingRecording,
): Promise<void> => {
const meetingUrl = calendarEvent.conferenceLinkUrl;
const joinAt = calendarEvent.startsAt;
if (isUndefined(meetingUrl) || isUndefined(joinAt)) {
return;
}
const freshCallRecording = (
await findCallRecordingsByIds(client, [callRecording.id])
)[0];
if (
isUndefined(freshCallRecording) ||
freshCallRecording.recordingRequestStatus !==
CallRecordingRequestStatus.REQUESTED ||
!isUndefined(freshCallRecording.externalBotId)
) {
return;
}
const scheduleResult = await scheduleRecallBot({
meetingUrl,
joinAt,
metadata: buildRecallBotMetadata({ callRecording, calendarEvent }),
});
if (!scheduleResult.ok) {
console.warn(
`[twenty-meeting-bot] failed to schedule Recall bot for callRecording ${callRecording.id}: ${scheduleResult.errorMessage}`,
);
return;
}
await updateCallRecording(client, {
id: callRecording.id,
data: { externalBotId: scheduleResult.externalBotId },
});
};
@@ -0,0 +1,495 @@
import { isUndefined } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type';
import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type';
import { type MeetingBotPolicyResultForMeeting } from 'src/logic-functions/types/meeting-bot-policy-result-for-meeting.type';
import { type MeetingBotReconciliationResult } from 'src/logic-functions/types/meeting-bot-reconciliation-result.type';
import { type RemovedMeetingBotOccurrence } from 'src/logic-functions/types/removed-meeting-bot-occurrence.type';
import { aggregateMeetingBotPolicyResultsByMeeting } from 'src/logic-functions/domain/aggregate-meeting-bot-policy-results-by-meeting.util';
import { buildMeetingBotPolicyResult } from 'src/logic-functions/domain/build-meeting-bot-policy-result.util';
import { cancelCallRecordingRequest } from 'src/logic-functions/flows/cancel-call-recording-request.util';
import { computeCallRecordingIdForMeeting } from 'src/logic-functions/domain/compute-call-recording-id-for-meeting.util';
import {
createCallRecording,
type ScheduledCallRecordingFields,
} from 'src/logic-functions/data/create-call-recording.util';
import { ensureMeetingBot } from 'src/logic-functions/flows/ensure-meeting-bot.util';
import { fetchCalendarEventsByIds } from 'src/logic-functions/data/fetch-calendar-events-by-ids.util';
import { fetchCalendarEventsByStartsAtValues } from 'src/logic-functions/data/fetch-calendar-events-by-starts-at-values.util';
import { findCallRecordingsByCalendarEventIds } from 'src/logic-functions/data/find-call-recordings-by-calendar-event-ids.util';
import { findCallRecordingsByIds } from 'src/logic-functions/data/find-call-recordings-by-ids.util';
import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util';
import { rescheduleCallRecordingBot } from 'src/logic-functions/flows/reschedule-call-recording-bot.util';
import {
updateCallRecording,
type CallRecordingUpdateFields,
} from 'src/logic-functions/data/update-call-recording.util';
export const reconcileMeetingBotForCalendarEventIds = async ({
client,
calendarEventIds,
removedOccurrences = [],
now = new Date(),
}: {
client: CoreApiClient;
calendarEventIds: string[];
removedOccurrences?: RemovedMeetingBotOccurrence[];
now?: Date;
}): Promise<MeetingBotReconciliationResult[]> => {
const meetingPolicyResults = await resolveMeetingBotPolicyResultsForMeetings({
client,
calendarEventIds,
removedOccurrences,
now,
});
return reconcileMeetingBotForMeetingOccurrences({
client,
meetingPolicyResults,
removedOccurrences,
});
};
const resolveMeetingBotPolicyResultsForMeetings = async ({
client,
calendarEventIds,
removedOccurrences = [],
now = new Date(),
}: {
client: CoreApiClient;
calendarEventIds: string[];
removedOccurrences?: RemovedMeetingBotOccurrence[];
now?: Date;
}): Promise<MeetingBotPolicyResultForMeeting[]> => {
const changedCalendarEvents = await fetchCalendarEventsByIds(
client,
getUniqueSortedIds(calendarEventIds),
);
const affectedMeetingKeys = new Set<string>();
const occurrenceStartsAtAnchors = new Set<string>();
const changedCalendarEventPolicyResults = changedCalendarEvents.map(
(calendarEvent) => buildMeetingBotPolicyResult(calendarEvent, now),
);
for (const policyResult of changedCalendarEventPolicyResults) {
affectedMeetingKeys.add(policyResult.realMeetingKey);
}
for (const calendarEvent of changedCalendarEvents) {
if (!isUndefined(calendarEvent.startsAt)) {
occurrenceStartsAtAnchors.add(calendarEvent.startsAt);
}
}
for (const removedOccurrence of removedOccurrences) {
affectedMeetingKeys.add(removedOccurrence.realMeetingKey);
if (!isUndefined(removedOccurrence.startsAt)) {
occurrenceStartsAtAnchors.add(removedOccurrence.startsAt);
}
}
if (affectedMeetingKeys.size === 0) {
return [];
}
const occurrenceSiblingEvents = await fetchCalendarEventsByStartsAtValues(
client,
[...occurrenceStartsAtAnchors],
);
const policyResultsByCalendarEventId = new Map(
changedCalendarEventPolicyResults.map((policyResult) => [
policyResult.calendarEventId,
policyResult,
]),
);
for (const calendarEvent of occurrenceSiblingEvents) {
if (policyResultsByCalendarEventId.has(calendarEvent.id)) {
continue;
}
policyResultsByCalendarEventId.set(
calendarEvent.id,
buildMeetingBotPolicyResult(calendarEvent, now),
);
}
const perCalendarEventPolicyResults = [
...policyResultsByCalendarEventId.values(),
]
.filter((policyResult) =>
affectedMeetingKeys.has(policyResult.realMeetingKey),
)
.map((policyResult) => ({
calendarEventId: policyResult.calendarEventId,
realMeetingKey: policyResult.realMeetingKey,
shouldRequestBot: policyResult.shouldRequestBot,
}));
const meetingPolicyResults = aggregateMeetingBotPolicyResultsByMeeting(
perCalendarEventPolicyResults,
);
const meetingKeysWithPolicyResult = new Set(
meetingPolicyResults.map(
(meetingPolicyResult) => meetingPolicyResult.realMeetingKey,
),
);
for (const meetingKey of [...affectedMeetingKeys].sort()) {
if (meetingKeysWithPolicyResult.has(meetingKey)) {
continue;
}
meetingPolicyResults.push({
realMeetingKey: meetingKey,
shouldRequestBot: false,
calendarEventIds: [],
requestingCalendarEventIds: [],
});
}
return meetingPolicyResults;
};
const reconcileMeetingBotForMeetingOccurrences = async ({
client,
meetingPolicyResults,
removedOccurrences = [],
}: {
client: CoreApiClient;
meetingPolicyResults: MeetingBotPolicyResultForMeeting[];
removedOccurrences?: RemovedMeetingBotOccurrence[];
}): Promise<MeetingBotReconciliationResult[]> => {
const removedCalendarEventIdsByMeetingKey =
buildRemovedCalendarEventIdsByMeetingKey(removedOccurrences);
const reconciliationResults: MeetingBotReconciliationResult[] = [];
const orderedMeetingPolicyResults = [
...meetingPolicyResults.filter(
(meetingPolicyResult) => !meetingPolicyResult.shouldRequestBot,
),
...meetingPolicyResults.filter(
(meetingPolicyResult) => meetingPolicyResult.shouldRequestBot,
),
];
for (const meetingPolicyResult of orderedMeetingPolicyResults) {
const removedCalendarEventIds =
removedCalendarEventIdsByMeetingKey.get(
meetingPolicyResult.realMeetingKey,
) ?? [];
try {
reconciliationResults.push(
meetingPolicyResult.shouldRequestBot
? await reconcileActiveMeeting({
client,
meetingPolicyResult,
removedCalendarEventIds,
})
: await reconcileCanceledMeeting({
client,
meetingPolicyResult,
removedCalendarEventIds,
}),
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(
`[twenty-meeting-bot] reconciliation failed for meeting ${meetingPolicyResult.realMeetingKey}: ${errorMessage}`,
);
reconciliationResults.push({
action: 'FAILED',
realMeetingKey: meetingPolicyResult.realMeetingKey,
errorMessage,
});
}
}
return reconciliationResults;
};
const reconcileActiveMeeting = async ({
client,
meetingPolicyResult,
removedCalendarEventIds,
}: {
client: CoreApiClient;
meetingPolicyResult: MeetingBotPolicyResultForMeeting;
removedCalendarEventIds: string[];
}): Promise<MeetingBotReconciliationResult> => {
const representativeCalendarEventId = getUniqueSortedIds(
meetingPolicyResult.requestingCalendarEventIds,
)[0];
if (isUndefined(representativeCalendarEventId)) {
return buildSkippedResult(meetingPolicyResult.realMeetingKey);
}
const representativeCalendarEvent = (
await fetchCalendarEventsByIds(client, [representativeCalendarEventId])
)[0];
if (isUndefined(representativeCalendarEvent)) {
return buildSkippedResult(meetingPolicyResult.realMeetingKey);
}
const callRecordingId = computeCallRecordingIdForMeeting(
meetingPolicyResult.realMeetingKey,
);
const existingCallRecording = (
await findCallRecordingsByIds(client, [callRecordingId])
)[0];
if (!isUndefined(existingCallRecording)) {
return updatePolicyManagedCallRecording({
client,
existingCallRecording,
representativeCalendarEvent,
realMeetingKey: meetingPolicyResult.realMeetingKey,
});
}
const manualOpenCallRecording = await findManualOpenCallRecording({
client,
meetingPolicyResult,
removedCalendarEventIds,
});
if (!isUndefined(manualOpenCallRecording)) {
return {
action: 'SKIPPED',
realMeetingKey: meetingPolicyResult.realMeetingKey,
callRecordingId: manualOpenCallRecording.id,
};
}
return createPolicyManagedCallRecording({
client,
callRecordingId,
representativeCalendarEvent,
realMeetingKey: meetingPolicyResult.realMeetingKey,
});
};
const updatePolicyManagedCallRecording = async ({
client,
existingCallRecording,
representativeCalendarEvent,
realMeetingKey,
}: {
client: CoreApiClient;
existingCallRecording: CallRecordingRecord;
representativeCalendarEvent: CalendarEventRecord;
realMeetingKey: string;
}): Promise<MeetingBotReconciliationResult> => {
await updateCallRecording(client, {
id: existingCallRecording.id,
data: buildPolicyManagedCallRecordingUpdateFields({
existingCallRecording,
calendarEvent: representativeCalendarEvent,
}),
});
await rescheduleCallRecordingBot(client, {
callRecording: existingCallRecording,
calendarEvent: representativeCalendarEvent,
});
await ensureMeetingBot(client, {
callRecording: existingCallRecording,
calendarEvent: representativeCalendarEvent,
});
return {
action: 'UPDATED',
realMeetingKey,
callRecordingId: existingCallRecording.id,
};
};
const createPolicyManagedCallRecording = async ({
client,
callRecordingId,
representativeCalendarEvent,
realMeetingKey,
}: {
client: CoreApiClient;
callRecordingId: string;
representativeCalendarEvent: CalendarEventRecord;
realMeetingKey: string;
}): Promise<MeetingBotReconciliationResult> => {
const scheduledFields = buildScheduledCallRecordingFields(
representativeCalendarEvent,
);
try {
await createCallRecording(client, {
id: callRecordingId,
data: scheduledFields,
});
} catch (error) {
// The id is deterministic, so a conflict means a concurrent run created the row first.
const concurrentlyCreatedCallRecording = (
await findCallRecordingsByIds(client, [callRecordingId])
)[0];
if (isUndefined(concurrentlyCreatedCallRecording)) {
throw error;
}
return updatePolicyManagedCallRecording({
client,
existingCallRecording: concurrentlyCreatedCallRecording,
representativeCalendarEvent,
realMeetingKey,
});
}
// Winning the deterministic-id insert elects this run as the single writer that creates the bot.
await ensureMeetingBot(client, {
callRecording: {
id: callRecordingId,
...scheduledFields,
title: scheduledFields.title ?? undefined,
},
calendarEvent: representativeCalendarEvent,
});
return {
action: 'CREATED',
realMeetingKey,
callRecordingId,
};
};
const findManualOpenCallRecording = async ({
client,
meetingPolicyResult,
removedCalendarEventIds,
}: {
client: CoreApiClient;
meetingPolicyResult: MeetingBotPolicyResultForMeeting;
removedCalendarEventIds: string[];
}): Promise<CallRecordingRecord | undefined> => {
const calendarEventIds = getUniqueSortedIds([
...meetingPolicyResult.calendarEventIds,
...meetingPolicyResult.requestingCalendarEventIds,
...removedCalendarEventIds,
]);
const callRecordings = await findCallRecordingsByCalendarEventIds(
client,
calendarEventIds,
);
return [...callRecordings]
.sort((firstCallRecording, secondCallRecording) =>
firstCallRecording.id.localeCompare(secondCallRecording.id),
)
.find(
(callRecording) =>
callRecording.status !== CallRecordingStatus.COMPLETED &&
isUndefined(callRecording.recordingRequestStatus),
);
};
const reconcileCanceledMeeting = async ({
client,
meetingPolicyResult,
removedCalendarEventIds,
}: {
client: CoreApiClient;
meetingPolicyResult: MeetingBotPolicyResultForMeeting;
removedCalendarEventIds: string[];
}): Promise<MeetingBotReconciliationResult> => {
const calendarEventIds = getUniqueSortedIds([
...meetingPolicyResult.calendarEventIds,
...removedCalendarEventIds,
]);
const cancellableCallRecordings = (
await findCallRecordingsByCalendarEventIds(client, calendarEventIds)
).filter(
(callRecording) =>
callRecording.status === CallRecordingStatus.SCHEDULED &&
callRecording.recordingRequestStatus ===
CallRecordingRequestStatus.REQUESTED,
);
if (cancellableCallRecordings.length === 0) {
return buildSkippedResult(meetingPolicyResult.realMeetingKey);
}
for (const callRecording of cancellableCallRecordings) {
await cancelCallRecordingRequest({
client,
callRecording,
});
}
return {
action: 'CANCELED',
realMeetingKey: meetingPolicyResult.realMeetingKey,
callRecordingId: cancellableCallRecordings[0].id,
};
};
// startedAt/endedAt come from the webhook; calendar writes never touch them.
const buildCalendarDrivenCallRecordingFields = (
calendarEvent: CalendarEventRecord,
): Omit<ScheduledCallRecordingFields, 'status'> => ({
// Wire null clears a stale title when the calendar title is gone or restricted.
title: calendarEvent.title ?? null,
recordingRequestStatus: CallRecordingRequestStatus.REQUESTED,
calendarEventId: calendarEvent.id,
});
const buildScheduledCallRecordingFields = (
calendarEvent: CalendarEventRecord,
): ScheduledCallRecordingFields => ({
...buildCalendarDrivenCallRecordingFields(calendarEvent),
status: CallRecordingStatus.SCHEDULED,
});
// A live or finished bot lifecycle must never be reset to SCHEDULED by a calendar-driven update.
const buildPolicyManagedCallRecordingUpdateFields = ({
existingCallRecording,
calendarEvent,
}: {
existingCallRecording: CallRecordingRecord;
calendarEvent: CalendarEventRecord;
}): CallRecordingUpdateFields =>
canResetCallRecordingStatusToScheduled(existingCallRecording.status)
? buildScheduledCallRecordingFields(calendarEvent)
: buildCalendarDrivenCallRecordingFields(calendarEvent);
const canResetCallRecordingStatusToScheduled = (
status: string | undefined,
): boolean =>
status === CallRecordingStatus.SCHEDULED ||
status === CallRecordingStatus.FAILED_UNKNOWN;
const buildRemovedCalendarEventIdsByMeetingKey = (
removedOccurrences: RemovedMeetingBotOccurrence[],
): Map<string, string[]> => {
const calendarEventIdsByMeetingKey = new Map<string, string[]>();
for (const removedOccurrence of removedOccurrences) {
calendarEventIdsByMeetingKey.set(removedOccurrence.realMeetingKey, [
...(calendarEventIdsByMeetingKey.get(removedOccurrence.realMeetingKey) ??
[]),
removedOccurrence.calendarEventId,
]);
}
return calendarEventIdsByMeetingKey;
};
const buildSkippedResult = (
realMeetingKey: string,
): MeetingBotReconciliationResult => ({
action: 'SKIPPED',
realMeetingKey,
callRecordingId: null,
});
@@ -0,0 +1,52 @@
import { isUndefined } from '@sniptt/guards';
import { 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 { rescheduleRecallBot } from 'src/logic-functions/recall-api/reschedule-recall-bot.util';
import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util';
const RECALL_BOT_NOT_FOUND_STATUS = 404;
export const rescheduleCallRecordingBot = async (
client: CoreApiClient,
{ callRecording, calendarEvent }: MeetingRecording,
): Promise<void> => {
const externalBotId = callRecording.externalBotId;
if (isUndefined(externalBotId)) {
return;
}
const meetingUrl = calendarEvent.conferenceLinkUrl;
const joinAt = calendarEvent.startsAt;
if (isUndefined(meetingUrl) || isUndefined(joinAt)) {
return;
}
const rescheduleResult = await rescheduleRecallBot({
externalBotId,
meetingUrl,
joinAt,
metadata: buildRecallBotMetadata({ callRecording, calendarEvent }),
});
if (rescheduleResult.ok) {
return;
}
// The caller re-runs ensureMeetingBot so this botless REQUESTED row is re-created by the single writer.
if (rescheduleResult.status === RECALL_BOT_NOT_FOUND_STATUS) {
await updateCallRecording(client, {
id: callRecording.id,
data: { externalBotId: null },
});
return;
}
console.warn(
`[twenty-meeting-bot] failed to update Recall bot for callRecording ${callRecording.id}: ${rescheduleResult.errorMessage}`,
);
};
@@ -0,0 +1,284 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cancelRecallBot } from 'src/logic-functions/recall-api/cancel-recall-bot.util';
import { rescheduleRecallBot } from 'src/logic-functions/recall-api/reschedule-recall-bot.util';
import { scheduleRecallBot } from 'src/logic-functions/recall-api/schedule-recall-bot.util';
const getRecallApiConfigMock = vi.hoisted(() => vi.fn());
vi.mock('src/logic-functions/recall-api/get-recall-api-config.util', () => ({
getRecallApiConfig: getRecallApiConfigMock,
}));
describe('recall bot api', () => {
const fetchMock = vi.fn();
beforeEach(() => {
getRecallApiConfigMock.mockReset();
getRecallApiConfigMock.mockReturnValue({
success: true,
config: {
apiKey: 'recall-api-key',
baseUrl: 'https://ap-northeast-1.recall.ai/api/v1',
botName: 'Twenty Meeting Bot',
},
});
fetchMock.mockReset();
fetchMock.mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ id: 'recall-bot-id' }),
});
vi.stubGlobal('fetch', fetchMock);
});
it('creates Recall bot requests with the Token authorization scheme', async () => {
const result = await scheduleRecallBot({
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-01T13:00:00.000Z',
metadata: {
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
},
});
expect(result).toEqual({ ok: true, externalBotId: 'recall-bot-id' });
expect(fetchMock).toHaveBeenCalledWith(
'https://ap-northeast-1.recall.ai/api/v1/bot/',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Token recall-api-key',
'Content-Type': 'application/json',
}),
}),
);
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
meeting_url: 'https://meet.google.com/abc-defg-hij',
join_at: '2026-01-01T13:00:00.000Z',
bot_name: 'Twenty Meeting Bot',
automatic_leave: {
waiting_room_timeout: 1200,
noone_joined_timeout: 1200,
},
recording_config: {
video_mixed_mp4: {},
audio_mixed_mp3: {},
},
metadata: {
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
},
});
});
it('carries the automatic leave config when rescheduling a bot', async () => {
const result = await rescheduleRecallBot({
externalBotId: 'recall-bot-id',
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-02T13:00:00.000Z',
metadata: {
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
},
});
expect(result).toEqual({ ok: true, externalBotId: 'recall-bot-id' });
expect(fetchMock).toHaveBeenCalledWith(
'https://ap-northeast-1.recall.ai/api/v1/bot/recall-bot-id/',
expect.objectContaining({ method: 'PATCH' }),
);
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual(
expect.objectContaining({
automatic_leave: {
waiting_room_timeout: 1200,
noone_joined_timeout: 1200,
},
}),
);
});
it('cancels a scheduled Recall bot request', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 204,
json: async () => ({}),
});
const result = await cancelRecallBot({
externalBotId: 'recall-bot-id',
});
expect(result).toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledWith(
'https://ap-northeast-1.recall.ai/api/v1/bot/recall-bot-id/',
expect.objectContaining({ method: 'DELETE' }),
);
});
it('fails when the create response does not include a bot id', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 201,
json: async () => ({}),
});
const result = await scheduleRecallBot({
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-01T13:00:00.000Z',
metadata: {
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
},
});
expect(result).toEqual({
ok: false,
status: null,
errorMessage:
'Recall API created a bot but the response did not include a bot id',
});
});
it('reports the HTTP status when rescheduling a bot that no longer exists', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 404,
json: async () => ({ detail: 'Not found.' }),
});
const result = await rescheduleRecallBot({
externalBotId: 'recall-bot-gone',
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-01T13:00:00.000Z',
metadata: {
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
},
});
expect(result).toEqual({
ok: false,
status: 404,
errorMessage:
'Recall API responded with HTTP 404: {"detail":"Not found."}',
});
});
it('does not duplicate an existing Token authorization prefix', async () => {
getRecallApiConfigMock.mockReturnValue({
success: true,
config: {
apiKey: 'Token recall-api-key',
baseUrl: 'https://ap-northeast-1.recall.ai/api/v1',
botName: 'Twenty Meeting Bot',
},
});
await scheduleRecallBot({
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-01T13:00:00.000Z',
metadata: {
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
},
});
expect(fetchMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Token recall-api-key',
}),
}),
);
});
describe('transient failure retries', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('retries a network failure and succeeds on the next attempt', async () => {
fetchMock.mockRejectedValueOnce(new Error('socket hang up'));
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ id: 'recall-bot-id' }),
});
const resultPromise = rescheduleRecallBot({
externalBotId: 'recall-bot-id',
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-01T13:00:00.000Z',
metadata: {
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
},
});
await vi.runAllTimersAsync();
expect(await resultPromise).toEqual({
ok: true,
externalBotId: 'recall-bot-id',
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('gives up after the attempt budget on persistent server errors', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ detail: 'server error' }),
});
const resultPromise = rescheduleRecallBot({
externalBotId: 'recall-bot-id',
meetingUrl: 'https://meet.google.com/abc-defg-hij',
joinAt: '2026-01-01T13:00:00.000Z',
metadata: {
twentyCallRecordingId: 'call-recording-id',
twentyCalendarEventId: 'calendar-event-id',
twentyRealMeetingKey: 'meeting-key',
},
});
await vi.runAllTimersAsync();
expect(await resultPromise).toEqual({
ok: false,
status: 500,
errorMessage:
'Recall API responded with HTTP 500: {"detail":"server error"}',
});
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('does not retry an allowed 404 on cancel', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 404,
json: async () => ({ detail: 'not found' }),
});
const result = await cancelRecallBot({
externalBotId: 'recall-bot-id',
});
expect(result).toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,28 @@
import { type RecallBotRemovalResult } from 'src/logic-functions/types/recall-bot-operation-result.type';
import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util';
import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util';
export const cancelRecallBot = async ({
externalBotId,
}: {
externalBotId: string;
}): Promise<RecallBotRemovalResult> => {
const configResult = getRecallApiConfig();
if (!configResult.success) {
return { ok: false, status: null, errorMessage: configResult.error };
}
const result = await recallBotApiRequest<undefined>({
config: configResult.config,
path: `/bot/${externalBotId}/`,
method: 'DELETE',
allowNotFound: true,
});
if (!result.ok) {
return result;
}
return { ok: true };
};
@@ -0,0 +1,10 @@
import { getString } from 'src/logic-functions/utils/get-string.util';
export type RecallBotResponse = {
id?: unknown;
bot_id?: unknown;
};
export const extractRecallBotId = (
response: RecallBotResponse | undefined,
): string | undefined => getString(response?.id) ?? getString(response?.bot_id);
@@ -0,0 +1,59 @@
import { isUndefined } from '@sniptt/guards';
import { DEFAULT_RECALL_BOT_NAME } from 'src/logic-functions/constants/default-recall-bot-name';
import { DEFAULT_RECALL_REGION } from 'src/logic-functions/constants/default-recall-region';
import { RECALL_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-api-key-env-var-name';
import { RECALL_BOT_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-bot-name-env-var-name';
import { RECALL_REGION_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-region-env-var-name';
import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util';
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
export type RecallApiConfig = {
apiKey: string;
baseUrl: string;
botName: string;
};
export const getRecallApiConfig = ():
| {
success: true;
config: RecallApiConfig;
}
| {
success: false;
error: string;
} => {
const apiKey = normalizeOptionalString(
getApplicationVariableValue(RECALL_API_KEY_ENV_VAR_NAME),
);
if (isUndefined(apiKey)) {
return {
success: false,
error:
'RECALL_API_KEY server variable is not set. A server admin must set it on the Twenty Meeting Bot application registration before scheduling bots.',
};
}
const region =
normalizeOptionalString(
getApplicationVariableValue(RECALL_REGION_ENV_VAR_NAME),
) ?? DEFAULT_RECALL_REGION;
const botName =
normalizeOptionalString(
getApplicationVariableValue(RECALL_BOT_NAME_ENV_VAR_NAME),
) ?? DEFAULT_RECALL_BOT_NAME;
return {
success: true,
config: {
apiKey,
baseUrl: `https://${region}.recall.ai/api/v1`,
botName,
},
};
};
const normalizeOptionalString = (
value: string | undefined,
): string | undefined => (isNonEmptyString(value) ? value.trim() : undefined);
@@ -0,0 +1,161 @@
import { isUndefined } from '@sniptt/guards';
import { RECALL_API_MAX_ATTEMPTS } from 'src/logic-functions/constants/recall-api-max-attempts';
import { RECALL_API_RETRY_DELAY_MS } from 'src/logic-functions/constants/recall-api-retry-delay-ms';
import { type RecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util';
type RecallBotApiRequestArgs = {
config: RecallApiConfig;
path: string;
method: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
allowNotFound?: boolean;
};
type RecallBotApiRequestResult<TData> =
| {
ok: true;
status: number;
data: TData;
}
| {
ok: false;
status: number | null;
errorMessage: string;
};
// Retried creates can duplicate bots; duplicates stay unclaimed and get reaped.
export const recallBotApiRequest = async <TData>(
requestArgs: RecallBotApiRequestArgs,
): Promise<RecallBotApiRequestResult<TData>> => {
for (let attemptNumber = 1; ; attemptNumber++) {
const { result, isRetryable } =
await performRecallBotApiRequestAttempt<TData>(requestArgs);
if (!isRetryable || attemptNumber >= RECALL_API_MAX_ATTEMPTS) {
return result;
}
await sleep(RECALL_API_RETRY_DELAY_MS * attemptNumber);
}
};
const performRecallBotApiRequestAttempt = async <TData>({
config,
path,
method,
body,
allowNotFound = false,
}: RecallBotApiRequestArgs): Promise<{
result: RecallBotApiRequestResult<TData>;
isRetryable: boolean;
}> => {
let response: Response;
try {
response = await fetch(`${config.baseUrl}${path}`, {
method,
headers: {
Authorization: buildRecallApiAuthorizationHeader(config.apiKey),
...(isUndefined(body) ? {} : { 'Content-Type': 'application/json' }),
},
...(isUndefined(body) ? {} : { body: JSON.stringify(body) }),
});
} catch (error) {
return {
isRetryable: true,
result: {
ok: false,
status: null,
errorMessage: `Recall API request failed: ${
error instanceof Error ? error.message : String(error)
}`,
},
};
}
if (allowNotFound && response.status === 404) {
return {
isRetryable: false,
result: {
ok: true,
status: response.status,
data: undefined as TData,
},
};
}
if (response.status === 204) {
return {
isRetryable: false,
result: {
ok: true,
status: response.status,
data: undefined as TData,
},
};
}
if (!response.ok) {
return {
isRetryable: isRetryableRecallApiStatus(response.status),
result: {
ok: false,
status: response.status,
errorMessage: await extractRecallApiErrorMessage(response),
},
};
}
try {
return {
isRetryable: false,
result: {
ok: true,
status: response.status,
data: (await response.json()) as TData,
},
};
} catch (error) {
return {
isRetryable: false,
result: {
ok: false,
status: response.status,
errorMessage: `Recall API returned a non-JSON response: ${
error instanceof Error ? error.message : String(error)
}`,
},
};
}
};
const isRetryableRecallApiStatus = (status: number): boolean =>
status === 429 || status >= 500;
const sleep = (delayMs: number): Promise<void> =>
new Promise((resolve) => {
setTimeout(resolve, delayMs);
});
const buildRecallApiAuthorizationHeader = (apiKey: string): string => {
const trimmedApiKey = apiKey.trim();
return trimmedApiKey.toLowerCase().startsWith('token ')
? trimmedApiKey
: `Token ${trimmedApiKey}`;
};
const extractRecallApiErrorMessage = async (
response: Response,
): Promise<string> => {
const fallback = `Recall API responded with HTTP ${response.status}`;
try {
const body = (await response.json()) as unknown;
return `${fallback}: ${JSON.stringify(body)}`;
} catch {
return fallback;
}
};
@@ -0,0 +1,50 @@
import { RECALL_BOT_AUTOMATIC_LEAVE } from 'src/logic-functions/constants/recall-bot-automatic-leave';
import { RECALL_BOT_RECORDING_CONFIG } from 'src/logic-functions/constants/recall-bot-recording-config';
import { type RecallBotScheduleResult } from 'src/logic-functions/types/recall-bot-operation-result.type';
import {
extractRecallBotId,
type RecallBotResponse,
} from 'src/logic-functions/recall-api/extract-recall-bot-id.util';
import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util';
import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util';
import { type ScheduleRecallBotArgs } from 'src/logic-functions/recall-api/schedule-recall-bot.util';
type RescheduleRecallBotArgs = ScheduleRecallBotArgs & {
externalBotId: string;
};
export const rescheduleRecallBot = async ({
externalBotId,
meetingUrl,
joinAt,
metadata,
}: RescheduleRecallBotArgs): Promise<RecallBotScheduleResult> => {
const configResult = getRecallApiConfig();
if (!configResult.success) {
return { ok: false, status: null, errorMessage: configResult.error };
}
const result = await recallBotApiRequest<RecallBotResponse>({
config: configResult.config,
path: `/bot/${externalBotId}/`,
method: 'PATCH',
body: {
meeting_url: meetingUrl,
join_at: joinAt,
bot_name: configResult.config.botName,
automatic_leave: RECALL_BOT_AUTOMATIC_LEAVE,
recording_config: RECALL_BOT_RECORDING_CONFIG,
metadata,
},
});
if (!result.ok) {
return result;
}
return {
ok: true,
externalBotId: extractRecallBotId(result.data) ?? externalBotId,
};
};
@@ -0,0 +1,64 @@
import { isUndefined } from '@sniptt/guards';
import { RECALL_BOT_AUTOMATIC_LEAVE } from 'src/logic-functions/constants/recall-bot-automatic-leave';
import { RECALL_BOT_RECORDING_CONFIG } from 'src/logic-functions/constants/recall-bot-recording-config';
import { type RecallBotMetadata } from 'src/logic-functions/types/recall-bot-metadata.type';
import { type RecallBotScheduleResult } from 'src/logic-functions/types/recall-bot-operation-result.type';
import {
extractRecallBotId,
type RecallBotResponse,
} from 'src/logic-functions/recall-api/extract-recall-bot-id.util';
import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util';
import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util';
export type ScheduleRecallBotArgs = {
meetingUrl: string;
joinAt: string;
metadata: RecallBotMetadata;
};
export const scheduleRecallBot = async ({
meetingUrl,
joinAt,
metadata,
}: ScheduleRecallBotArgs): Promise<RecallBotScheduleResult> => {
const configResult = getRecallApiConfig();
if (!configResult.success) {
return { ok: false, status: null, errorMessage: configResult.error };
}
const result = await recallBotApiRequest<RecallBotResponse>({
config: configResult.config,
path: '/bot/',
method: 'POST',
body: {
meeting_url: meetingUrl,
join_at: joinAt,
bot_name: configResult.config.botName,
automatic_leave: RECALL_BOT_AUTOMATIC_LEAVE,
recording_config: RECALL_BOT_RECORDING_CONFIG,
metadata,
},
});
if (!result.ok) {
return result;
}
const externalBotId = extractRecallBotId(result.data);
if (isUndefined(externalBotId)) {
return {
ok: false,
status: null,
errorMessage:
'Recall API created a bot but the response did not include a bot id',
};
}
return {
ok: true,
externalBotId,
};
};
@@ -0,0 +1,178 @@
import { isUndefined } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordBaseEvent,
} from 'twenty-sdk/define';
import { CALENDAR_EVENT_RECONCILIATION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/calendar-event-reconciliation-logic-function-universal-identifier';
import { type RemovedMeetingBotOccurrence } from 'src/logic-functions/types/removed-meeting-bot-occurrence.type';
import { computeRealMeetingKey } from 'src/logic-functions/domain/compute-real-meeting-key.util';
import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util';
import { reconcileMeetingBotForCalendarEventIds } from 'src/logic-functions/flows/reconcile-meeting-bot.util';
const CALENDAR_EVENT_OBJECT_NAME = 'calendarEvent';
const MEETING_BOT_RELEVANT_CALENDAR_EVENT_FIELDS = [
'title',
'meetingBotPreference',
'conferenceLink',
'startsAt',
'endsAt',
'isCanceled',
'iCalUid',
];
const MEETING_BOT_KEY_CALENDAR_EVENT_FIELDS = [
'conferenceLink',
'startsAt',
'iCalUid',
];
type CalendarEventForDatabaseEvent = {
id: string;
conferenceLink?: { primaryLinkUrl?: string | null } | null;
iCalUid?: string | null;
startsAt?: string | null;
};
type CalendarEventDatabaseEvent = DatabaseEventPayload<
ObjectRecordBaseEvent<CalendarEventForDatabaseEvent>
>;
type CalendarEventReconciliationPayload = {
calendarEventIds: string[];
removedOccurrences: RemovedMeetingBotOccurrence[];
};
const handler = async (
event: CalendarEventDatabaseEvent,
): Promise<object | undefined> => {
const [objectName, action] = event.name.split('.');
if (objectName !== CALENDAR_EVENT_OBJECT_NAME) {
return { skipped: true, reason: 'not a calendar event' };
}
const reconciliationPayload = buildCalendarEventReconciliationPayload({
event,
action,
});
if (
reconciliationPayload.calendarEventIds.length === 0 &&
reconciliationPayload.removedOccurrences.length === 0
) {
return { skipped: true, reason: 'no relevant calendar event change' };
}
const client = new CoreApiClient();
const reconciliationResults = await reconcileMeetingBotForCalendarEventIds({
client,
calendarEventIds: reconciliationPayload.calendarEventIds,
removedOccurrences: reconciliationPayload.removedOccurrences,
});
return {
reconciled: true,
calendarEventIds: reconciliationPayload.calendarEventIds,
removedOccurrenceCount: reconciliationPayload.removedOccurrences.length,
reconciliationResults,
};
};
const buildCalendarEventReconciliationPayload = ({
event,
action,
}: {
event: CalendarEventDatabaseEvent;
action: string | undefined;
}): CalendarEventReconciliationPayload => {
if (action === 'created') {
return {
calendarEventIds: getUniqueSortedIds([
event.recordId,
event.properties.after?.id,
]),
removedOccurrences: [],
};
}
if (action === 'updated') {
const updatedFields = event.properties.updatedFields ?? [];
if (!hasRelevantFieldChange(updatedFields)) {
return { calendarEventIds: [], removedOccurrences: [] };
}
const removedOccurrence = hasKeyFieldChange(updatedFields)
? buildRemovedOccurrence(event.properties.before)
: undefined;
return {
calendarEventIds: getUniqueSortedIds([
event.recordId,
event.properties.after?.id,
]),
removedOccurrences: isUndefined(removedOccurrence)
? []
: [removedOccurrence],
};
}
if (action === 'deleted' || action === 'destroyed') {
const removedOccurrence = buildRemovedOccurrence(event.properties.before);
return {
calendarEventIds: [],
removedOccurrences: isUndefined(removedOccurrence)
? []
: [removedOccurrence],
};
}
return { calendarEventIds: [], removedOccurrences: [] };
};
const hasRelevantFieldChange = (updatedFields: string[]): boolean =>
updatedFields.some((updatedField) =>
MEETING_BOT_RELEVANT_CALENDAR_EVENT_FIELDS.includes(updatedField),
);
const hasKeyFieldChange = (updatedFields: string[]): boolean =>
updatedFields.some((updatedField) =>
MEETING_BOT_KEY_CALENDAR_EVENT_FIELDS.includes(updatedField),
);
const buildRemovedOccurrence = (
calendarEvent: CalendarEventForDatabaseEvent | undefined,
): RemovedMeetingBotOccurrence | undefined => {
if (isUndefined(calendarEvent)) {
return undefined;
}
return {
calendarEventId: calendarEvent.id,
realMeetingKey: computeRealMeetingKey({
calendarEventId: calendarEvent.id,
conferenceLinkUrl: calendarEvent.conferenceLink?.primaryLinkUrl,
iCalUid: calendarEvent.iCalUid ?? undefined,
startsAt: calendarEvent.startsAt ?? undefined,
}),
startsAt: calendarEvent.startsAt ?? undefined,
};
};
export default defineLogicFunction({
universalIdentifier:
CALENDAR_EVENT_RECONCILIATION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'reconcile-meeting-bot-calendar-event',
description:
'Reconciles app-managed Recall bot recording requests when calendar events change.',
timeoutSeconds: 60,
handler,
databaseEventTriggerSettings: {
eventName: `${CALENDAR_EVENT_OBJECT_NAME}.*`,
},
});
@@ -0,0 +1,5 @@
import { type MeetingBotPolicyCalendarEventInput } from 'src/logic-functions/types/meeting-bot-policy-calendar-event-input.type';
export type CalendarEventRecord = MeetingBotPolicyCalendarEventInput & {
title: string | undefined;
};
@@ -0,0 +1,14 @@
import { type CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
// Domain read shape: absence is always undefined; null lives only on wire types.
export type CallRecordingRecord = {
id: string;
title?: string;
status?: string;
recordingRequestStatus?: CallRecordingRequestStatus;
startedAt?: string;
endedAt?: string;
calendarEventId?: string;
externalBotId?: string;
externalRecordingId?: string;
};
@@ -0,0 +1,10 @@
// Domain read shape: wire composites are flattened and absence is undefined.
export type MeetingBotPolicyCalendarEventInput = {
id: string;
isCanceled: boolean;
startsAt: string | undefined;
endsAt: string | undefined;
iCalUid: string | undefined;
conferenceLinkUrl: string | undefined;
meetingBotPreference: string | undefined;
};
@@ -0,0 +1,9 @@
import { type MeetingBotPreference } from 'src/constants/meeting-bot-preference';
export type MeetingBotPolicyInput = {
meetingBotPreference: MeetingBotPreference | undefined;
isCanceled: boolean;
startsAt: string | undefined;
endsAt: string | undefined;
conferenceLinkUrl: string | undefined;
};
@@ -0,0 +1,5 @@
export type MeetingBotPolicyNotRequiredReason =
| 'EVENT_CANCELED'
| 'PREFERENCE_OFF'
| 'MISSING_CONFERENCE_LINK'
| 'EVENT_NOT_UPCOMING';
@@ -0,0 +1 @@
export type MeetingBotPolicyRequiredReason = 'RECORDING_ENABLED';
@@ -0,0 +1,8 @@
import { type MeetingBotPreference } from 'src/constants/meeting-bot-preference';
import { type MeetingBotPolicyResult } from 'src/logic-functions/types/meeting-bot-policy-result.type';
export type MeetingBotPolicyResultForCalendarEvent = MeetingBotPolicyResult & {
calendarEventId: string;
meetingBotPreference: MeetingBotPreference | undefined;
realMeetingKey: string;
};
@@ -0,0 +1,6 @@
export type MeetingBotPolicyResultForMeeting = {
realMeetingKey: string;
shouldRequestBot: boolean;
calendarEventIds: string[];
requestingCalendarEventIds: string[];
};
@@ -0,0 +1,12 @@
import { type MeetingBotPolicyNotRequiredReason } from 'src/logic-functions/types/meeting-bot-policy-not-required-reason.type';
import { type MeetingBotPolicyRequiredReason } from 'src/logic-functions/types/meeting-bot-policy-required-reason.type';
export type MeetingBotPolicyResult =
| {
shouldRequestBot: true;
reason: MeetingBotPolicyRequiredReason;
}
| {
shouldRequestBot: false;
reason: MeetingBotPolicyNotRequiredReason;
};
@@ -0,0 +1,16 @@
export type MeetingBotReconciliationResult =
| {
action: 'CREATED' | 'UPDATED' | 'CANCELED';
realMeetingKey: string;
callRecordingId: string;
}
| {
action: 'SKIPPED';
realMeetingKey: string;
callRecordingId: string | null;
}
| {
action: 'FAILED';
realMeetingKey: string;
errorMessage: string;
};
@@ -0,0 +1,7 @@
import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type';
import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type';
export type MeetingRecording = {
callRecording: CallRecordingRecord;
calendarEvent: CalendarEventRecord;
};
@@ -0,0 +1,7 @@
export type RecallBotMetadata = {
twentyCallRecordingId: string;
twentyCalendarEventId: string;
twentyRealMeetingKey: string;
// Workspace dispatch key for a future host-level webhook ingress.
twentyApplicationId?: string;
};
@@ -0,0 +1,19 @@
export type RecallBotOperationFailure = {
ok: false;
// null = no HTTP response (network failure), distinct from any status code.
status: number | null;
errorMessage: string;
};
export type RecallBotScheduleResult =
| {
ok: true;
externalBotId: string;
}
| RecallBotOperationFailure;
export type RecallBotRemovalResult =
| {
ok: true;
}
| RecallBotOperationFailure;
@@ -0,0 +1,6 @@
// An occurrence whose event was deleted/moved; key + start re-checks siblings.
export type RemovedMeetingBotOccurrence = {
calendarEventId: string;
realMeetingKey: string;
startsAt: string | undefined;
};
@@ -0,0 +1,3 @@
// Application variables are injected into process.env on every execution.
export const getApplicationVariableValue = (key: string): string | undefined =>
process.env[key];
@@ -0,0 +1,4 @@
import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
export const getString = (value: unknown): string | undefined =>
isNonEmptyString(value) ? value : undefined;
@@ -0,0 +1,8 @@
import { isString } from '@sniptt/guards';
export const getUniqueSortedIds = (
ids: Array<string | null | undefined>,
): string[] =>
[...new Set(ids.filter(isString))].sort((firstId, secondId) =>
firstId.localeCompare(secondId),
);
@@ -0,0 +1,5 @@
import { isString } from '@sniptt/guards';
// Trimming variant of @sniptt/guards isNonEmptyString, for normalizing at read boundaries.
export const isNonEmptyString = (value: unknown): value is string =>
isString(value) && value.trim() !== '';
@@ -0,0 +1,14 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
include: ['src/**/*.test.ts'],
},
});
@@ -2625,6 +2625,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "twenty-meeting-bot@workspace:."
dependencies:
"@sniptt/guards": "npm:^0.2.0"
"@types/node": "npm:^24.7.2"
"@types/react": "npm:^19.0.0"
oxlint: "npm:^0.16.0"