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