From 12b1dba986aa56e7f6f91f4010b97ec15c110ce1 Mon Sep 17 00:00:00 2001
From: nitin <142569587+ehconitin@users.noreply.github.com>
Date: Tue, 16 Jun 2026 14:17:09 +0530
Subject: [PATCH] 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
---
.../ci-internal-app-twenty-meeting-bot.yaml | 111 ++
.../internal/twenty-meeting-bot/package.json | 3 +
.../src/__tests__/schema.integration-test.ts | 58 +-
.../src/application-config.ts | 26 +
...ion-logic-function-universal-identifier.ts | 2 +
.../meeting-bot-preference-auto-option-id.ts | 2 -
.../meeting-bot-preference-on-option-id.ts | 2 +-
.../src/constants/meeting-bot-preference.ts | 1 -
...-name-app-variable-universal-identifier.ts | 2 +
.../twenty-meeting-bot/src/default-role.ts | 27 +-
...-bot-preference-on-calendar-event.field.ts | 22 +-
.../constants/application-id-env-var-name.ts | 2 +
.../call-recording-request-status.ts | 5 +
.../constants/call-recording-status.ts | 9 +
.../constants/default-recall-bot-name.ts | 1 +
.../constants/default-recall-region.ts | 1 +
.../constants/recall-api-key-env-var-name.ts | 1 +
.../constants/recall-api-max-attempts.ts | 1 +
.../constants/recall-api-retry-delay-ms.ts | 1 +
.../constants/recall-bot-automatic-leave.ts | 7 +
.../constants/recall-bot-name-env-var-name.ts | 1 +
...recall-bot-noone-joined-timeout-seconds.ts | 1 +
.../constants/recall-bot-recording-config.ts | 5 +
...recall-bot-waiting-room-timeout-seconds.ts | 1 +
.../constants/recall-region-env-var-name.ts | 1 +
.../constants/restricted-field-placeholder.ts | 3 +
.../constants/twenty-page-size.ts | 1 +
.../data/__tests__/fetch-all-nodes.test.ts | 43 +
.../strip-restricted-field-value.test.ts | 22 +
.../data/create-call-recording.util.ts | 41 +
.../data/fetch-all-nodes.util.ts | 44 +
.../fetch-calendar-events-by-filter.util.ts | 80 ++
.../data/fetch-calendar-events-by-ids.util.ts | 20 +
...alendar-events-by-starts-at-values.util.ts | 19 +
...l-recordings-by-calendar-event-ids.util.ts | 17 +
.../find-call-recordings-by-filter.util.ts | 97 ++
.../data/find-call-recordings-by-ids.util.ts | 17 +
.../data/strip-restricted-field-value.util.ts | 6 +
.../data/update-call-recording.util.ts | 38 +
.../build-meeting-bot-policy-result.test.ts | 47 +
...pute-call-recording-id-for-meeting.test.ts | 37 +
.../compute-real-meeting-key.test.ts | 88 ++
.../resolve-meeting-bot-policy-result.test.ts | 120 ++
...ting-bot-policy-results-by-meeting.util.ts | 42 +
.../build-meeting-bot-policy-result.util.ts | 53 +
.../domain/build-recall-bot-metadata.util.ts | 30 +
...pute-call-recording-id-for-meeting.util.ts | 16 +
.../domain/compute-real-meeting-key.util.ts | 48 +
.../resolve-meeting-bot-policy-result.util.ts | 72 ++
.../__tests__/reconcile-meeting-bot.test.ts | 1009 +++++++++++++++++
.../cancel-call-recording-request.util.ts | 46 +
.../flows/ensure-meeting-bot.util.ts | 55 +
.../flows/reconcile-meeting-bot.util.ts | 495 ++++++++
.../reschedule-call-recording-bot.util.ts | 52 +
.../__tests__/recall-bot-api.test.ts | 284 +++++
.../recall-api/cancel-recall-bot.util.ts | 28 +
.../recall-api/extract-recall-bot-id.util.ts | 10 +
.../recall-api/get-recall-api-config.util.ts | 59 +
.../recall-api/recall-bot-api-request.util.ts | 161 +++
.../recall-api/reschedule-recall-bot.util.ts | 50 +
.../recall-api/schedule-recall-bot.util.ts | 64 ++
.../reconcile-meeting-bot-calendar-event.ts | 178 +++
.../types/calendar-event-record.type.ts | 5 +
.../types/call-recording-record.type.ts | 14 +
...ng-bot-policy-calendar-event-input.type.ts | 10 +
.../types/meeting-bot-policy-input.type.ts | 9 +
...ing-bot-policy-not-required-reason.type.ts | 5 +
...meeting-bot-policy-required-reason.type.ts | 1 +
...t-policy-result-for-calendar-event.type.ts | 8 +
...ting-bot-policy-result-for-meeting.type.ts | 6 +
.../types/meeting-bot-policy-result.type.ts | 12 +
.../meeting-bot-reconciliation-result.type.ts | 16 +
.../types/meeting-recording.type.ts | 7 +
.../types/recall-bot-metadata.type.ts | 7 +
.../types/recall-bot-operation-result.type.ts | 19 +
.../removed-meeting-bot-occurrence.type.ts | 6 +
.../get-application-variable-value.util.ts | 3 +
.../logic-functions/utils/get-string.util.ts | 4 +
.../utils/get-unique-sorted-ids.util.ts | 8 +
.../utils/is-non-empty-string.util.ts | 5 +
.../twenty-meeting-bot/vitest.unit.config.ts | 14 +
.../internal/twenty-meeting-bot/yarn.lock | 1 +
82 files changed, 3892 insertions(+), 53 deletions(-)
create mode 100644 .github/workflows/ci-internal-app-twenty-meeting-bot.yaml
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/constants/calendar-event-reconciliation-logic-function-universal-identifier.ts
delete mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/constants/meeting-bot-preference-auto-option-id.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-name-app-variable-universal-identifier.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/application-id-env-var-name.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/call-recording-request-status.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/call-recording-status.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-bot-name.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-region.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-api-key-env-var-name.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-api-max-attempts.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-api-retry-delay-ms.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-automatic-leave.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-name-env-var-name.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-noone-joined-timeout-seconds.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-recording-config.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-waiting-room-timeout-seconds.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-region-env-var-name.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/restricted-field-placeholder.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/twenty-page-size.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/fetch-all-nodes.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/strip-restricted-field-value.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/create-call-recording.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/fetch-all-nodes.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-filter.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-ids.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-starts-at-values.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-calendar-event-ids.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-filter.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-ids.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/strip-restricted-field-value.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/update-call-recording.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/build-meeting-bot-policy-result.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-call-recording-id-for-meeting.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-real-meeting-key.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/resolve-meeting-bot-policy-result.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/aggregate-meeting-bot-policy-results-by-meeting.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-meeting-bot-policy-result.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-recall-bot-metadata.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-call-recording-id-for-meeting.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-real-meeting-key.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/resolve-meeting-bot-policy-result.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-meeting-bot.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/cancel-call-recording-request.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/ensure-meeting-bot.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-meeting-bot.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reschedule-call-recording-bot.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/cancel-recall-bot.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-bot-id.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/get-recall-api-config.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/recall-bot-api-request.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/reschedule-recall-bot.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/schedule-recall-bot.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/reconcile-meeting-bot-calendar-event.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/calendar-event-record.type.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/call-recording-record.type.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-calendar-event-input.type.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-input.type.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-not-required-reason.type.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-required-reason.type.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result-for-calendar-event.type.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result-for-meeting.type.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result.type.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-reconciliation-result.type.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-recording.type.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/recall-bot-metadata.type.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/recall-bot-operation-result.type.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/removed-meeting-bot-occurrence.type.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-application-variable-value.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-string.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-unique-sorted-ids.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/is-non-empty-string.util.ts
create mode 100644 packages/twenty-apps/internal/twenty-meeting-bot/vitest.unit.config.ts
diff --git a/.github/workflows/ci-internal-app-twenty-meeting-bot.yaml b/.github/workflows/ci-internal-app-twenty-meeting-bot.yaml
new file mode 100644
index 0000000000..f84747e8d8
--- /dev/null
+++ b/.github/workflows/ci-internal-app-twenty-meeting-bot.yaml
@@ -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
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/package.json b/packages/twenty-apps/internal/twenty-meeting-bot/package.json
index c19c9feebf..e482ec28d3 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/package.json
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/package.json
@@ -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": {
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/__tests__/schema.integration-test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/__tests__/schema.integration-test.ts
index f9e79bcdda..a8e294cc78 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/__tests__/schema.integration-test.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/__tests__/schema.integration-test.ts
@@ -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,
},
});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/application-config.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/application-config.ts
index c64660ddfd..514c3dacad 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/application-config.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/application-config.ts
@@ -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,
+ },
+ },
});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/calendar-event-reconciliation-logic-function-universal-identifier.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/calendar-event-reconciliation-logic-function-universal-identifier.ts
new file mode 100644
index 0000000000..b9f7a2ee81
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/calendar-event-reconciliation-logic-function-universal-identifier.ts
@@ -0,0 +1,2 @@
+export const CALENDAR_EVENT_RECONCILIATION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
+ '1f28c477-6423-4911-85bf-2296ef112be9';
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/meeting-bot-preference-auto-option-id.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/meeting-bot-preference-auto-option-id.ts
deleted file mode 100644
index f8dc7f4852..0000000000
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/meeting-bot-preference-auto-option-id.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const MEETING_BOT_PREFERENCE_AUTO_OPTION_ID =
- '72431216-49c4-47c8-99af-de4c3831b0be';
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/meeting-bot-preference-on-option-id.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/meeting-bot-preference-on-option-id.ts
index 92b0081167..77a82fa86d 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/meeting-bot-preference-on-option-id.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/meeting-bot-preference-on-option-id.ts
@@ -1,2 +1,2 @@
export const MEETING_BOT_PREFERENCE_ON_OPTION_ID =
- 'd7b437b1-d6a3-4e99-8bb8-ba632d4a544e';
+ '72431216-49c4-47c8-99af-de4c3831b0be';
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/meeting-bot-preference.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/meeting-bot-preference.ts
index b4eafb7db4..670e71c935 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/meeting-bot-preference.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/meeting-bot-preference.ts
@@ -1,5 +1,4 @@
export enum MeetingBotPreference {
- AUTO = 'AUTO',
ON = 'ON',
OFF = 'OFF',
}
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-name-app-variable-universal-identifier.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-name-app-variable-universal-identifier.ts
new file mode 100644
index 0000000000..a30824b424
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/constants/recall-bot-name-app-variable-universal-identifier.ts
@@ -0,0 +1,2 @@
+export const RECALL_BOT_NAME_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
+ 'c54cbacd-ad10-40b4-9056-7aaf23846d64';
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/default-role.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/default-role.ts
index 1a7b2e03ba..5cb23410fb 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/default-role.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/default-role.ts
@@ -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,
- ],
});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/fields/meeting-bot-preference-on-calendar-event.field.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/fields/meeting-bot-preference-on-calendar-event.field.ts
index 7338543ab8..078c91a327 100644
--- a/packages/twenty-apps/internal/twenty-meeting-bot/src/fields/meeting-bot-preference-on-calendar-event.field.ts
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/fields/meeting-bot-preference-on-calendar-event.field.ts
@@ -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',
},
],
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/application-id-env-var-name.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/application-id-env-var-name.ts
new file mode 100644
index 0000000000..5d125bc037
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/application-id-env-var-name.ts
@@ -0,0 +1,2 @@
+// Injected by the platform into every logic function execution.
+export const APPLICATION_ID_ENV_VAR_NAME = 'APPLICATION_ID';
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/call-recording-request-status.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/call-recording-request-status.ts
new file mode 100644
index 0000000000..0413760cbc
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/call-recording-request-status.ts
@@ -0,0 +1,5 @@
+// Mirrors the core select options; guarded by the schema integration test.
+export enum CallRecordingRequestStatus {
+ REQUESTED = 'REQUESTED',
+ CANCELED = 'CANCELED',
+}
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/call-recording-status.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/call-recording-status.ts
new file mode 100644
index 0000000000..11b780e6e5
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/call-recording-status.ts
@@ -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',
+}
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-bot-name.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-bot-name.ts
new file mode 100644
index 0000000000..4e0bacb9ed
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-bot-name.ts
@@ -0,0 +1 @@
+export const DEFAULT_RECALL_BOT_NAME = 'Twenty Meeting Bot';
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-region.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-region.ts
new file mode 100644
index 0000000000..dd19a5e7a3
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/default-recall-region.ts
@@ -0,0 +1 @@
+export const DEFAULT_RECALL_REGION = 'ap-northeast-1';
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-api-key-env-var-name.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-api-key-env-var-name.ts
new file mode 100644
index 0000000000..aa5f13a0ac
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-api-key-env-var-name.ts
@@ -0,0 +1 @@
+export const RECALL_API_KEY_ENV_VAR_NAME = 'RECALL_API_KEY';
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-api-max-attempts.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-api-max-attempts.ts
new file mode 100644
index 0000000000..b8c6d08365
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-api-max-attempts.ts
@@ -0,0 +1 @@
+export const RECALL_API_MAX_ATTEMPTS = 3;
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-api-retry-delay-ms.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-api-retry-delay-ms.ts
new file mode 100644
index 0000000000..06dba9a11d
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-api-retry-delay-ms.ts
@@ -0,0 +1 @@
+export const RECALL_API_RETRY_DELAY_MS = 500;
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-automatic-leave.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-automatic-leave.ts
new file mode 100644
index 0000000000..1d1c9be90d
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-automatic-leave.ts
@@ -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,
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-name-env-var-name.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-name-env-var-name.ts
new file mode 100644
index 0000000000..4d6d97f070
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-name-env-var-name.ts
@@ -0,0 +1 @@
+export const RECALL_BOT_NAME_ENV_VAR_NAME = 'RECALL_BOT_NAME';
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-noone-joined-timeout-seconds.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-noone-joined-timeout-seconds.ts
new file mode 100644
index 0000000000..2a4337a0a8
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-noone-joined-timeout-seconds.ts
@@ -0,0 +1 @@
+export const RECALL_BOT_NOONE_JOINED_TIMEOUT_SECONDS = 20 * 60;
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-recording-config.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-recording-config.ts
new file mode 100644
index 0000000000..6f1102e830
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-recording-config.ts
@@ -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: {},
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-waiting-room-timeout-seconds.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-waiting-room-timeout-seconds.ts
new file mode 100644
index 0000000000..e366ee008b
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-bot-waiting-room-timeout-seconds.ts
@@ -0,0 +1 @@
+export const RECALL_BOT_WAITING_ROOM_TIMEOUT_SECONDS = 1200;
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-region-env-var-name.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-region-env-var-name.ts
new file mode 100644
index 0000000000..31c9a29523
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/recall-region-env-var-name.ts
@@ -0,0 +1 @@
+export const RECALL_REGION_ENV_VAR_NAME = 'RECALL_REGION';
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/restricted-field-placeholder.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/restricted-field-placeholder.ts
new file mode 100644
index 0000000000..80388082c8
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/restricted-field-placeholder.ts
@@ -0,0 +1,3 @@
+// Mirrors twenty-shared; calendar restrictions write it over title/description.
+export const RESTRICTED_FIELD_PLACEHOLDER =
+ 'FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED';
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/twenty-page-size.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/twenty-page-size.ts
new file mode 100644
index 0000000000..51637dc13d
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/constants/twenty-page-size.ts
@@ -0,0 +1 @@
+export const TWENTY_PAGE_SIZE = 100;
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/fetch-all-nodes.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/fetch-all-nodes.test.ts
new file mode 100644
index 0000000000..c82576d67f
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/fetch-all-nodes.test.ts
@@ -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(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(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(fetchPage)).rejects.toThrow(
+ 'Pagination query returned no connection',
+ );
+ });
+});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/strip-restricted-field-value.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/strip-restricted-field-value.test.ts
new file mode 100644
index 0000000000..27bc08f2f9
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/__tests__/strip-restricted-field-value.test.ts
@@ -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();
+ });
+});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/create-call-recording.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/create-call-recording.util.ts
new file mode 100644
index 0000000000..8b653bc104
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/create-call-recording.util.ts
@@ -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 => {
+ 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;
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/fetch-all-nodes.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/fetch-all-nodes.util.ts
new file mode 100644
index 0000000000..ce16ce4fe8
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/fetch-all-nodes.util.ts
@@ -0,0 +1,44 @@
+import { isString, isUndefined } from '@sniptt/guards';
+
+export type ConnectionPage = {
+ pageInfo?: {
+ hasNextPage?: boolean | null;
+ endCursor?: string | null;
+ } | null;
+ edges?: Array<{ node: TNode }> | null;
+};
+
+export const fetchAllNodes = async (
+ fetchPage: (
+ afterCursor: string | undefined,
+ ) => Promise | undefined>,
+): Promise => {
+ 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;
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-filter.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-filter.util.ts
new file mode 100644
index 0000000000..18cfb33172
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-filter.util.ts
@@ -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,
+): Promise => {
+ const calendarEventNodes = await fetchAllNodes(
+ 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
+ | 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,
+ }));
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-ids.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-ids.util.ts
new file mode 100644
index 0000000000..a6144f712b
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-ids.util.ts
@@ -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 => {
+ const uniqueCalendarEventIds = getUniqueSortedIds(calendarEventIds);
+
+ if (uniqueCalendarEventIds.length === 0) {
+ return [];
+ }
+
+ return fetchCalendarEventsByFilter(client, {
+ id: { in: uniqueCalendarEventIds },
+ });
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-starts-at-values.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-starts-at-values.util.ts
new file mode 100644
index 0000000000..a5a70b9c46
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-starts-at-values.util.ts
@@ -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 => {
+ const uniqueStartsAtValues = [...new Set(startsAtValues)].sort();
+
+ if (uniqueStartsAtValues.length === 0) {
+ return [];
+ }
+
+ return fetchCalendarEventsByFilter(client, {
+ startsAt: { in: uniqueStartsAtValues },
+ });
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-calendar-event-ids.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-calendar-event-ids.util.ts
new file mode 100644
index 0000000000..bfbbe14bbf
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-calendar-event-ids.util.ts
@@ -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 => {
+ if (calendarEventIds.length === 0) {
+ return [];
+ }
+
+ return findCallRecordingsByFilter(client, {
+ calendarEventId: { in: calendarEventIds },
+ });
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-filter.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-filter.util.ts
new file mode 100644
index 0000000000..c17c2f1a7c
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-filter.util.ts
@@ -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,
+): Promise => {
+ const callRecordingNodes = await fetchAllNodes(
+ 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
+ | 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;
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-ids.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-ids.util.ts
new file mode 100644
index 0000000000..8559795859
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-ids.util.ts
@@ -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 => {
+ if (callRecordingIds.length === 0) {
+ return [];
+ }
+
+ return findCallRecordingsByFilter(client, {
+ id: { in: callRecordingIds },
+ });
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/strip-restricted-field-value.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/strip-restricted-field-value.util.ts
new file mode 100644
index 0000000000..06b4a79a47
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/strip-restricted-field-value.util.ts
@@ -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;
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/update-call-recording.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/update-call-recording.util.ts
new file mode 100644
index 0000000000..27da032bcf
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/data/update-call-recording.util.ts
@@ -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 => {
+ await client.mutation({
+ updateCallRecording: {
+ __args: {
+ id,
+ data,
+ },
+ id: true,
+ },
+ });
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/build-meeting-bot-policy-result.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/build-meeting-bot-policy-result.test.ts
new file mode 100644
index 0000000000..919b8d7948
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/build-meeting-bot-policy-result.test.ts
@@ -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 => ({
+ 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');
+ });
+});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-call-recording-id-for-meeting.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-call-recording-id-for-meeting.test.ts
new file mode 100644
index 0000000000..45fea40e4b
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-call-recording-id-for-meeting.test.ts
@@ -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);
+ });
+});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-real-meeting-key.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-real-meeting-key.test.ts
new file mode 100644
index 0000000000..940316139e
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-real-meeting-key.test.ts
@@ -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[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:',
+ );
+ });
+});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/resolve-meeting-bot-policy-result.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/resolve-meeting-bot-policy-result.test.ts
new file mode 100644
index 0000000000..4ed5739e37
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/__tests__/resolve-meeting-bot-policy-result.test.ts
@@ -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',
+ });
+ });
+});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/aggregate-meeting-bot-policy-results-by-meeting.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/aggregate-meeting-bot-policy-results-by-meeting.util.ts
new file mode 100644
index 0000000000..07c7975b8c
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/aggregate-meeting-bot-policy-results-by-meeting.util.ts
@@ -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()];
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-meeting-bot-policy-result.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-meeting-bot-policy-result.util.ts
new file mode 100644
index 0000000000..fc181bcd06
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-meeting-bot-policy-result.util.ts
@@ -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,
+ );
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-recall-bot-metadata.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-recall-bot-metadata.util.ts
new file mode 100644
index 0000000000..93ec5eb3b0
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/build-recall-bot-metadata.util.ts
@@ -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 }),
+ };
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-call-recording-id-for-meeting.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-call-recording-id-for-meeting.util.ts
new file mode 100644
index 0000000000..5df420dd35
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-call-recording-id-for-meeting.util.ts
@@ -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)}`;
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-real-meeting-key.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-real-meeting-key.util.ts
new file mode 100644
index 0000000000..56f59cf7f3
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/compute-real-meeting-key.util.ts
@@ -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;
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/resolve-meeting-bot-policy-result.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/resolve-meeting-bot-policy-result.util.ts
new file mode 100644
index 0000000000..d90cc1906f
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/domain/resolve-meeting-bot-policy-result.util.ts
@@ -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 });
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-meeting-bot.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-meeting-bot.test.ts
new file mode 100644
index 0000000000..43270c423e
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-meeting-bot.test.ts
@@ -0,0 +1,1009 @@
+import { type CoreApiClient } from 'twenty-client-sdk/core';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { computeCallRecordingIdForMeeting } from 'src/logic-functions/domain/compute-call-recording-id-for-meeting.util';
+import { reconcileMeetingBotForCalendarEventIds } from 'src/logic-functions/flows/reconcile-meeting-bot.util';
+
+const scheduleRecallBotMock = vi.hoisted(() => vi.fn());
+const rescheduleRecallBotMock = vi.hoisted(() => vi.fn());
+const cancelRecallBotMock = vi.hoisted(() => vi.fn());
+
+vi.mock('src/logic-functions/recall-api/schedule-recall-bot.util', () => ({
+ scheduleRecallBot: scheduleRecallBotMock,
+}));
+
+vi.mock('src/logic-functions/recall-api/reschedule-recall-bot.util', () => ({
+ rescheduleRecallBot: rescheduleRecallBotMock,
+}));
+
+vi.mock('src/logic-functions/recall-api/cancel-recall-bot.util', () => ({
+ cancelRecallBot: cancelRecallBotMock,
+}));
+
+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 buildCustomerSyncCallRecordingId = (
+ startsAt: string = FUTURE_STARTS_AT,
+): string =>
+ computeCallRecordingIdForMeeting(
+ `link:meet.example.com/customer-sync:${startsAt}`,
+ );
+
+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;
+};
+
+type CallRecordingNode = {
+ id: string;
+ title?: string | null;
+ status?: string | null;
+ recordingRequestStatus?: string | null;
+ startedAt?: string | null;
+ endedAt?: string | null;
+ calendarEventId?: string | null;
+ externalBotId?: string | null;
+ externalRecordingId?: string | null;
+};
+
+type FakeCoreApiClientFixture = {
+ calendarEvents: CalendarEventNode[];
+ callRecordings?: CallRecordingNode[];
+};
+
+class FakeCoreApiClient {
+ calendarEvents: CalendarEventNode[];
+ callRecordings: CallRecordingNode[];
+ mutations: Array<{ name: string; args: unknown }> = [];
+
+ constructor({
+ calendarEvents,
+ callRecordings = [],
+ }: FakeCoreApiClientFixture) {
+ this.calendarEvents = calendarEvents;
+ this.callRecordings = callRecordings;
+ }
+
+ async query(query: any): Promise {
+ if (query.calendarEvents !== undefined) {
+ return {
+ calendarEvents: buildConnection(
+ this.filterCalendarEvents(query.calendarEvents.__args.filter),
+ ),
+ };
+ }
+
+ if (query.callRecordings !== undefined) {
+ const filter = query.callRecordings.__args.filter;
+
+ if (filter.id?.in !== undefined) {
+ return {
+ callRecordings: buildConnection(
+ this.callRecordings.filter((callRecording) =>
+ filter.id.in.includes(callRecording.id),
+ ),
+ ),
+ };
+ }
+
+ return {
+ callRecordings: buildConnection(
+ this.callRecordings.filter((callRecording) =>
+ filter.calendarEventId.in.includes(callRecording.calendarEventId),
+ ),
+ ),
+ };
+ }
+
+ throw new Error(`Unhandled query: ${JSON.stringify(query)}`);
+ }
+
+ async mutation(mutation: any): Promise {
+ if (mutation.createCallRecording !== undefined) {
+ const data = mutation.createCallRecording.__args.data;
+
+ if (this.callRecordings.some((candidate) => candidate.id === data.id)) {
+ throw new Error(`Duplicate call recording id ${data.id}`);
+ }
+
+ const createdCallRecording = { ...data };
+
+ this.callRecordings.push(createdCallRecording);
+ this.mutations.push({
+ name: 'createCallRecording',
+ args: data,
+ });
+
+ return {
+ createCallRecording: {
+ id: createdCallRecording.id,
+ },
+ };
+ }
+
+ if (mutation.updateCallRecording !== undefined) {
+ const { id, data } = mutation.updateCallRecording.__args;
+ const callRecording = this.callRecordings.find(
+ (candidate) => candidate.id === id,
+ );
+
+ if (callRecording === undefined) {
+ throw new Error(`Could not find call recording ${id}`);
+ }
+
+ Object.assign(callRecording, data);
+ this.mutations.push({
+ name: 'updateCallRecording',
+ args: { id, data },
+ });
+
+ return {
+ updateCallRecording: {
+ id,
+ },
+ };
+ }
+
+ throw new Error(`Unhandled mutation: ${JSON.stringify(mutation)}`);
+ }
+
+ private filterCalendarEvents(filter: any): CalendarEventNode[] {
+ if (filter.id?.in !== undefined) {
+ return this.calendarEvents.filter((calendarEvent) =>
+ filter.id.in.includes(calendarEvent.id),
+ );
+ }
+
+ if (filter.startsAt?.in !== undefined) {
+ return this.calendarEvents.filter((calendarEvent) =>
+ filter.startsAt.in.includes(calendarEvent.startsAt),
+ );
+ }
+
+ throw new Error(
+ `Unhandled calendar event filter: ${JSON.stringify(filter)}`,
+ );
+ }
+}
+
+const buildConnection = (nodes: Node[]) => ({
+ pageInfo: {
+ hasNextPage: false,
+ endCursor: undefined,
+ },
+ edges: nodes.map((node) => ({ node })),
+});
+
+const buildCalendarEvent = (
+ overrides: Partial = {},
+): CalendarEventNode => ({
+ id: 'calendar-event-1',
+ title: 'Customer Sync',
+ isCanceled: false,
+ startsAt: FUTURE_STARTS_AT,
+ endsAt: FUTURE_ENDS_AT,
+ iCalUid: 'calendar-event-uid',
+ conferenceLink: {
+ primaryLinkUrl: 'https://meet.example.com/customer-sync',
+ },
+ meetingBotPreference: 'ON',
+ ...overrides,
+});
+
+const buildFakeCoreApiClient = (
+ fixture: FakeCoreApiClientFixture,
+): FakeCoreApiClient => new FakeCoreApiClient(fixture);
+
+describe('reconcileMeetingBotForCalendarEventIds', () => {
+ beforeEach(() => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ scheduleRecallBotMock.mockReset();
+ scheduleRecallBotMock.mockResolvedValue({
+ ok: true,
+ externalBotId: 'recall-bot-1',
+ });
+ rescheduleRecallBotMock.mockReset();
+ rescheduleRecallBotMock.mockResolvedValue({
+ ok: true,
+ externalBotId: 'recall-bot-1',
+ });
+ cancelRecallBotMock.mockReset();
+ cancelRecallBotMock.mockResolvedValue({
+ ok: true,
+ externalBotId: null,
+ });
+ });
+
+ it('creates a scheduled call recording when the policy requests a bot', async () => {
+ const client = buildFakeCoreApiClient({
+ calendarEvents: [buildCalendarEvent()],
+ });
+
+ const result = await reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1'],
+ now: NOW,
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ action: 'CREATED',
+ callRecordingId: buildCustomerSyncCallRecordingId(),
+ }),
+ ]);
+ expect(client.callRecordings).toEqual([
+ {
+ id: buildCustomerSyncCallRecordingId(),
+ title: 'Customer Sync',
+ status: 'SCHEDULED',
+ recordingRequestStatus: 'REQUESTED',
+ calendarEventId: 'calendar-event-1',
+ externalBotId: 'recall-bot-1',
+ },
+ ]);
+ expect(scheduleRecallBotMock).toHaveBeenCalledWith({
+ meetingUrl: 'https://meet.example.com/customer-sync',
+ joinAt: FUTURE_STARTS_AT,
+ metadata: {
+ twentyCallRecordingId: buildCustomerSyncCallRecordingId(),
+ twentyCalendarEventId: 'calendar-event-1',
+ twentyRealMeetingKey:
+ 'link:meet.example.com/customer-sync:2026-01-01T13:00:00.000Z',
+ },
+ });
+ });
+
+ it('creates a scheduled call recording for the default ON preference', async () => {
+ const client = buildFakeCoreApiClient({
+ calendarEvents: [buildCalendarEvent({ meetingBotPreference: null })],
+ });
+
+ const result = await reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1'],
+ now: NOW,
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ action: 'CREATED',
+ callRecordingId: buildCustomerSyncCallRecordingId(),
+ }),
+ ]);
+ expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('creates a recording for an in-progress meeting that has not ended', async () => {
+ const client = buildFakeCoreApiClient({
+ calendarEvents: [
+ buildCalendarEvent({
+ meetingBotPreference: null,
+ startsAt: '2026-01-01T11:30:00.000Z',
+ endsAt: '2026-01-01T13:00:00.000Z',
+ }),
+ ],
+ });
+
+ const result = await reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1'],
+ now: NOW,
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ action: 'CREATED',
+ callRecordingId: buildCustomerSyncCallRecordingId(
+ '2026-01-01T11:30:00.000Z',
+ ),
+ }),
+ ]);
+ expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('updates an existing in-progress recording', async () => {
+ const client = buildFakeCoreApiClient({
+ calendarEvents: [
+ buildCalendarEvent({
+ meetingBotPreference: null,
+ title: 'Updated Customer Sync',
+ startsAt: '2026-01-01T11:30:00.000Z',
+ endsAt: '2026-01-01T13:00:00.000Z',
+ }),
+ ],
+ callRecordings: [
+ {
+ id: buildCustomerSyncCallRecordingId('2026-01-01T11:30:00.000Z'),
+ title: 'Old Customer Sync',
+ status: 'SCHEDULED',
+ recordingRequestStatus: 'REQUESTED',
+ calendarEventId: 'calendar-event-1',
+ externalBotId: 'recall-bot-1',
+ },
+ ],
+ });
+
+ const result = await reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1'],
+ now: NOW,
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ action: 'UPDATED',
+ callRecordingId: buildCustomerSyncCallRecordingId(
+ '2026-01-01T11:30:00.000Z',
+ ),
+ }),
+ ]);
+ expect(client.callRecordings).toEqual([
+ expect.objectContaining({
+ title: 'Updated Customer Sync',
+ recordingRequestStatus: 'REQUESTED',
+ }),
+ ]);
+ });
+
+ it('updates an existing policy-managed scheduled call recording', async () => {
+ const client = buildFakeCoreApiClient({
+ calendarEvents: [
+ buildCalendarEvent({
+ title: 'Updated Customer Sync',
+ }),
+ ],
+ callRecordings: [
+ {
+ id: buildCustomerSyncCallRecordingId(),
+ title: 'Old Customer Sync',
+ status: 'SCHEDULED',
+ recordingRequestStatus: 'REQUESTED',
+ startedAt: FUTURE_STARTS_AT,
+ endedAt: FUTURE_ENDS_AT,
+ calendarEventId: 'calendar-event-1',
+ externalBotId: 'recall-bot-1',
+ },
+ ],
+ });
+
+ const result = await reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1'],
+ now: NOW,
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ action: 'UPDATED',
+ callRecordingId: buildCustomerSyncCallRecordingId(),
+ }),
+ ]);
+ expect(client.callRecordings).toEqual([
+ expect.objectContaining({
+ id: buildCustomerSyncCallRecordingId(),
+ title: 'Updated Customer Sync',
+ status: 'SCHEDULED',
+ recordingRequestStatus: 'REQUESTED',
+ startedAt: FUTURE_STARTS_AT,
+ endedAt: FUTURE_ENDS_AT,
+ calendarEventId: 'calendar-event-1',
+ externalBotId: 'recall-bot-1',
+ }),
+ ]);
+ expect(rescheduleRecallBotMock).toHaveBeenCalledWith({
+ externalBotId: 'recall-bot-1',
+ meetingUrl: 'https://meet.example.com/customer-sync',
+ joinAt: FUTURE_STARTS_AT,
+ metadata: {
+ twentyCallRecordingId: buildCustomerSyncCallRecordingId(),
+ twentyCalendarEventId: 'calendar-event-1',
+ twentyRealMeetingKey:
+ 'link:meet.example.com/customer-sync:2026-01-01T13:00:00.000Z',
+ },
+ });
+ });
+
+ it('cancels an existing scheduled request when the policy no longer requests a bot', async () => {
+ const client = buildFakeCoreApiClient({
+ calendarEvents: [
+ buildCalendarEvent({
+ meetingBotPreference: 'OFF',
+ }),
+ ],
+ callRecordings: [
+ {
+ id: 'call-recording-1',
+ title: 'Customer Sync',
+ status: 'SCHEDULED',
+ recordingRequestStatus: 'REQUESTED',
+ startedAt: FUTURE_STARTS_AT,
+ endedAt: FUTURE_ENDS_AT,
+ calendarEventId: 'calendar-event-1',
+ externalBotId: 'recall-bot-1',
+ },
+ ],
+ });
+
+ const result = await reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1'],
+ now: NOW,
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ action: 'CANCELED',
+ callRecordingId: 'call-recording-1',
+ }),
+ ]);
+ expect(client.callRecordings).toEqual([
+ expect.objectContaining({
+ id: 'call-recording-1',
+ recordingRequestStatus: 'CANCELED',
+ externalBotId: null,
+ }),
+ ]);
+ expect(cancelRecallBotMock).toHaveBeenCalledWith({
+ externalBotId: 'recall-bot-1',
+ });
+ });
+
+ it('persists the cancel intent and leaves the bot for the planned stale-state cron when the Recall cancel fails', async () => {
+ cancelRecallBotMock.mockResolvedValue({
+ ok: false,
+ status: 500,
+ errorMessage: 'Recall API responded with HTTP 500',
+ });
+
+ const client = buildFakeCoreApiClient({
+ calendarEvents: [
+ buildCalendarEvent({
+ meetingBotPreference: 'OFF',
+ }),
+ ],
+ callRecordings: [
+ {
+ id: 'call-recording-1',
+ title: 'Customer Sync',
+ status: 'SCHEDULED',
+ recordingRequestStatus: 'REQUESTED',
+ startedAt: FUTURE_STARTS_AT,
+ endedAt: FUTURE_ENDS_AT,
+ calendarEventId: 'calendar-event-1',
+ externalBotId: 'recall-bot-1',
+ },
+ ],
+ });
+
+ const result = await reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1'],
+ now: NOW,
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ action: 'CANCELED',
+ callRecordingId: 'call-recording-1',
+ }),
+ ]);
+ expect(client.callRecordings).toEqual([
+ expect.objectContaining({
+ id: 'call-recording-1',
+ recordingRequestStatus: 'CANCELED',
+ externalBotId: 'recall-bot-1',
+ }),
+ ]);
+ });
+
+ it('does not reset the status of a recording whose bot is already live', async () => {
+ const client = buildFakeCoreApiClient({
+ calendarEvents: [
+ buildCalendarEvent({
+ title: 'Renamed Customer Sync',
+ }),
+ ],
+ callRecordings: [
+ {
+ id: buildCustomerSyncCallRecordingId(),
+ title: 'Customer Sync',
+ status: 'JOINING',
+ recordingRequestStatus: 'REQUESTED',
+ startedAt: FUTURE_STARTS_AT,
+ endedAt: FUTURE_ENDS_AT,
+ calendarEventId: 'calendar-event-1',
+ externalBotId: 'recall-bot-1',
+ },
+ ],
+ });
+
+ const result = await reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1'],
+ now: NOW,
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ action: 'UPDATED',
+ callRecordingId: buildCustomerSyncCallRecordingId(),
+ }),
+ ]);
+ expect(client.callRecordings).toEqual([
+ expect.objectContaining({
+ id: buildCustomerSyncCallRecordingId(),
+ title: 'Renamed Customer Sync',
+ status: 'JOINING',
+ }),
+ ]);
+ });
+
+ it('creates a single recording when duplicate synced rows share the same real meeting', async () => {
+ const client = buildFakeCoreApiClient({
+ calendarEvents: [
+ buildCalendarEvent(),
+ buildCalendarEvent({
+ id: 'calendar-event-2',
+ iCalUid: 'calendar-event-uid-from-other-channel',
+ }),
+ ],
+ });
+
+ const result = await reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1'],
+ now: NOW,
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ action: 'CREATED',
+ callRecordingId: buildCustomerSyncCallRecordingId(),
+ }),
+ ]);
+ expect(client.callRecordings).toHaveLength(1);
+ expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not create a duplicate when a non-policy-managed open recording already exists', async () => {
+ const client = buildFakeCoreApiClient({
+ calendarEvents: [buildCalendarEvent()],
+ callRecordings: [
+ {
+ id: 'call-recording-1',
+ title: 'Manual Recording',
+ status: 'SCHEDULED',
+ recordingRequestStatus: null,
+ startedAt: FUTURE_STARTS_AT,
+ endedAt: FUTURE_ENDS_AT,
+ calendarEventId: 'calendar-event-1',
+ },
+ ],
+ });
+
+ const result = await reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1'],
+ now: NOW,
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ action: 'SKIPPED',
+ callRecordingId: 'call-recording-1',
+ }),
+ ]);
+ expect(client.callRecordings).toHaveLength(1);
+ expect(client.mutations).toEqual([]);
+ expect(scheduleRecallBotMock).not.toHaveBeenCalled();
+ });
+
+ it('cancels the scheduled request when the calendar event is deleted', async () => {
+ const client = buildFakeCoreApiClient({
+ calendarEvents: [],
+ callRecordings: [
+ {
+ id: 'call-recording-1',
+ title: 'Customer Sync',
+ status: 'SCHEDULED',
+ recordingRequestStatus: 'REQUESTED',
+ startedAt: FUTURE_STARTS_AT,
+ endedAt: FUTURE_ENDS_AT,
+ calendarEventId: 'calendar-event-1',
+ externalBotId: 'recall-bot-1',
+ },
+ ],
+ });
+
+ const result = await reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: [],
+ removedOccurrences: [
+ {
+ calendarEventId: 'calendar-event-1',
+ realMeetingKey: `link:meet.example.com/customer-sync:${FUTURE_STARTS_AT}`,
+ startsAt: FUTURE_STARTS_AT,
+ },
+ ],
+ now: NOW,
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ action: 'CANCELED',
+ callRecordingId: 'call-recording-1',
+ }),
+ ]);
+ expect(client.callRecordings).toEqual([
+ expect.objectContaining({
+ id: 'call-recording-1',
+ recordingRequestStatus: 'CANCELED',
+ externalBotId: null,
+ }),
+ ]);
+ expect(cancelRecallBotMock).toHaveBeenCalledWith({
+ externalBotId: 'recall-bot-1',
+ });
+ });
+
+ it('cancels the old occurrence and creates a fresh recording when the meeting moves to a new time', async () => {
+ const NEW_STARTS_AT = '2026-01-02T13:00:00.000Z';
+ const NEW_ENDS_AT = '2026-01-02T14:00:00.000Z';
+ const client = buildFakeCoreApiClient({
+ calendarEvents: [
+ buildCalendarEvent({
+ startsAt: NEW_STARTS_AT,
+ endsAt: NEW_ENDS_AT,
+ }),
+ ],
+ callRecordings: [
+ {
+ id: buildCustomerSyncCallRecordingId(),
+ title: 'Customer Sync',
+ status: 'SCHEDULED',
+ recordingRequestStatus: 'REQUESTED',
+ startedAt: FUTURE_STARTS_AT,
+ endedAt: FUTURE_ENDS_AT,
+ calendarEventId: 'calendar-event-1',
+ externalBotId: 'recall-bot-old',
+ },
+ ],
+ });
+
+ const result = await reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1'],
+ removedOccurrences: [
+ {
+ calendarEventId: 'calendar-event-1',
+ realMeetingKey: `link:meet.example.com/customer-sync:${FUTURE_STARTS_AT}`,
+ startsAt: FUTURE_STARTS_AT,
+ },
+ ],
+ now: NOW,
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ action: 'CANCELED',
+ callRecordingId: buildCustomerSyncCallRecordingId(),
+ }),
+ expect.objectContaining({
+ action: 'CREATED',
+ callRecordingId: buildCustomerSyncCallRecordingId(NEW_STARTS_AT),
+ }),
+ ]);
+ expect(cancelRecallBotMock).toHaveBeenCalledExactlyOnceWith({
+ externalBotId: 'recall-bot-old',
+ });
+ expect(scheduleRecallBotMock).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ joinAt: NEW_STARTS_AT }),
+ );
+ expect(client.callRecordings).toEqual([
+ expect.objectContaining({
+ id: buildCustomerSyncCallRecordingId(),
+ recordingRequestStatus: 'CANCELED',
+ externalBotId: null,
+ }),
+ expect.objectContaining({
+ id: buildCustomerSyncCallRecordingId(NEW_STARTS_AT),
+ recordingRequestStatus: 'REQUESTED',
+ externalBotId: 'recall-bot-1',
+ }),
+ ]);
+ });
+
+ it('reconciles the remaining meetings when one meeting fails', async () => {
+ cancelRecallBotMock.mockRejectedValue(new Error('recall exploded'));
+
+ const client = buildFakeCoreApiClient({
+ calendarEvents: [
+ buildCalendarEvent({
+ meetingBotPreference: 'OFF',
+ }),
+ buildCalendarEvent({
+ id: 'calendar-event-2',
+ iCalUid: 'other-meeting-uid',
+ conferenceLink: {
+ primaryLinkUrl: 'https://meet.example.com/other-sync',
+ },
+ }),
+ ],
+ callRecordings: [
+ {
+ id: 'call-recording-1',
+ title: 'Customer Sync',
+ status: 'SCHEDULED',
+ recordingRequestStatus: 'REQUESTED',
+ startedAt: FUTURE_STARTS_AT,
+ endedAt: FUTURE_ENDS_AT,
+ calendarEventId: 'calendar-event-1',
+ externalBotId: 'recall-bot-1',
+ },
+ ],
+ });
+
+ const result = await reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1', 'calendar-event-2'],
+ now: NOW,
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ action: 'FAILED',
+ realMeetingKey: `link:meet.example.com/customer-sync:${FUTURE_STARTS_AT}`,
+ errorMessage: 'recall exploded',
+ }),
+ expect.objectContaining({ action: 'CREATED' }),
+ ]);
+ expect(client.callRecordings).toEqual([
+ expect.objectContaining({
+ id: 'call-recording-1',
+ recordingRequestStatus: 'CANCELED',
+ externalBotId: 'recall-bot-1',
+ }),
+ expect.objectContaining({
+ calendarEventId: 'calendar-event-2',
+ status: 'SCHEDULED',
+ }),
+ ]);
+ });
+
+ it('cancels the scheduled request when the conference link is removed', async () => {
+ const client = buildFakeCoreApiClient({
+ calendarEvents: [
+ buildCalendarEvent({
+ conferenceLink: null,
+ }),
+ ],
+ callRecordings: [
+ {
+ id: 'call-recording-1',
+ title: 'Customer Sync',
+ status: 'SCHEDULED',
+ recordingRequestStatus: 'REQUESTED',
+ startedAt: FUTURE_STARTS_AT,
+ endedAt: FUTURE_ENDS_AT,
+ calendarEventId: 'calendar-event-1',
+ externalBotId: 'recall-bot-1',
+ },
+ ],
+ });
+
+ const result = await reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1'],
+ now: NOW,
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ action: 'CANCELED',
+ callRecordingId: 'call-recording-1',
+ }),
+ ]);
+ expect(client.callRecordings).toEqual([
+ expect.objectContaining({
+ id: 'call-recording-1',
+ recordingRequestStatus: 'CANCELED',
+ externalBotId: null,
+ }),
+ ]);
+ });
+
+ it('recreates the bot when the existing Recall bot no longer exists', async () => {
+ rescheduleRecallBotMock.mockResolvedValue({
+ ok: false,
+ status: 404,
+ errorMessage: 'Recall API responded with HTTP 404',
+ });
+
+ const client = buildFakeCoreApiClient({
+ calendarEvents: [buildCalendarEvent()],
+ callRecordings: [
+ {
+ id: buildCustomerSyncCallRecordingId(),
+ title: 'Customer Sync',
+ status: 'SCHEDULED',
+ recordingRequestStatus: 'REQUESTED',
+ startedAt: FUTURE_STARTS_AT,
+ endedAt: FUTURE_ENDS_AT,
+ calendarEventId: 'calendar-event-1',
+ externalBotId: 'recall-bot-stale',
+ },
+ ],
+ });
+
+ const result = await reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1'],
+ now: NOW,
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ action: 'UPDATED',
+ callRecordingId: buildCustomerSyncCallRecordingId(),
+ }),
+ ]);
+ expect(rescheduleRecallBotMock).toHaveBeenCalledWith(
+ expect.objectContaining({ externalBotId: 'recall-bot-stale' }),
+ );
+ expect(scheduleRecallBotMock).toHaveBeenCalledWith({
+ meetingUrl: 'https://meet.example.com/customer-sync',
+ joinAt: FUTURE_STARTS_AT,
+ metadata: {
+ twentyCallRecordingId: buildCustomerSyncCallRecordingId(),
+ twentyCalendarEventId: 'calendar-event-1',
+ twentyRealMeetingKey:
+ 'link:meet.example.com/customer-sync:2026-01-01T13:00:00.000Z',
+ },
+ });
+ expect(client.callRecordings).toEqual([
+ expect.objectContaining({
+ id: buildCustomerSyncCallRecordingId(),
+ externalBotId: 'recall-bot-1',
+ }),
+ ]);
+ });
+
+ it('adopts the concurrently created recording when it loses the deterministic-id insert race', async () => {
+ class InsertRaceFakeCoreApiClient extends FakeCoreApiClient {
+ override async mutation(mutation: any): Promise {
+ if (mutation.createCallRecording !== undefined) {
+ const concurrentlyInsertedId =
+ mutation.createCallRecording.__args.data.id;
+
+ if (
+ !this.callRecordings.some(
+ (candidate) => candidate.id === concurrentlyInsertedId,
+ )
+ ) {
+ this.callRecordings.push({
+ id: concurrentlyInsertedId,
+ status: 'SCHEDULED',
+ recordingRequestStatus: 'REQUESTED',
+ calendarEventId: 'calendar-event-1',
+ externalBotId: 'sibling-bot',
+ });
+ }
+ }
+
+ return super.mutation(mutation);
+ }
+ }
+
+ const client = new InsertRaceFakeCoreApiClient({
+ calendarEvents: [buildCalendarEvent()],
+ });
+
+ const result = await reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1'],
+ now: NOW,
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ action: 'UPDATED',
+ callRecordingId: buildCustomerSyncCallRecordingId(),
+ }),
+ ]);
+ expect(client.callRecordings).toHaveLength(1);
+ expect(scheduleRecallBotMock).not.toHaveBeenCalled();
+ expect(rescheduleRecallBotMock).toHaveBeenCalledWith(
+ expect.objectContaining({ externalBotId: 'sibling-bot' }),
+ );
+ });
+
+ it('fails the meeting when the create conflicts without a readable recording', async () => {
+ class TombstoneFakeCoreApiClient extends FakeCoreApiClient {
+ override async mutation(mutation: any): Promise {
+ if (mutation.createCallRecording !== undefined) {
+ throw new Error('Duplicate id on a soft-deleted record');
+ }
+
+ return super.mutation(mutation);
+ }
+ }
+
+ const client = new TombstoneFakeCoreApiClient({
+ calendarEvents: [buildCalendarEvent()],
+ });
+
+ const result = await reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1'],
+ now: NOW,
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ action: 'FAILED',
+ errorMessage: 'Duplicate id on a soft-deleted record',
+ }),
+ ]);
+ expect(scheduleRecallBotMock).not.toHaveBeenCalled();
+ });
+
+ it('schedules exactly one bot when concurrent reconciles race for the same meeting', async () => {
+ const client = buildFakeCoreApiClient({
+ calendarEvents: [buildCalendarEvent()],
+ });
+
+ await Promise.all(
+ Array.from({ length: 4 }, () =>
+ reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1'],
+ now: NOW,
+ }),
+ ),
+ );
+
+ expect(client.callRecordings).toHaveLength(1);
+ expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1);
+ expect(client.callRecordings[0].externalBotId).toBe('recall-bot-1');
+ });
+
+ it('does not schedule a bot when the recording is canceled between decide and schedule', async () => {
+ class CancelRaceFakeCoreApiClient extends FakeCoreApiClient {
+ override async query(query: any): Promise {
+ if (query.callRecordings?.__args.filter.id?.in !== undefined) {
+ const callRecording = this.callRecordings.find((candidate) =>
+ query.callRecordings.__args.filter.id.in.includes(candidate.id),
+ );
+
+ if (callRecording !== undefined) {
+ callRecording.recordingRequestStatus = 'CANCELED';
+ }
+ }
+
+ return super.query(query);
+ }
+ }
+
+ const client = new CancelRaceFakeCoreApiClient({
+ calendarEvents: [buildCalendarEvent()],
+ });
+
+ const result = await reconcileMeetingBotForCalendarEventIds({
+ client: client as unknown as CoreApiClient,
+ calendarEventIds: ['calendar-event-1'],
+ now: NOW,
+ });
+
+ expect(result).toEqual([expect.objectContaining({ action: 'CREATED' })]);
+ expect(scheduleRecallBotMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/cancel-call-recording-request.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/cancel-call-recording-request.util.ts
new file mode 100644
index 0000000000..046fdb892e
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/cancel-call-recording-request.util.ts
@@ -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 => {
+ 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,
+ },
+ });
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/ensure-meeting-bot.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/ensure-meeting-bot.util.ts
new file mode 100644
index 0000000000..a4160f871a
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/ensure-meeting-bot.util.ts
@@ -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 => {
+ 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 },
+ });
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-meeting-bot.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-meeting-bot.util.ts
new file mode 100644
index 0000000000..6f007d74b9
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reconcile-meeting-bot.util.ts
@@ -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 => {
+ 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 => {
+ const changedCalendarEvents = await fetchCalendarEventsByIds(
+ client,
+ getUniqueSortedIds(calendarEventIds),
+ );
+ const affectedMeetingKeys = new Set();
+ const occurrenceStartsAtAnchors = new Set();
+ 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 => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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 => ({
+ // 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 => {
+ const calendarEventIdsByMeetingKey = new Map();
+
+ 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,
+});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reschedule-call-recording-bot.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reschedule-call-recording-bot.util.ts
new file mode 100644
index 0000000000..d43774fb2d
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/flows/reschedule-call-recording-bot.util.ts
@@ -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 => {
+ 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}`,
+ );
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts
new file mode 100644
index 0000000000..c525a0cbb1
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts
@@ -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);
+ });
+ });
+});
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/cancel-recall-bot.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/cancel-recall-bot.util.ts
new file mode 100644
index 0000000000..04d7bf5ee0
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/cancel-recall-bot.util.ts
@@ -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 => {
+ const configResult = getRecallApiConfig();
+
+ if (!configResult.success) {
+ return { ok: false, status: null, errorMessage: configResult.error };
+ }
+
+ const result = await recallBotApiRequest({
+ config: configResult.config,
+ path: `/bot/${externalBotId}/`,
+ method: 'DELETE',
+ allowNotFound: true,
+ });
+
+ if (!result.ok) {
+ return result;
+ }
+
+ return { ok: true };
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-bot-id.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-bot-id.util.ts
new file mode 100644
index 0000000000..165ba2d327
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-bot-id.util.ts
@@ -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);
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/get-recall-api-config.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/get-recall-api-config.util.ts
new file mode 100644
index 0000000000..a08a2f5b6b
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/get-recall-api-config.util.ts
@@ -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);
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/recall-bot-api-request.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/recall-bot-api-request.util.ts
new file mode 100644
index 0000000000..07b842ba20
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/recall-bot-api-request.util.ts
@@ -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 =
+ | {
+ 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 (
+ requestArgs: RecallBotApiRequestArgs,
+): Promise> => {
+ for (let attemptNumber = 1; ; attemptNumber++) {
+ const { result, isRetryable } =
+ await performRecallBotApiRequestAttempt(requestArgs);
+
+ if (!isRetryable || attemptNumber >= RECALL_API_MAX_ATTEMPTS) {
+ return result;
+ }
+
+ await sleep(RECALL_API_RETRY_DELAY_MS * attemptNumber);
+ }
+};
+
+const performRecallBotApiRequestAttempt = async ({
+ config,
+ path,
+ method,
+ body,
+ allowNotFound = false,
+}: RecallBotApiRequestArgs): Promise<{
+ result: RecallBotApiRequestResult;
+ 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 =>
+ 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 => {
+ 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;
+ }
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/reschedule-recall-bot.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/reschedule-recall-bot.util.ts
new file mode 100644
index 0000000000..d66a92aa9a
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/reschedule-recall-bot.util.ts
@@ -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 => {
+ const configResult = getRecallApiConfig();
+
+ if (!configResult.success) {
+ return { ok: false, status: null, errorMessage: configResult.error };
+ }
+
+ const result = await recallBotApiRequest({
+ 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,
+ };
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/schedule-recall-bot.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/schedule-recall-bot.util.ts
new file mode 100644
index 0000000000..8e5f071e72
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/recall-api/schedule-recall-bot.util.ts
@@ -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 => {
+ const configResult = getRecallApiConfig();
+
+ if (!configResult.success) {
+ return { ok: false, status: null, errorMessage: configResult.error };
+ }
+
+ const result = await recallBotApiRequest({
+ 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,
+ };
+};
diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/reconcile-meeting-bot-calendar-event.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/reconcile-meeting-bot-calendar-event.ts
new file mode 100644
index 0000000000..e023330383
--- /dev/null
+++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/reconcile-meeting-bot-calendar-event.ts
@@ -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
+>;
+
+type CalendarEventReconciliationPayload = {
+ calendarEventIds: string[];
+ removedOccurrences: RemovedMeetingBotOccurrence[];
+};
+
+const handler = async (
+ event: CalendarEventDatabaseEvent,
+): Promise