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 Review in cubic --- .../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 => { + const [objectName, action] = event.name.split('.'); + + if (objectName !== CALENDAR_EVENT_OBJECT_NAME) { + return { skipped: true, reason: 'not a calendar event' }; + } + + const reconciliationPayload = buildCalendarEventReconciliationPayload({ + event, + action, + }); + + if ( + reconciliationPayload.calendarEventIds.length === 0 && + reconciliationPayload.removedOccurrences.length === 0 + ) { + return { skipped: true, reason: 'no relevant calendar event change' }; + } + + const client = new CoreApiClient(); + const reconciliationResults = await reconcileMeetingBotForCalendarEventIds({ + client, + calendarEventIds: reconciliationPayload.calendarEventIds, + removedOccurrences: reconciliationPayload.removedOccurrences, + }); + + return { + reconciled: true, + calendarEventIds: reconciliationPayload.calendarEventIds, + removedOccurrenceCount: reconciliationPayload.removedOccurrences.length, + reconciliationResults, + }; +}; + +const buildCalendarEventReconciliationPayload = ({ + event, + action, +}: { + event: CalendarEventDatabaseEvent; + action: string | undefined; +}): CalendarEventReconciliationPayload => { + if (action === 'created') { + return { + calendarEventIds: getUniqueSortedIds([ + event.recordId, + event.properties.after?.id, + ]), + removedOccurrences: [], + }; + } + + if (action === 'updated') { + const updatedFields = event.properties.updatedFields ?? []; + + if (!hasRelevantFieldChange(updatedFields)) { + return { calendarEventIds: [], removedOccurrences: [] }; + } + + const removedOccurrence = hasKeyFieldChange(updatedFields) + ? buildRemovedOccurrence(event.properties.before) + : undefined; + + return { + calendarEventIds: getUniqueSortedIds([ + event.recordId, + event.properties.after?.id, + ]), + removedOccurrences: isUndefined(removedOccurrence) + ? [] + : [removedOccurrence], + }; + } + + if (action === 'deleted' || action === 'destroyed') { + const removedOccurrence = buildRemovedOccurrence(event.properties.before); + + return { + calendarEventIds: [], + removedOccurrences: isUndefined(removedOccurrence) + ? [] + : [removedOccurrence], + }; + } + + return { calendarEventIds: [], removedOccurrences: [] }; +}; + +const hasRelevantFieldChange = (updatedFields: string[]): boolean => + updatedFields.some((updatedField) => + MEETING_BOT_RELEVANT_CALENDAR_EVENT_FIELDS.includes(updatedField), + ); + +const hasKeyFieldChange = (updatedFields: string[]): boolean => + updatedFields.some((updatedField) => + MEETING_BOT_KEY_CALENDAR_EVENT_FIELDS.includes(updatedField), + ); + +const buildRemovedOccurrence = ( + calendarEvent: CalendarEventForDatabaseEvent | undefined, +): RemovedMeetingBotOccurrence | undefined => { + if (isUndefined(calendarEvent)) { + return undefined; + } + + return { + calendarEventId: calendarEvent.id, + realMeetingKey: computeRealMeetingKey({ + calendarEventId: calendarEvent.id, + conferenceLinkUrl: calendarEvent.conferenceLink?.primaryLinkUrl, + iCalUid: calendarEvent.iCalUid ?? undefined, + startsAt: calendarEvent.startsAt ?? undefined, + }), + startsAt: calendarEvent.startsAt ?? undefined, + }; +}; + +export default defineLogicFunction({ + universalIdentifier: + CALENDAR_EVENT_RECONCILIATION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'reconcile-meeting-bot-calendar-event', + description: + 'Reconciles app-managed Recall bot recording requests when calendar events change.', + timeoutSeconds: 60, + handler, + databaseEventTriggerSettings: { + eventName: `${CALENDAR_EVENT_OBJECT_NAME}.*`, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/calendar-event-record.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/calendar-event-record.type.ts new file mode 100644 index 0000000000..1871fee9a2 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/calendar-event-record.type.ts @@ -0,0 +1,5 @@ +import { type MeetingBotPolicyCalendarEventInput } from 'src/logic-functions/types/meeting-bot-policy-calendar-event-input.type'; + +export type CalendarEventRecord = MeetingBotPolicyCalendarEventInput & { + title: string | undefined; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/call-recording-record.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/call-recording-record.type.ts new file mode 100644 index 0000000000..04d031de93 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/call-recording-record.type.ts @@ -0,0 +1,14 @@ +import { type CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status'; + +// Domain read shape: absence is always undefined; null lives only on wire types. +export type CallRecordingRecord = { + id: string; + title?: string; + status?: string; + recordingRequestStatus?: CallRecordingRequestStatus; + startedAt?: string; + endedAt?: string; + calendarEventId?: string; + externalBotId?: string; + externalRecordingId?: string; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-calendar-event-input.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-calendar-event-input.type.ts new file mode 100644 index 0000000000..0a87c8114a --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-calendar-event-input.type.ts @@ -0,0 +1,10 @@ +// Domain read shape: wire composites are flattened and absence is undefined. +export type MeetingBotPolicyCalendarEventInput = { + id: string; + isCanceled: boolean; + startsAt: string | undefined; + endsAt: string | undefined; + iCalUid: string | undefined; + conferenceLinkUrl: string | undefined; + meetingBotPreference: string | undefined; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-input.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-input.type.ts new file mode 100644 index 0000000000..5a90fd60f4 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-input.type.ts @@ -0,0 +1,9 @@ +import { type MeetingBotPreference } from 'src/constants/meeting-bot-preference'; + +export type MeetingBotPolicyInput = { + meetingBotPreference: MeetingBotPreference | undefined; + isCanceled: boolean; + startsAt: string | undefined; + endsAt: string | undefined; + conferenceLinkUrl: string | undefined; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-not-required-reason.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-not-required-reason.type.ts new file mode 100644 index 0000000000..748a5a5dc0 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-not-required-reason.type.ts @@ -0,0 +1,5 @@ +export type MeetingBotPolicyNotRequiredReason = + | 'EVENT_CANCELED' + | 'PREFERENCE_OFF' + | 'MISSING_CONFERENCE_LINK' + | 'EVENT_NOT_UPCOMING'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-required-reason.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-required-reason.type.ts new file mode 100644 index 0000000000..ef80f8eaeb --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-required-reason.type.ts @@ -0,0 +1 @@ +export type MeetingBotPolicyRequiredReason = 'RECORDING_ENABLED'; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result-for-calendar-event.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result-for-calendar-event.type.ts new file mode 100644 index 0000000000..a8c5b0762e --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result-for-calendar-event.type.ts @@ -0,0 +1,8 @@ +import { type MeetingBotPreference } from 'src/constants/meeting-bot-preference'; +import { type MeetingBotPolicyResult } from 'src/logic-functions/types/meeting-bot-policy-result.type'; + +export type MeetingBotPolicyResultForCalendarEvent = MeetingBotPolicyResult & { + calendarEventId: string; + meetingBotPreference: MeetingBotPreference | undefined; + realMeetingKey: string; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result-for-meeting.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result-for-meeting.type.ts new file mode 100644 index 0000000000..7091c0addb --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result-for-meeting.type.ts @@ -0,0 +1,6 @@ +export type MeetingBotPolicyResultForMeeting = { + realMeetingKey: string; + shouldRequestBot: boolean; + calendarEventIds: string[]; + requestingCalendarEventIds: string[]; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result.type.ts new file mode 100644 index 0000000000..2b420ff94f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result.type.ts @@ -0,0 +1,12 @@ +import { type MeetingBotPolicyNotRequiredReason } from 'src/logic-functions/types/meeting-bot-policy-not-required-reason.type'; +import { type MeetingBotPolicyRequiredReason } from 'src/logic-functions/types/meeting-bot-policy-required-reason.type'; + +export type MeetingBotPolicyResult = + | { + shouldRequestBot: true; + reason: MeetingBotPolicyRequiredReason; + } + | { + shouldRequestBot: false; + reason: MeetingBotPolicyNotRequiredReason; + }; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-reconciliation-result.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-reconciliation-result.type.ts new file mode 100644 index 0000000000..ae463e04b5 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-bot-reconciliation-result.type.ts @@ -0,0 +1,16 @@ +export type MeetingBotReconciliationResult = + | { + action: 'CREATED' | 'UPDATED' | 'CANCELED'; + realMeetingKey: string; + callRecordingId: string; + } + | { + action: 'SKIPPED'; + realMeetingKey: string; + callRecordingId: string | null; + } + | { + action: 'FAILED'; + realMeetingKey: string; + errorMessage: string; + }; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-recording.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-recording.type.ts new file mode 100644 index 0000000000..4c672082f4 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/meeting-recording.type.ts @@ -0,0 +1,7 @@ +import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type'; +import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type'; + +export type MeetingRecording = { + callRecording: CallRecordingRecord; + calendarEvent: CalendarEventRecord; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/recall-bot-metadata.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/recall-bot-metadata.type.ts new file mode 100644 index 0000000000..2f62cc5c00 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/recall-bot-metadata.type.ts @@ -0,0 +1,7 @@ +export type RecallBotMetadata = { + twentyCallRecordingId: string; + twentyCalendarEventId: string; + twentyRealMeetingKey: string; + // Workspace dispatch key for a future host-level webhook ingress. + twentyApplicationId?: string; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/recall-bot-operation-result.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/recall-bot-operation-result.type.ts new file mode 100644 index 0000000000..3279288413 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/recall-bot-operation-result.type.ts @@ -0,0 +1,19 @@ +export type RecallBotOperationFailure = { + ok: false; + // null = no HTTP response (network failure), distinct from any status code. + status: number | null; + errorMessage: string; +}; + +export type RecallBotScheduleResult = + | { + ok: true; + externalBotId: string; + } + | RecallBotOperationFailure; + +export type RecallBotRemovalResult = + | { + ok: true; + } + | RecallBotOperationFailure; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/removed-meeting-bot-occurrence.type.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/removed-meeting-bot-occurrence.type.ts new file mode 100644 index 0000000000..506a3490d5 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/types/removed-meeting-bot-occurrence.type.ts @@ -0,0 +1,6 @@ +// An occurrence whose event was deleted/moved; key + start re-checks siblings. +export type RemovedMeetingBotOccurrence = { + calendarEventId: string; + realMeetingKey: string; + startsAt: string | undefined; +}; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-application-variable-value.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-application-variable-value.util.ts new file mode 100644 index 0000000000..7903574f4d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-application-variable-value.util.ts @@ -0,0 +1,3 @@ +// Application variables are injected into process.env on every execution. +export const getApplicationVariableValue = (key: string): string | undefined => + process.env[key]; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-string.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-string.util.ts new file mode 100644 index 0000000000..f222af01b1 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-string.util.ts @@ -0,0 +1,4 @@ +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +export const getString = (value: unknown): string | undefined => + isNonEmptyString(value) ? value : undefined; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-unique-sorted-ids.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-unique-sorted-ids.util.ts new file mode 100644 index 0000000000..a296142c0d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/get-unique-sorted-ids.util.ts @@ -0,0 +1,8 @@ +import { isString } from '@sniptt/guards'; + +export const getUniqueSortedIds = ( + ids: Array, +): string[] => + [...new Set(ids.filter(isString))].sort((firstId, secondId) => + firstId.localeCompare(secondId), + ); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/is-non-empty-string.util.ts b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/is-non-empty-string.util.ts new file mode 100644 index 0000000000..175943f383 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/src/logic-functions/utils/is-non-empty-string.util.ts @@ -0,0 +1,5 @@ +import { isString } from '@sniptt/guards'; + +// Trimming variant of @sniptt/guards isNonEmptyString, for normalizing at read boundaries. +export const isNonEmptyString = (value: unknown): value is string => + isString(value) && value.trim() !== ''; diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/vitest.unit.config.ts b/packages/twenty-apps/internal/twenty-meeting-bot/vitest.unit.config.ts new file mode 100644 index 0000000000..e0d140015b --- /dev/null +++ b/packages/twenty-apps/internal/twenty-meeting-bot/vitest.unit.config.ts @@ -0,0 +1,14 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [ + tsconfigPaths({ + projects: ['tsconfig.spec.json'], + ignoreConfigErrors: true, + }), + ], + test: { + include: ['src/**/*.test.ts'], + }, +}); diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/yarn.lock b/packages/twenty-apps/internal/twenty-meeting-bot/yarn.lock index 52e7322532..8f82bf1011 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/yarn.lock +++ b/packages/twenty-apps/internal/twenty-meeting-bot/yarn.lock @@ -2625,6 +2625,7 @@ __metadata: version: 0.0.0-use.local resolution: "twenty-meeting-bot@workspace:." dependencies: + "@sniptt/guards": "npm:^0.2.0" "@types/node": "npm:^24.7.2" "@types/react": "npm:^19.0.0" oxlint: "npm:^0.16.0"