snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 3s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
*.d.ts

View File

@@ -0,0 +1 @@
24.5.0

View File

@@ -0,0 +1,38 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"ignoreRestSiblings": true
}
],
"typescript/no-explicit-any": "off"
},
"overrides": [
{
"files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["twenty-shared", "twenty-shared/*"],
"message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal."
}
]
}
]
}
}
]
}

View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View File

@@ -0,0 +1,70 @@
## Base documentation
- Getting started:
- https://docs.twenty.com/developers/extend/apps/getting-started/quick-start.md
- https://docs.twenty.com/developers/extend/apps/getting-started/concepts.md
- https://docs.twenty.com/developers/extend/apps/getting-started/project-structure.md
- https://docs.twenty.com/developers/extend/apps/getting-started/local-server.md
- https://docs.twenty.com/developers/extend/apps/getting-started/scaffolding.md
- https://docs.twenty.com/developers/extend/apps/getting-started/troubleshooting.md
- Config:
- https://docs.twenty.com/developers/extend/apps/config/overview.md
- https://docs.twenty.com/developers/extend/apps/config/application.md
- https://docs.twenty.com/developers/extend/apps/config/roles.md
- https://docs.twenty.com/developers/extend/apps/config/install-hooks.md
- https://docs.twenty.com/developers/extend/apps/config/public-assets.md
- Data:
- https://docs.twenty.com/developers/extend/apps/data/overview.md
- https://docs.twenty.com/developers/extend/apps/data/objects.md
- https://docs.twenty.com/developers/extend/apps/data/extending-objects.md
- https://docs.twenty.com/developers/extend/apps/data/relations.md
- Logic:
- https://docs.twenty.com/developers/extend/apps/logic/overview.md
- https://docs.twenty.com/developers/extend/apps/logic/logic-functions.md
- https://docs.twenty.com/developers/extend/apps/logic/skills-and-agents.md
- https://docs.twenty.com/developers/extend/apps/logic/connections.md
- Layout:
- https://docs.twenty.com/developers/extend/apps/layout/overview.md
- https://docs.twenty.com/developers/extend/apps/layout/views.md
- https://docs.twenty.com/developers/extend/apps/layout/navigation-menu-items.md
- https://docs.twenty.com/developers/extend/apps/layout/page-layouts.md
- https://docs.twenty.com/developers/extend/apps/layout/front-components.md
- https://docs.twenty.com/developers/extend/apps/layout/command-menu-items.md
- Operations:
- https://docs.twenty.com/developers/extend/apps/operations/overview.md
- https://docs.twenty.com/developers/extend/apps/operations/cli.md
- https://docs.twenty.com/developers/extend/apps/operations/testing.md
- https://docs.twenty.com/developers/extend/apps/operations/publishing.md
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
## Best practice
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
| Entity type | Command | Generated file |
| -------------------- | ---------------------------------------- | ------------------------------------- |
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
| Page layout tab | `yarn twenty dev:add pageLayoutTab` | `src/page-layout-tabs/<name>.ts` |
| Command menu item | `yarn twenty dev:add commandMenuItem` | `src/command-menu-items/<name>.ts` |
| View field | `yarn twenty dev:add viewField` | `src/view-fields/<name>.ts` |
| Connection provider | `yarn twenty dev:add connectionProvider` | `src/connection-providers/<name>.ts` |
This helps automatically generate required IDs etc.

View File

@@ -0,0 +1,9 @@
# Changelog
All notable changes to this application are documented in this file.
## 0.1.0
- Independent desktop agenda, Recall upload provisioning, signed webhook processing, audio/transcript import, recovery, and AI summaries.
- Separate application identity, ownership fields, agent, configuration, and credentials.
- One-time migration of completed legacy desktop recording history.

View File

@@ -0,0 +1,70 @@
## Base documentation
- Getting started:
- https://docs.twenty.com/developers/extend/apps/getting-started/quick-start.md
- https://docs.twenty.com/developers/extend/apps/getting-started/concepts.md
- https://docs.twenty.com/developers/extend/apps/getting-started/project-structure.md
- https://docs.twenty.com/developers/extend/apps/getting-started/local-server.md
- https://docs.twenty.com/developers/extend/apps/getting-started/scaffolding.md
- https://docs.twenty.com/developers/extend/apps/getting-started/troubleshooting.md
- Config:
- https://docs.twenty.com/developers/extend/apps/config/overview.md
- https://docs.twenty.com/developers/extend/apps/config/application.md
- https://docs.twenty.com/developers/extend/apps/config/roles.md
- https://docs.twenty.com/developers/extend/apps/config/install-hooks.md
- https://docs.twenty.com/developers/extend/apps/config/public-assets.md
- Data:
- https://docs.twenty.com/developers/extend/apps/data/overview.md
- https://docs.twenty.com/developers/extend/apps/data/objects.md
- https://docs.twenty.com/developers/extend/apps/data/extending-objects.md
- https://docs.twenty.com/developers/extend/apps/data/relations.md
- Logic:
- https://docs.twenty.com/developers/extend/apps/logic/overview.md
- https://docs.twenty.com/developers/extend/apps/logic/logic-functions.md
- https://docs.twenty.com/developers/extend/apps/logic/skills-and-agents.md
- https://docs.twenty.com/developers/extend/apps/logic/connections.md
- Layout:
- https://docs.twenty.com/developers/extend/apps/layout/overview.md
- https://docs.twenty.com/developers/extend/apps/layout/views.md
- https://docs.twenty.com/developers/extend/apps/layout/navigation-menu-items.md
- https://docs.twenty.com/developers/extend/apps/layout/page-layouts.md
- https://docs.twenty.com/developers/extend/apps/layout/front-components.md
- https://docs.twenty.com/developers/extend/apps/layout/command-menu-items.md
- Operations:
- https://docs.twenty.com/developers/extend/apps/operations/overview.md
- https://docs.twenty.com/developers/extend/apps/operations/cli.md
- https://docs.twenty.com/developers/extend/apps/operations/testing.md
- https://docs.twenty.com/developers/extend/apps/operations/publishing.md
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
## Best practice
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
| Entity type | Command | Generated file |
| -------------------- | ---------------------------------------- | ------------------------------------- |
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
| Page layout tab | `yarn twenty dev:add pageLayoutTab` | `src/page-layout-tabs/<name>.ts` |
| Command menu item | `yarn twenty dev:add commandMenuItem` | `src/command-menu-items/<name>.ts` |
| View field | `yarn twenty dev:add viewField` | `src/view-fields/<name>.ts` |
| Connection provider | `yarn twenty dev:add connectionProvider` | `src/connection-providers/<name>.ts` |
This helps automatically generate required IDs etc.

View File

@@ -0,0 +1,37 @@
# Desktop Recorder integration
The independent Twenty backend for the [Twenty desktop app](../../../twenty-companion/README.md). It provides personal calendars, audio upload provisioning, transcripts, summaries, and recording recovery. Call Recorder is not required.
## Responsibilities
```text
Twenty desktop → Twenty OAuth + /companion/desktop
→ Recall Desktop SDK (temporary upload token)
Recall webhook → Desktop Recorder importer → standard CallRecording
→ Desktop Recorder summarizer → Twenty AI
Desktop Recorder maintenance → recover missed upload completion events
```
The integration has its own application registration, default role, custom fields, logic functions, summarizer agent, application settings, Recall credentials, and webhook endpoint. It shares Twenty's standard calendar, people, and Call Recording objects with the rest of the CRM.
Desktop requests run as the signed-in user. Processing jobs only accept recordings with a valid `companionSession`; webhook events additionally require Recall metadata `twentyRecordingSource: companion`. An installed calendar bot can coexist: Desktop Recorder checks standard scheduled/active recording state to avoid recording that event twice. It does not read Call Recorder's custom fields or invoke its functions.
The desktop uses the existing CLI OAuth client and PKCE flow from `twenty remote:add`. After sign-in it checks whether the Desktop Recorder backend is available and offers installation if needed. The app's Settings tab provides the macOS download and configuration without a dedicated page in Twenty core.
## Recording processing and credits
The backend provisions audio-only Recall uploads, imports audio and transcripts into Twenty, and runs its own summarizer when `COMPANION_SUMMARY_ENABLED` is enabled. Summaries use Twenty AI. Recording charges use the existing `chargeCredits` API from `twenty-sdk/billing`, with the same duration-based rate as Call Recorder. Recall processing uses the configured Recall account.
A conditional status update ensures only the worker completing a recording submits its charge. Completed recordings are not charged again by webhook or recovery replays. As with the existing Call Recorder path, an ambiguous charge failure is not automatically retried. This integration adds no billing endpoint, pricing policy, receipt table, or usage-accounting migration.
Transcript updates enqueue summary generation with a stable job ID per recording. The existing queue deduplicates jobs, and the existing KV store caches results before writing the recording summary so retries reuse them. A running marker preserves uncertain paid attempts after interruption. Maintenance saves cached summaries without generating new ones or replacing an existing summary. Persistent cache-write failures are reported after bounded retries.
The integration uses existing CLI OAuth, queue job IDs, and KV get/set operations. No changes to core billing, queue, or application-state APIs are required.
## Development
Use Node 24 and Yarn 4. Run `yarn install`, `yarn typecheck`, `yarn test:unit`, and `yarn lint` from this directory. Installation, Recall configuration, and migration instructions are in [SETUP.md](SETUP.md).
Integration tests use `yarn test` with explicit `TWENTY_API_URL` and `TWENTY_API_KEY` environment variables. They read an already installed application and never install, uninstall, or reset it.
No deployment credentials or provider keys belong in this package.

View File

@@ -0,0 +1,16 @@
# Workspace setup
1. Install dependencies with Node 24 and Yarn 4: `yarn install`.
2. Publish `@twentyhq/companion-app`, let the server sync its catalog, and install Desktop Recorder from Settings → Applications. Desktop sign-in uses the existing CLI OAuth client; no dedicated OAuth registration is needed.
3. As a server administrator, configure this application's registration variables: `RECALL_API_KEY`, `RECALL_WEBHOOK_SECRET`, and `RECALL_REGION` (default `eu-central-1`). The registration must belong to the workspace hosting its server webhook resolver.
4. Create a separate Recall webhook endpoint pointing to `<public-server-url>/webhooks/server/2f955a7f-9d03-44c6-bb12-fbc8f6e33b07`. Subscribe to `sdk_upload.recording_started`, `sdk_upload.recording_ended`, `sdk_upload.complete`, `sdk_upload.failed`, `recording.done`, `recording.failed`, `transcript.done`, and `transcript.failed`. Use its applicable Recall signing secret. Keep existing Call Recorder webhook endpoints separate.
5. Open the installed app's Settings tab. Configure its published macOS download URL, AI summaries, transcript provider, and optional additional summary instructions. AI must be configured in Twenty for summaries; Gladia requires its provider key in Recall.
6. Download Twenty from that Settings tab and sign in to the same workspace. It discovers the function domain and calls authenticated `POST /companion/desktop`. Sign-in requires user permissions for calendars and recordings; an application-only API key cannot use that endpoint.
Application universal identifier: `8bdaaa9f-dc53-4247-a89b-aa386c9b3244`.
## Local verification
Use a local Twenty instance supporting CLI OAuth and queue job IDs. Verify installation, per-user sign-in, settings, and recording recovery before publishing a release.
A temporary public tunnel is required for Recall callbacks to localhost. When its URL changes, update the dedicated Companion Recall endpoint. Keep the tunnel and Docker running while processing recordings, and retire the local endpoint when testing ends.

View File

@@ -0,0 +1,40 @@
{
"name": "@twentyhq/companion-app",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.13.0",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"typecheck": "tsgo --noEmit -p tsconfig.spec.json",
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run --config vitest.unit.config.ts"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"twenty-client-sdk": "2.39.0",
"twenty-sdk": "2.39.0",
"twenty-ui": "1.0.0-alpha.1",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.0.0"
},
"dependencies": {
"@sniptt/guards": "^0.2.0"
}
}

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="96px" height="96px" viewBox="0 0 96 96" version="1.1">
<defs>
<clipPath id="clip1">
<path d="M 11.292969 0 L 84.707031 0 C 90.945312 0 96 5.054688 96 11.292969 L 96 84.707031 C 96 90.945312 90.945312 96 84.707031 96 L 11.292969 96 C 5.054688 96 0 90.945312 0 84.707031 L 0 11.292969 C 0 5.054688 5.054688 0 11.292969 0 Z M 11.292969 0 "/>
</clipPath>
<clipPath id="clip2">
<path d="M 19 26 L 48 26 L 48 45 L 19 45 Z M 19 26 "/>
</clipPath>
<clipPath id="clip3">
<path d="M 11.292969 0 L 84.707031 0 C 90.945312 0 96 5.054688 96 11.292969 L 96 84.707031 C 96 90.945312 90.945312 96 84.707031 96 L 11.292969 96 C 5.054688 96 0 90.945312 0 84.707031 L 0 11.292969 C 0 5.054688 5.054688 0 11.292969 0 Z M 11.292969 0 "/>
</clipPath>
<clipPath id="clip4">
<path d="M 19 26 L 77 26 L 77 70 L 19 70 Z M 19 26 "/>
</clipPath>
<clipPath id="clip5">
<path d="M 11.292969 0 L 84.707031 0 C 90.945312 0 96 5.054688 96 11.292969 L 96 84.707031 C 96 90.945312 90.945312 96 84.707031 96 L 11.292969 96 C 5.054688 96 0 90.945312 0 84.707031 L 0 11.292969 C 0 5.054688 5.054688 0 11.292969 0 Z M 11.292969 0 "/>
</clipPath>
</defs>
<g id="surface1">
<g clip-path="url(#clip1)" clip-rule="nonzero">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,0%);fill-opacity:1;" d="M 96 0 L 0 0 L 0 96 L 96 96 Z M 19.25 35.746094 C 19.25 30.5 23.507812 26.246094 28.75 26.246094 L 47.039062 26.246094 C 47.308594 26.246094 47.554688 26.40625 47.667969 26.652344 C 47.78125 26.902344 47.730469 27.191406 47.546875 27.394531 L 43.539062 31.75 C 42.839844 32.507812 41.859375 32.945312 40.828125 32.945312 L 28.800781 32.945312 C 27.226562 32.945312 25.949219 34.222656 25.949219 35.796875 L 25.949219 42.980469 C 25.949219 43.90625 25.199219 44.652344 24.273438 44.652344 L 20.929688 44.652344 C 20.003906 44.652344 19.257812 43.90625 19.257812 42.980469 L 19.257812 35.746094 Z M 76.152344 60.253906 C 76.152344 65.5 71.894531 69.753906 66.648438 69.753906 L 58.878906 69.753906 C 53.632812 69.753906 49.375 65.5 49.375 60.253906 L 49.375 46.652344 C 49.375 45.726562 49.722656 44.835938 50.351562 44.152344 L 54.882812 39.234375 C 55.074219 39.027344 55.371094 38.957031 55.636719 39.054688 C 55.898438 39.164062 56.074219 39.410156 56.074219 39.691406 L 56.074219 60.210938 C 56.074219 61.785156 57.351562 63.0625 58.925781 63.0625 L 66.605469 63.0625 C 68.179688 63.0625 69.457031 61.785156 69.457031 60.210938 L 69.457031 35.796875 C 69.457031 34.222656 68.179688 32.945312 66.605469 32.945312 L 57.675781 32.945312 C 56.652344 32.945312 55.679688 33.375 54.980469 34.121094 L 28.347656 63.0625 L 44.351562 63.0625 C 45.273438 63.0625 46.023438 63.8125 46.023438 64.738281 L 46.023438 68.082031 C 46.023438 69.007812 45.273438 69.753906 44.351562 69.753906 L 22.785156 69.753906 C 20.832031 69.753906 19.242188 68.167969 19.242188 66.210938 L 19.242188 64.441406 C 19.242188 63.550781 19.574219 62.695312 20.179688 62.039062 L 50.039062 29.605469 C 52.015625 27.457031 54.789062 26.246094 57.707031 26.246094 L 66.640625 26.246094 C 71.886719 26.246094 76.144531 30.5 76.144531 35.746094 L 76.144531 60.253906 Z M 76.152344 60.253906 "/>
</g>
<g clip-path="url(#clip2)" clip-rule="nonzero">
<g clip-path="url(#clip3)" clip-rule="nonzero">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 19.25 35.746094 C 19.25 30.5 23.507812 26.246094 28.75 26.246094 L 47.039062 26.246094 C 47.308594 26.246094 47.554688 26.40625 47.667969 26.652344 C 47.78125 26.902344 47.730469 27.191406 47.546875 27.394531 L 43.539062 31.75 C 42.839844 32.507812 41.859375 32.945312 40.828125 32.945312 L 28.800781 32.945312 C 27.226562 32.945312 25.949219 34.222656 25.949219 35.796875 L 25.949219 42.980469 C 25.949219 43.90625 25.199219 44.652344 24.273438 44.652344 L 20.929688 44.652344 C 20.003906 44.652344 19.257812 43.90625 19.257812 42.980469 L 19.257812 35.746094 Z M 19.25 35.746094 "/>
</g>
</g>
<g clip-path="url(#clip4)" clip-rule="nonzero">
<g clip-path="url(#clip5)" clip-rule="nonzero">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 76.152344 60.253906 C 76.152344 65.5 71.894531 69.753906 66.648438 69.753906 L 58.878906 69.753906 C 53.632812 69.753906 49.375 65.5 49.375 60.253906 L 49.375 46.652344 C 49.375 45.726562 49.722656 44.835938 50.351562 44.152344 L 54.882812 39.234375 C 55.074219 39.027344 55.371094 38.957031 55.636719 39.054688 C 55.898438 39.164062 56.074219 39.410156 56.074219 39.691406 L 56.074219 60.210938 C 56.074219 61.785156 57.351562 63.0625 58.925781 63.0625 L 66.605469 63.0625 C 68.179688 63.0625 69.457031 61.785156 69.457031 60.210938 L 69.457031 35.796875 C 69.457031 34.222656 68.179688 32.945312 66.605469 32.945312 L 57.675781 32.945312 C 56.652344 32.945312 55.679688 33.375 54.980469 34.121094 L 28.347656 63.0625 L 44.351562 63.0625 C 45.273438 63.0625 46.023438 63.8125 46.023438 64.738281 L 46.023438 68.082031 C 46.023438 69.007812 45.273438 69.753906 44.351562 69.753906 L 22.785156 69.753906 C 20.832031 69.753906 19.242188 68.167969 19.242188 66.210938 L 19.242188 64.441406 C 19.242188 63.550781 19.574219 62.695312 20.179688 62.039062 L 50.039062 29.605469 C 52.015625 27.457031 54.789062 26.246094 57.707031 26.246094 L 66.640625 26.246094 C 71.886719 26.246094 76.144531 30.5 76.144531 35.746094 L 76.144531 60.253906 Z M 76.152344 60.253906 "/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -0,0 +1,12 @@
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 { describe, expect, it } from 'vitest';
describe('application identifiers', () => {
it('should expose the application metadata constants', () => {
expect(APP_DISPLAY_NAME).toBeTruthy();
expect(typeof APP_DESCRIPTION).toBe('string');
expect(APPLICATION_UNIVERSAL_IDENTIFIER).toBeTruthy();
});
});

View File

@@ -0,0 +1,46 @@
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';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const app = result.findManyApplications.find(
(application: { universalIdentifier: string }) =>
application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
});
});
describe('CoreApiClient', () => {
it('can read its own ownership and processing fields on standard recordings', async () => {
const client = new CoreApiClient();
const result = await client.query({
callRecordings: {
__args: { first: 1 },
edges: {
node: {
id: true,
companionSession: true,
companionFailureReason: true,
companionImportClaimedAt: true,
},
},
},
});
expect(result.callRecordings?.edges).toBeDefined();
});
});

View File

@@ -0,0 +1,54 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.test.json');
const getRemoteConfiguration = (): { apiUrl: string; apiKey: string } => {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error('TWENTY_API_URL and TWENTY_API_KEY must be set.');
}
return { apiUrl, apiKey };
};
export const setup = async () => {
const { apiUrl, apiKey } = getRemoteConfiguration();
const response = await fetch(`${apiUrl}/healthz`);
if (!response.ok) {
throw new Error(`Twenty returned HTTP ${response.status}`);
}
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(
CONFIG_PATH,
JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
),
);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({ appPath: APP_PATH });
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
};

View File

@@ -0,0 +1,17 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.test.json');
export const teardown = async () => {
try {
await appUninstall({ appPath: APP_PATH });
} finally {
fs.rmSync(CONFIG_PATH, { force: true });
}
};

View File

@@ -0,0 +1,14 @@
import { defineAgent } from 'twenty-sdk/define';
import { CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER } from 'src/constants/CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER';
import { DEFAULT_CALL_RECORDING_SUMMARY_PROMPT } from 'src/constants/DEFAULT_CALL_RECORDING_SUMMARY_PROMPT';
export default defineAgent({
universalIdentifier: CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER,
name: 'companion-summarizer',
label: 'Desktop Recorder Summarizer',
icon: 'IconFileText',
description:
'Summarizes a meeting transcript into structured Markdown notes stored on the Call Recording.',
prompt: DEFAULT_CALL_RECORDING_SUMMARY_PROMPT,
responseFormat: { type: 'text' },
});

View File

@@ -0,0 +1,86 @@
import { defineApplication, FieldType } 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 { COMPANION_SUMMARY_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/COMPANION_SUMMARY_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER';
import { COMPANION_TRANSCRIPT_PROVIDER_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/COMPANION_TRANSCRIPT_PROVIDER_APP_VARIABLE_UNIVERSAL_IDENTIFIER';
import { COMPANION_ADDITIONAL_SUMMARY_PROMPT_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/COMPANION_ADDITIONAL_SUMMARY_PROMPT_APP_VARIABLE_UNIVERSAL_IDENTIFIER';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
logo: 'public/logo.svg',
category: 'Productivity',
author: 'Twenty',
applicationVariables: {
DESKTOP_DOWNLOAD_URL: {
universalIdentifier: 'e436eaee-c56e-4da4-834e-fd30a1ded1df',
label: 'macOS download URL',
description:
'HTTPS link to the published Twenty installer for macOS (Apple silicon). Shown to workspace members in desktop setup. Leave empty until a release is available.',
type: FieldType.TEXT,
isSecret: false,
value: '',
},
COMPANION_SUMMARY_ENABLED: {
universalIdentifier:
COMPANION_SUMMARY_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'AI summaries',
description:
'Generate summaries of desktop recordings using Twenty AI credits when billing is enabled.',
type: FieldType.BOOLEAN,
isSecret: false,
value: true,
},
COMPANION_TRANSCRIPT_PROVIDER: {
universalIdentifier:
COMPANION_TRANSCRIPT_PROVIDER_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Transcript provider',
description:
'Speech recognition for desktop recordings. Gladia requires a provider key configured in Recall.',
type: FieldType.SELECT,
isSecret: false,
options: [
{ label: 'Recall.ai', value: 'recallai_async' },
{ label: 'Gladia', value: 'gladia_v2_async' },
],
value: 'recallai_async',
},
COMPANION_ADDITIONAL_SUMMARY_PROMPT: {
universalIdentifier:
COMPANION_ADDITIONAL_SUMMARY_PROMPT_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Additional summary instructions',
description:
'Optional instructions for the language, tone, or focus of meeting summaries.',
type: FieldType.TEXT,
isSecret: false,
},
},
serverVariables: {
RECALL_API_KEY: {
description: 'Recall API key used to provision desktop uploads.',
type: FieldType.TEXT,
isSecret: true,
isRequired: true,
},
RECALL_REGION: {
description:
'Recall region, for example eu-central-1. Defaults to eu-central-1.',
type: FieldType.TEXT,
isSecret: false,
},
RECALL_WEBHOOK_SECRET: {
description:
'Signing secret for the separate Desktop Recorder Recall webhook endpoint.',
type: FieldType.TEXT,
isSecret: true,
isRequired: true,
},
COMPANION_RECORDING_RETENTION_HOURS: {
description: 'Recall media retention in hours. Defaults to 166.',
type: FieldType.NUMBER,
isSecret: false,
},
},
});

View File

@@ -0,0 +1,217 @@
import { useEffect, useState } from 'react';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { useFrontComponentId, useTranslate } from 'twenty-sdk/front-component';
import { Button } from 'twenty-ui/input';
import { H2Title } from 'twenty-ui/typography';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type ApplicationVariable = {
key: string;
value: string;
description?: string | null;
};
export const DesktopSettings = () => {
const { t } = useTranslate();
const frontComponentId = useFrontComponentId();
const [applicationId, setApplicationId] = useState<string>();
const [variables, setVariables] = useState<ApplicationVariable[]>([]);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [error, setError] = useState(false);
const [saving, setSaving] = useState(false);
const labels: Record<string, string> = {
DESKTOP_DOWNLOAD_URL: t('macOS download URL'),
COMPANION_SUMMARY_ENABLED: t('AI summaries'),
COMPANION_TRANSCRIPT_PROVIDER: t('Transcript provider'),
COMPANION_ADDITIONAL_SUMMARY_PROMPT: t('Additional summary instructions'),
};
const inputStyle = {
padding: themeCssVariables.spacing[2],
color: themeCssVariables.font.color.primary,
background: themeCssVariables.background.secondary,
border: `1px solid ${themeCssVariables.border.color.medium}`,
borderRadius: themeCssVariables.border.radius.sm,
font: 'inherit',
};
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const client = new MetadataApiClient();
const component = await client.query({
frontComponent: {
__args: { id: frontComponentId },
applicationId: true,
},
});
const id = component.frontComponent?.applicationId;
if (!id) throw new Error('Settings application not found');
const result = await client.query({
findOneApplication: {
__args: { id },
applicationVariables: {
key: true,
value: true,
description: true,
isDeprecated: true,
isSecret: true,
},
},
});
if (cancelled) return;
setVariables(
(result.findOneApplication?.applicationVariables ?? []).filter(
(variable) => !variable.isDeprecated && !variable.isSecret,
),
);
setApplicationId(id);
} catch {
if (!cancelled) setError(true);
}
};
void load();
return () => {
cancelled = true;
};
}, [frontComponentId]);
const save = async () => {
if (!applicationId) return;
setSaving(true);
setError(false);
try {
const client = new MetadataApiClient();
for (const [key, value] of Object.entries(drafts)) {
await client.mutation({
updateOneApplicationVariable: {
__args: { applicationId, key, value },
},
});
}
setVariables(
variables.map((variable) => ({
...variable,
value: drafts[variable.key] ?? variable.value,
})),
);
setDrafts({});
} catch {
setError(true);
} finally {
setSaving(false);
}
};
let downloadUrl: string | undefined;
try {
const url = new URL(
variables.find(({ key }) => key === 'DESKTOP_DOWNLOAD_URL')?.value ?? '',
);
if (url.protocol === 'https:' && !url.username && !url.password)
downloadUrl = url.href;
} catch {
// Unpublished releases have no download URL.
}
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: themeCssVariables.spacing[4],
}}
>
<H2Title
title={t('Twenty for macOS')}
description={t(
'Record meetings on your computer and find transcripts and summaries in this workspace.',
)}
/>
{downloadUrl ? (
<a
href={downloadUrl}
target="_blank"
rel="noopener noreferrer"
style={{
...inputStyle,
alignSelf: 'flex-start',
textDecoration: 'none',
}}
>
{t('Download Twenty for macOS')}
</a>
) : (
<p>
{t('Add the published macOS release URL below to enable downloads.')}
</p>
)}
<p>
{t(
'Requires macOS 14.2 or later on Apple silicon. Install Twenty in Applications, then connect this workspace through your browser. Each person signs in with their own Twenty account.',
)}
</p>
{error && (
<p role="alert">
{t(
'Could not load or save settings. Your edits are kept; try again or reopen this page.',
)}
</p>
)}
{!applicationId && !error && <p>{t('Loading settings…')}</p>}
{variables.map(({ key, value, description }) => (
<label
key={key}
style={{
display: 'flex',
flexDirection: 'column',
gap: themeCssVariables.spacing[1],
}}
>
<span>{labels[key] ?? key}</span>
<span style={{ color: themeCssVariables.font.color.secondary }}>
{description}
</span>
{key === 'COMPANION_SUMMARY_ENABLED' ? (
<input
type="checkbox"
checked={(drafts[key] ?? value) === 'true'}
disabled={saving}
onChange={(event) =>
setDrafts({ ...drafts, [key]: String(event.target.checked) })
}
/>
) : key === 'COMPANION_TRANSCRIPT_PROVIDER' ? (
<select
value={drafts[key] ?? value}
disabled={saving}
style={inputStyle}
onChange={(event) =>
setDrafts({ ...drafts, [key]: event.target.value })
}
>
<option value="recallai_async">Recall.ai</option>
<option value="gladia_v2_async">Gladia</option>
</select>
) : (
<input
value={drafts[key] ?? value}
disabled={saving}
onChange={(event) =>
setDrafts({ ...drafts, [key]: event.target.value })
}
style={inputStyle}
/>
)}
</label>
))}
{applicationId && (
<Button
title={t('Save settings')}
disabled={saving || Object.keys(drafts).length === 0}
onClick={() => void save()}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,2 @@
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'8bdaaa9f-dc53-4247-a89b-aa386c9b3244';

View File

@@ -0,0 +1,2 @@
export const APP_DESCRIPTION =
'Record meetings from the Twenty desktop app, with transcripts and summaries. No meeting bot.';

View File

@@ -0,0 +1 @@
export const APP_DISPLAY_NAME = 'Desktop Recorder';

View File

@@ -0,0 +1,2 @@
export const CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER =
'2eafc2d0-8fec-430c-a939-65ca5fbc0f08';

View File

@@ -0,0 +1,2 @@
export const CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER =
'ebc46dc0-bbb4-45c3-b820-59e56ac899e7';

View File

@@ -0,0 +1 @@
export const CALL_RECORDING_SUMMARY_UNAVAILABLE_PREFIX = 'SUMMARY_UNAVAILABLE:';

View File

@@ -0,0 +1,2 @@
export const CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER =
'bb9523d3-457e-4f4b-8c79-27a77afb87da';

View File

@@ -0,0 +1,2 @@
export const COMPANION_ADDITIONAL_SUMMARY_PROMPT_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'61051403-6bd8-442f-ada0-d1c573e27eac';

View File

@@ -0,0 +1,2 @@
export const COMPANION_SUMMARY_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'ba3e76cf-2220-47d8-8b10-1c697ffaae1a';

View File

@@ -0,0 +1,2 @@
export const COMPANION_TRANSCRIPT_PROVIDER_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'51741213-33bb-44ae-9d96-3614f452affa';

View File

@@ -0,0 +1,58 @@
import { CALL_RECORDING_SUMMARY_UNAVAILABLE_PREFIX } from 'src/constants/CALL_RECORDING_SUMMARY_UNAVAILABLE_PREFIX';
export const DEFAULT_CALL_RECORDING_SUMMARY_PROMPT = [
'You are an AI meeting note-taker for a CRM. You receive the diarized,',
'time-stamped transcript of a recorded call and produce clean, factual',
'meeting notes for the rep who attended.',
'',
'Output GitHub-flavored Markdown only — no preamble, no closing remarks, no',
'surrounding code fences. Emit exactly these sections, in this order, each',
'with its heading exactly as written. Omit a section only when its rule says',
'to.',
'',
'## Gist',
'A single sentence (no bullet) capturing the essence of the meeting.',
'',
'## Overview',
'One paragraph of 2-4 sentences covering what the meeting was about and how',
'it ended. No bullets.',
'',
'## Action Items',
'Concrete follow-ups, grouped by the person responsible. Write each owner as',
'a bold line, then their tasks as a bulleted list beneath it. Use the',
'speaker name from the transcript, or "Unassigned" when no owner is clear.',
'Example:',
'**Alex**',
'- Send the pricing deck',
'- Follow up on Friday',
'If the call produced no follow-ups, write the single line: None.',
'',
'## Notes',
'The discussion split into short topical sections in chronological order.',
'Begin each section with a bold title and the [mm:ss] timestamp it started',
'at, then summarize it as concise bullets. Example:',
'**Introductions** — 0:00',
'- ...',
'**Pricing discussion** — 4:12',
'- ...',
'',
'## Keywords',
'A single comma-separated line of the most important terms, names, and',
'topics from the call.',
'',
'Grounding rules: use only information present in the transcript. Never',
'invent names, numbers, commitments, outcomes, or times — reuse the [mm:ss]',
'timestamps exactly as they appear in the transcript.',
'',
'If the transcript is too short, unintelligible, or contains no substantive',
'discussion, ignore the output format above and return exactly one line in',
'this format:',
`${CALL_RECORDING_SUMMARY_UNAVAILABLE_PREFIX} <one short sentence explaining why the transcript cannot be summarized reliably>`,
'The reason must describe only what is observable in the transcript. Be',
'specific and do not speculate. Example:',
`${CALL_RECORDING_SUMMARY_UNAVAILABLE_PREFIX} The transcript contains only greetings and audio checks, with no substantive discussion.`,
'',
'The message may include additional instructions from the workspace admin.',
'Apply them on top of these rules without breaking the output format or the',
'grounding rules above.',
].join('\n');

View File

@@ -0,0 +1,2 @@
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'd6e7b7da-f386-45aa-b0b5-57aeeb274860';

View File

@@ -0,0 +1,2 @@
export const IMPORT_CALL_RECORDING_ARTIFACTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'51fd47c8-6a11-444a-84e5-37339aaabb20';

View File

@@ -0,0 +1,2 @@
export const PROCESS_RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'b7e80bff-0cf2-45a3-b50c-3c6575cba95d';

View File

@@ -0,0 +1,2 @@
export const RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'2f955a7f-9d03-44c6-bb12-fbc8f6e33b07';

View File

@@ -0,0 +1,2 @@
export const SUMMARIZE_CALL_RECORDING_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'cc895d00-ae04-4fdb-b9a5-2b62bb607de5';

View File

@@ -0,0 +1,81 @@
import {
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
SystemPermissionFlag,
defineApplicationRole,
} from 'twenty-sdk/define';
import { APP_DISPLAY_NAME } from 'src/constants/APP_DISPLAY_NAME';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/DEFAULT_ROLE_UNIVERSAL_IDENTIFIER';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default role`,
description:
'Reads personal meeting context and processes desktop recordings, audio, transcripts, and summaries.',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarChannelEventAssociation
.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEventParticipant
.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember
.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [],
permissionFlagUniversalIdentifiers: [
SystemPermissionFlag.APPLICATIONS,
SystemPermissionFlag.UPLOAD_FILE,
SystemPermissionFlag.AI,
],
});

View File

@@ -0,0 +1,18 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '9c62c98a-143e-4dcf-a1d7-034fd2c5ccf9',
name: 'companionFailureReason',
label: 'Desktop recording failure reason',
type: FieldType.TEXT,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
description: 'Failure details for Companion capture or artifact processing.',
icon: 'IconAlertCircle',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,18 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'f99e9220-9618-4347-8ea9-d335f8c1305e',
name: 'companionImportClaimedAt',
label: 'Desktop recording import claimed at',
type: FieldType.DATE_TIME,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
description: 'Lease preventing concurrent Companion artifact imports.',
icon: 'IconLock',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,19 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'd2629373-7c86-4996-b09c-145fa531bc55',
name: 'companionSession',
label: 'Desktop recording session',
type: FieldType.RAW_JSON,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
description:
'Companion session ownership and Recall desktop upload reference.',
icon: 'IconDeviceDesktop',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,9 @@
import { defineSettingsFrontComponent } from 'twenty-sdk/define';
import { DesktopSettings } from 'src/components/DesktopSettings';
export default defineSettingsFrontComponent({
universalIdentifier: 'c7f85fb5-a198-4e80-9ee1-53d80cc68761',
name: 'desktop-settings',
description: 'Download Twenty for macOS and configure desktop recording.',
component: DesktopSettings,
});

View File

@@ -0,0 +1 @@
export const AUDIO_FILE_TOO_LARGE_FAILURE_REASON = 'audio_file_too_large';

View File

@@ -0,0 +1 @@
export const CALL_RECORDING_MICRO_CREDITS_PER_HOUR = 1_000_000;

View File

@@ -0,0 +1,2 @@
export const COMPANION_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME =
'COMPANION_ADDITIONAL_SUMMARY_PROMPT';

View File

@@ -0,0 +1 @@
export const COMPANION_MAX_MEDIA_FILE_SIZE_BYTES = 500 * 1024 * 1024;

View File

@@ -0,0 +1,2 @@
export const COMPANION_RECORDING_RETENTION_HOURS_ENV_VAR_NAME =
'COMPANION_RECORDING_RETENTION_HOURS';

View File

@@ -0,0 +1,2 @@
export const COMPANION_SUMMARY_ENABLED_ENV_VAR_NAME =
'COMPANION_SUMMARY_ENABLED';

View File

@@ -0,0 +1,2 @@
export const COMPANION_TRANSCRIPT_PROVIDER_ENV_VAR_NAME =
'COMPANION_TRANSCRIPT_PROVIDER';

View File

@@ -0,0 +1,5 @@
// Mirrors the core select options; guarded by the schema integration test.
export enum CallRecordingRequestStatus {
REQUESTED = 'REQUESTED',
CANCELED = 'CANCELED',
}

View File

@@ -0,0 +1,10 @@
// 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 = 'FAILED',
NOT_RECORDED = 'NOT_RECORDED',
}

View File

@@ -0,0 +1,2 @@
// Twenty stores imported recording artifacts, so Recall.ai media is temporary. Keep the default below Recall.ai's 168-hour free storage window.
export const DEFAULT_COMPANION_RECORDING_RETENTION_HOURS = 166;

View File

@@ -0,0 +1 @@
export const DEFAULT_COMPANION_SUMMARY_ENABLED = true;

View File

@@ -0,0 +1,4 @@
import { type RecallAsyncTranscriptProvider } from 'src/logic-functions/types/RecallAsyncTranscriptProvider';
export const DEFAULT_COMPANION_TRANSCRIPT_PROVIDER: RecallAsyncTranscriptProvider =
'recallai_async';

View File

@@ -0,0 +1 @@
export const DEFAULT_RECALL_REGION = 'eu-central-1';

View File

@@ -0,0 +1,2 @@
// Redelivery is safe only because every enqueued handler is idempotent.
export const ENQUEUED_JOB_RETRY_LIMIT = 2;

View File

@@ -0,0 +1,2 @@
// Server-enforced cap on the enqueueJobs mutation.
export const MAX_PAYLOADS_PER_ENQUEUE_JOBS_CALL = 200;

View File

@@ -0,0 +1 @@
export const MILLISECONDS_PER_MINUTE = 60_000;

View File

@@ -0,0 +1,8 @@
import { CallRecordingStatus } from 'src/logic-functions/constants/CallRecordingStatus';
export const NON_TERMINAL_CALL_RECORDING_STATUSES = [
CallRecordingStatus.SCHEDULED,
CallRecordingStatus.JOINING,
CallRecordingStatus.RECORDING,
CallRecordingStatus.PROCESSING,
] satisfies CallRecordingStatus[];

View File

@@ -0,0 +1,8 @@
// Sub codes meaning nothing was captured: bot never admitted, meeting not started, nobody joined.
export const NOT_RECORDED_RECALL_SUB_CODES: readonly string[] = [
'meeting_not_started',
'timeout_exceeded_noone_joined',
'timeout_exceeded_waiting_room',
'call_ended_by_platform_waiting_room_timeout',
'bot_kicked_from_waiting_room',
];

View File

@@ -0,0 +1 @@
export const RECALL_API_ADHOC_POOL_RETRY_DELAY_MS = 10_000;

View File

@@ -0,0 +1 @@
export const RECALL_API_KEY_ENV_VAR_NAME = 'RECALL_API_KEY';

View File

@@ -0,0 +1 @@
export const RECALL_API_MAX_ATTEMPTS = 3;

View File

@@ -0,0 +1 @@
export const RECALL_API_MAX_IN_PROCESS_RETRY_WAIT_MS = 10_000;

View File

@@ -0,0 +1 @@
export const RECALL_API_MAX_RETRY_AFTER_MS = 60_000;

View File

@@ -0,0 +1 @@
export const RECALL_API_RETRY_DELAY_MS = 500;

View File

@@ -0,0 +1,4 @@
export const RECALL_ASYNC_TRANSCRIPT_PROVIDERS = {
recallai_async: { language_code: 'auto' },
gladia_v2_async: { language_config: { code_switching: true } },
} as const;

View File

@@ -0,0 +1 @@
export const RECALL_REGION_ENV_VAR_NAME = 'RECALL_REGION';

View File

@@ -0,0 +1 @@
export const RECALL_WEBHOOK_SECRET_ENV_VAR_NAME = 'RECALL_WEBHOOK_SECRET';

View File

@@ -0,0 +1,3 @@
// Mirrors twenty-shared; calendar restrictions write it over title/description.
export const RESTRICTED_FIELD_PLACEHOLDER =
'FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED';

View File

@@ -0,0 +1,12 @@
// Meeting platforms a Recall bot can join. Anything else (Around, Whereby,
// ro.am, Daily, plain dial-in, etc.) cannot be recorded and is ignored.
// See https://docs.recall.ai/docs/meeting-platforms
// Ordered by provider precedence: the first matching provider wins (e.g. a
// pasted Zoom invitation beats an auto-added Meet link).
export const SUPPORTED_MEETING_PLATFORM_URL_PATTERNS: RegExp[] = [
/https:\/\/(?:[\w-]+\.)*(?:zoom\.us|zoomgov\.com)\/(?:j|my|s|w|wc\/join)\/[^\s"'<>\\]+/i,
/https:\/\/meet\.google\.com\/[^\s"'<>\\]+/i,
/https:\/\/teams\.(?:microsoft|live)\.com\/(?:l\/meetup-join|meet)\/[^\s"'<>\\]+/i,
/https:\/\/(?:[\w-]+\.)*webex\.com\/(?:meet\/|join\/|[\w-]+\/j\.php\?)[^\s"'<>\\]+/i,
/https:\/\/(?:[\w-]+\.)*(?:gotomeeting\.com\/join|gotomeet\.me)\/[^\s"'<>\\]+/i,
];

View File

@@ -0,0 +1 @@
export const TWENTY_PAGE_SIZE = 100;

View File

@@ -0,0 +1,6 @@
import { CallRecordingStatus } from 'src/logic-functions/constants/CallRecordingStatus';
export const UNAVAILABLE_CALL_RECORDING_STATUSES: readonly string[] = [
CallRecordingStatus.FAILED,
CallRecordingStatus.NOT_RECORDED,
];

View File

@@ -0,0 +1 @@
export const VIDEO_FILE_TOO_LARGE_FAILURE_REASON = 'video_file_too_large';

View File

@@ -0,0 +1,34 @@
import { COMPANION_RECORDING_RETENTION_HOURS_ENV_VAR_NAME } from 'src/logic-functions/constants/COMPANION_RECORDING_RETENTION_HOURS_ENV_VAR_NAME';
import { DEFAULT_COMPANION_RECORDING_RETENTION_HOURS } from 'src/logic-functions/constants/DEFAULT_COMPANION_RECORDING_RETENTION_HOURS';
import { getApplicationVariableValue } from 'src/logic-functions/utils/getApplicationVariableValue';
import { isNonEmptyString } from 'src/logic-functions/utils/isNonEmptyString';
type RecallBotRecordingConfig = {
video_mixed_mp4: Record<string, never>;
audio_mixed_mp3: Record<string, never>;
retention: { type: 'timed'; hours: number };
};
// Recall only produces artifacts declared at bot creation; both gate COMPLETED.
export const getRecallBotRecordingConfig = (): RecallBotRecordingConfig => {
const configuredRecordingRetentionHours = getApplicationVariableValue(
COMPANION_RECORDING_RETENTION_HOURS_ENV_VAR_NAME,
);
const recordingRetentionHours = isNonEmptyString(
configuredRecordingRetentionHours,
)
? Number(configuredRecordingRetentionHours.trim())
: NaN;
const resolvedRecordingRetentionHours =
Number.isInteger(recordingRetentionHours) && recordingRetentionHours > 0
? recordingRetentionHours
: DEFAULT_COMPANION_RECORDING_RETENTION_HOURS;
return {
video_mixed_mp4: {},
audio_mixed_mp3: {},
retention: { type: 'timed', hours: resolvedRecordingRetentionHours },
};
};

View File

@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { claimCallRecordingArtifactsImport } from 'src/logic-functions/data/utils/claimCallRecordingArtifactsImport';
import { releaseCallRecordingArtifactsImportClaim } from 'src/logic-functions/data/utils/releaseCallRecordingArtifactsImportClaim';
const mutationMock = vi.fn();
const client = { mutation: mutationMock };
describe('claimCallRecordingArtifactsImport', () => {
beforeEach(() => {
mutationMock.mockReset();
});
it('claims when no fresh lease is held and stamps the lease timestamp', async () => {
mutationMock.mockResolvedValue({
updateCallRecordings: [{ id: 'call-recording-1' }],
});
const claimed = await claimCallRecordingArtifactsImport(client, {
callRecordingId: 'call-recording-1',
now: new Date('2026-01-01T14:06:00.000Z'),
});
expect(claimed).toBe(true);
expect(mutationMock).toHaveBeenCalledWith({
updateCallRecordings: {
__args: {
filter: {
id: { eq: 'call-recording-1' },
or: [
{ companionImportClaimedAt: { is: 'NULL' } },
{ companionImportClaimedAt: { lte: '2026-01-01T13:56:00.000Z' } },
],
},
data: { companionImportClaimedAt: '2026-01-01T14:06:00.000Z' },
},
id: true,
},
});
});
it('does not claim when a fresh lease already blocks the update', async () => {
mutationMock.mockResolvedValue({ updateCallRecordings: [] });
const claimed = await claimCallRecordingArtifactsImport(client, {
callRecordingId: 'call-recording-1',
now: new Date('2026-01-01T14:06:00.000Z'),
});
expect(claimed).toBe(false);
});
it('reclaims a lease older than the TTL', async () => {
// Emulate the DB-side filter so the lte staleBefore branch and TTL math are exercised.
const storedClaimedAt = '2026-01-01T13:45:00.000Z'; // 21 minutes before now
mutationMock.mockImplementation(async (mutation: any) => {
const { filter } = mutation.updateCallRecordings.__args;
const staleBefore = filter.or[1].companionImportClaimedAt.lte;
const matches = storedClaimedAt <= staleBefore;
return { updateCallRecordings: matches ? [{ id: filter.id.eq }] : [] };
});
const claimed = await claimCallRecordingArtifactsImport(client, {
callRecordingId: 'call-recording-1',
now: new Date('2026-01-01T14:06:00.000Z'),
});
expect(claimed).toBe(true);
expect(
mutationMock.mock.calls[0][0].updateCallRecordings.__args.data,
).toEqual({ companionImportClaimedAt: '2026-01-01T14:06:00.000Z' });
});
it('does not reclaim a lease still within the TTL', async () => {
const storedClaimedAt = '2026-01-01T14:02:00.000Z'; // 4 minutes before now
mutationMock.mockImplementation(async (mutation: any) => {
const { filter } = mutation.updateCallRecordings.__args;
const staleBefore = filter.or[1].companionImportClaimedAt.lte;
const matches = storedClaimedAt <= staleBefore;
return { updateCallRecordings: matches ? [{ id: filter.id.eq }] : [] };
});
const claimed = await claimCallRecordingArtifactsImport(client, {
callRecordingId: 'call-recording-1',
now: new Date('2026-01-01T14:06:00.000Z'),
});
expect(claimed).toBe(false);
});
it('releases only the lease acquired by this worker', async () => {
mutationMock.mockResolvedValue({
updateCallRecording: { id: 'call-recording-1' },
});
await releaseCallRecordingArtifactsImportClaim(client, {
callRecordingId: 'call-recording-1',
claimedAt: new Date('2026-01-01T14:00:00Z'),
});
expect(mutationMock).toHaveBeenCalledWith({
updateCallRecordings: {
__args: {
filter: {
id: { eq: 'call-recording-1' },
companionImportClaimedAt: { eq: '2026-01-01T14:00:00.000Z' },
},
data: { companionImportClaimedAt: null },
},
id: true,
},
});
});
});

View File

@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest';
import { completeCallRecordingImport } from 'src/logic-functions/data/utils/completeCallRecordingImport';
describe('completeCallRecordingImport', () => {
it('guards the flip with non-terminal statuses and returns true when the row is claimed', async () => {
const mutation = vi.fn().mockResolvedValue({
updateCallRecordings: [{ id: 'call-recording-1' }],
});
const claimed = await completeCallRecordingImport(
{ mutation },
{
id: 'call-recording-1',
},
);
expect(claimed).toBe(true);
expect(mutation).toHaveBeenCalledTimes(1);
expect(mutation).toHaveBeenCalledWith({
updateCallRecordings: {
__args: {
filter: {
id: { eq: 'call-recording-1' },
status: { in: ['SCHEDULED', 'JOINING', 'RECORDING', 'PROCESSING'] },
},
data: { status: 'COMPLETED' },
},
id: true,
},
});
});
it('returns false when the row was already COMPLETED', async () => {
const mutation = vi.fn().mockResolvedValue({ updateCallRecordings: [] });
const claimed = await completeCallRecordingImport(
{ mutation },
{
id: 'call-recording-1',
},
);
expect(claimed).toBe(false);
});
it('returns false when the API omits the result list', async () => {
const mutation = vi.fn().mockResolvedValue({});
const claimed = await completeCallRecordingImport(
{ mutation },
{
id: 'call-recording-1',
},
);
expect(claimed).toBe(false);
});
});

View File

@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { IMPORT_CALL_RECORDING_ARTIFACTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/IMPORT_CALL_RECORDING_ARTIFACTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER';
import { ENQUEUED_JOB_RETRY_LIMIT } from 'src/logic-functions/constants/ENQUEUED_JOB_RETRY_LIMIT';
import { enqueueCallRecordingArtifactsImport } from 'src/logic-functions/data/utils/enqueueCallRecordingArtifactsImport';
const enqueueJobsMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-sdk/logic-function', () => ({
enqueueJobs: enqueueJobsMock,
}));
describe('enqueueCallRecordingArtifactsImport', () => {
beforeEach(() => {
vi.clearAllMocks();
enqueueJobsMock.mockResolvedValue({ enqueued: true, enqueuedJobsCount: 1 });
});
it('enqueues one import job with a fresh request timestamp', async () => {
await enqueueCallRecordingArtifactsImport({
callRecordingId: 'call-recording-1',
});
expect(enqueueJobsMock).toHaveBeenCalledExactlyOnceWith({
logicFunctionUniversalIdentifier:
IMPORT_CALL_RECORDING_ARTIFACTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
payloads: [
{
callRecordingId: 'call-recording-1',
requestedAt: expect.any(String),
},
],
retryLimit: ENQUEUED_JOB_RETRY_LIMIT,
});
});
it('propagates enqueue failures to the caller', async () => {
enqueueJobsMock.mockRejectedValue(new Error('Network failed'));
await expect(
enqueueCallRecordingArtifactsImport({
callRecordingId: 'call-recording-1',
}),
).rejects.toThrow('Network failed');
});
});

View File

@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ENQUEUED_JOB_RETRY_LIMIT } from 'src/logic-functions/constants/ENQUEUED_JOB_RETRY_LIMIT';
import { MAX_PAYLOADS_PER_ENQUEUE_JOBS_CALL } from 'src/logic-functions/constants/MAX_PAYLOADS_PER_ENQUEUE_JOBS_CALL';
import { enqueueLogicFunctionJobs } from 'src/logic-functions/data/utils/enqueueLogicFunctionJobs';
const enqueueJobsMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-sdk/logic-function', () => ({
enqueueJobs: enqueueJobsMock,
}));
const TARGET_UNIVERSAL_IDENTIFIER = '5a2f4d2a-1a1e-4c66-8a54-1f0a2b3c4d5e';
describe('enqueueLogicFunctionJobs', () => {
beforeEach(() => {
vi.clearAllMocks();
enqueueJobsMock.mockImplementation(async ({ payloads }) => ({
enqueued: true,
enqueuedJobsCount: payloads.length,
}));
});
it('enqueues a small payload list in a single call', async () => {
await enqueueLogicFunctionJobs({
logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER,
payloads: [{ batchIndex: 0 }, { batchIndex: 1 }],
});
expect(enqueueJobsMock).toHaveBeenCalledExactlyOnceWith({
logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER,
payloads: [{ batchIndex: 0 }, { batchIndex: 1 }],
retryLimit: ENQUEUED_JOB_RETRY_LIMIT,
});
});
it('splits payload lists above the mutation cap into multiple calls', async () => {
const payloads = Array.from(
{ length: MAX_PAYLOADS_PER_ENQUEUE_JOBS_CALL + 1 },
(_, payloadIndex) => ({ payloadIndex }),
);
await enqueueLogicFunctionJobs({
logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER,
payloads,
});
expect(enqueueJobsMock).toHaveBeenCalledTimes(2);
expect(enqueueJobsMock.mock.calls[0][0].payloads).toHaveLength(
MAX_PAYLOADS_PER_ENQUEUE_JOBS_CALL,
);
expect(enqueueJobsMock.mock.calls[1][0].payloads).toEqual([
{ payloadIndex: MAX_PAYLOADS_PER_ENQUEUE_JOBS_CALL },
]);
});
it('stops at the first failed chunk', async () => {
const payloads = Array.from(
{ length: MAX_PAYLOADS_PER_ENQUEUE_JOBS_CALL * 2 },
(_, payloadIndex) => ({ payloadIndex }),
);
enqueueJobsMock
.mockResolvedValueOnce({ enqueued: true })
.mockRejectedValueOnce(new Error('Network failed'));
await expect(
enqueueLogicFunctionJobs({
logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER,
payloads,
}),
).rejects.toThrow('Network failed');
expect(enqueueJobsMock).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from 'vitest';
import { findCallRecordingForSummary } from 'src/logic-functions/data/utils/findCallRecordingForSummary';
describe('findCallRecordingForSummary', () => {
it('treats blank summary markdown as missing', async () => {
const query = vi.fn().mockResolvedValue({
callRecordings: {
edges: [
{
node: {
id: 'call-recording-1',
title: 'Weekly sync',
transcript: [],
summary: { markdown: '' },
createdBy: { source: 'APPLICATION', name: 'Desktop Recorder' },
},
},
],
},
});
const callRecording = await findCallRecordingForSummary(
{ query },
{ id: 'call-recording-1' },
);
expect(callRecording?.summaryMarkdown).toBeUndefined();
});
});

View File

@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it } from 'vitest';
import { getCurrentWorkspaceId } from 'src/logic-functions/data/utils/getCurrentWorkspaceId';
const APP_ACCESS_TOKEN_ENV_VAR_NAME = 'TWENTY_APP_ACCESS_TOKEN';
const ORIGINAL_APP_ACCESS_TOKEN = process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME];
const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
const restoreOriginalAppAccessToken = () => {
if (ORIGINAL_APP_ACCESS_TOKEN === undefined) {
delete process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME];
return;
}
process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME] = ORIGINAL_APP_ACCESS_TOKEN;
};
const buildAccessToken = (payload: Record<string, unknown>): string =>
[
Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'),
Buffer.from(JSON.stringify(payload)).toString('base64url'),
'signature',
].join('.');
describe('getCurrentWorkspaceId', () => {
afterEach(() => {
restoreOriginalAppAccessToken();
});
it('reads the workspace id from the app access token payload', () => {
process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME] = buildAccessToken({
workspaceId: WORKSPACE_ID,
});
expect(getCurrentWorkspaceId()).toBe(WORKSPACE_ID);
});
});

View File

@@ -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/utils/stripRestrictedFieldValue';
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();
});
});

View File

@@ -0,0 +1,41 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
// Crash safety net: a lease older than this is reclaimable so a worker that died
// mid-import never blocks the recording forever. Normal runs release explicitly.
const ARTIFACTS_IMPORT_CLAIM_TTL_MS = 10 * 60 * 1000;
// Atomic per-recording lease. The conditional update matches only when no fresh
// lease is held, so exactly one of several concurrent webhook retries claims the
// import and performs the provider-facing work.
export const claimCallRecordingArtifactsImport = async (
client: Pick<CoreApiClient, 'mutation'>,
{
callRecordingId,
now,
}: {
callRecordingId: string;
now: Date;
},
): Promise<boolean> => {
const staleBefore = new Date(
now.getTime() - ARTIFACTS_IMPORT_CLAIM_TTL_MS,
).toISOString();
const result = await client.mutation({
updateCallRecordings: {
__args: {
filter: {
id: { eq: callRecordingId },
or: [
{ companionImportClaimedAt: { is: 'NULL' } },
{ companionImportClaimedAt: { lte: staleBefore } },
],
},
data: { companionImportClaimedAt: now.toISOString() },
},
id: true,
},
});
return (result.updateCallRecordings ?? []).length > 0;
};

View File

@@ -0,0 +1,25 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { CallRecordingStatus } from 'src/logic-functions/constants/CallRecordingStatus';
import { NON_TERMINAL_CALL_RECORDING_STATUSES } from 'src/logic-functions/constants/NON_TERMINAL_CALL_RECORDING_STATUSES';
export const completeCallRecordingImport = async (
client: Pick<CoreApiClient, 'mutation'>,
{ id }: { id: string },
): Promise<boolean> => {
const result = await client.mutation({
updateCallRecordings: {
__args: {
filter: {
id: { eq: id },
status: { in: NON_TERMINAL_CALL_RECORDING_STATUSES },
},
data: {
status: CallRecordingStatus.COMPLETED,
},
},
id: true,
},
});
return (result.updateCallRecordings ?? []).length > 0;
};

View File

@@ -0,0 +1,14 @@
import { IMPORT_CALL_RECORDING_ARTIFACTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/IMPORT_CALL_RECORDING_ARTIFACTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER';
import { enqueueLogicFunctionJobs } from 'src/logic-functions/data/utils/enqueueLogicFunctionJobs';
export const enqueueCallRecordingArtifactsImport = async ({
callRecordingId,
}: {
callRecordingId: string;
}): Promise<void> => {
await enqueueLogicFunctionJobs({
logicFunctionUniversalIdentifier:
IMPORT_CALL_RECORDING_ARTIFACTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
payloads: [{ callRecordingId, requestedAt: new Date().toISOString() }],
});
};

View File

@@ -0,0 +1,27 @@
import { enqueueJobs } from 'twenty-sdk/logic-function';
import { ENQUEUED_JOB_RETRY_LIMIT } from 'src/logic-functions/constants/ENQUEUED_JOB_RETRY_LIMIT';
import { MAX_PAYLOADS_PER_ENQUEUE_JOBS_CALL } from 'src/logic-functions/constants/MAX_PAYLOADS_PER_ENQUEUE_JOBS_CALL';
import { getBatches } from 'src/logic-functions/utils/getBatches';
const MAX_ENQUEUE_PAYLOADS = 1_000;
export const enqueueLogicFunctionJobs = async ({
logicFunctionUniversalIdentifier,
payloads,
}: {
logicFunctionUniversalIdentifier: string;
payloads: Record<string, unknown>[];
}): Promise<void> => {
if (payloads.length > MAX_ENQUEUE_PAYLOADS)
throw new Error('Cannot enqueue more than 1000 jobs at once.');
for (const payloadChunk of getBatches(
payloads,
MAX_PAYLOADS_PER_ENQUEUE_JOBS_CALL,
)) {
await enqueueJobs({
logicFunctionUniversalIdentifier,
payloads: payloadChunk,
retryLimit: ENQUEUED_JOB_RETRY_LIMIT,
});
}
};

View File

@@ -0,0 +1,49 @@
import { isString, isUndefined } from '@sniptt/guards';
import { type ConnectionPage } from 'src/logic-functions/types/ConnectionPage';
const MAX_PAGINATION_PAGES = 20;
export const fetchAllNodes = async <TNode>(
fetchPage: (
afterCursor: string | undefined,
) => Promise<ConnectionPage<TNode> | undefined>,
shouldStartPageRequest: () => boolean = () => true,
): Promise<TNode[]> => {
const nodes: TNode[] = [];
const cursors = new Set<string>();
let pages = 0;
let hasNextPage = true;
let afterCursor: string | undefined;
while (hasNextPage && shouldStartPageRequest()) {
if (++pages > MAX_PAGINATION_PAGES)
throw new Error('Pagination exceeded 20 pages; narrow the query.');
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',
);
}
if (hasNextPage && isString(endCursor)) {
if (!endCursor || cursors.has(endCursor))
throw new Error('Pagination cursor did not advance');
cursors.add(endCursor);
}
afterCursor = isString(endCursor) ? endCursor : undefined;
}
return nodes;
};

View File

@@ -0,0 +1,78 @@
import { isUndefined } from '@sniptt/guards';
import { type 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/CalendarEventRecord';
import { fetchAllNodes } from 'src/logic-functions/data/utils/fetchAllNodes';
import { type ConnectionPage } from 'src/logic-functions/types/ConnectionPage';
import { resolveConferenceLinkUrl } from 'src/logic-functions/domain/utils/resolveConferenceLinkUrl';
import { stripRestrictedFieldValue } from 'src/logic-functions/data/utils/stripRestrictedFieldValue';
type CalendarEventNode = {
id: string;
title?: string | null;
isCanceled?: boolean | null;
startsAt?: string | null;
endsAt?: string | null;
iCalUid?: string | null;
conferenceLink?: { primaryLinkUrl?: string | null } | null;
location?: string | null;
description?: 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,
},
location: true,
description: 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: resolveConferenceLinkUrl({
conferenceLinkUrl: calendarEvent.conferenceLink?.primaryLinkUrl,
location: stripRestrictedFieldValue(calendarEvent.location ?? undefined),
description: stripRestrictedFieldValue(
calendarEvent.description ?? undefined,
),
}),
}));
};

View File

@@ -0,0 +1,61 @@
import { isNull, isUndefined } from '@sniptt/guards';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { getString } from 'src/logic-functions/utils/getString';
type CallRecordingForSummary = {
id: string;
title: string | undefined;
transcript: unknown;
companionSession: unknown;
summaryMarkdown: string | undefined;
storedSummaryMarkdown: string | undefined;
createdBy: { source: string | undefined; name: string | undefined };
};
export const findCallRecordingForSummary = async (
client: Pick<CoreApiClient, 'query'>,
{ id }: { id: string },
): Promise<CallRecordingForSummary | undefined> => {
const queryResult = await client.query({
callRecordings: {
__args: {
filter: { id: { eq: id } },
first: 1,
},
edges: {
node: {
id: true,
title: true,
transcript: true,
companionSession: true,
summary: { markdown: true },
createdBy: { source: true, name: true },
},
},
},
});
const node = queryResult.callRecordings?.edges?.[0]?.node;
if (isUndefined(node) || isNull(node)) {
return undefined;
}
return {
id: node.id,
title: getString(node.title),
transcript: node.transcript ?? undefined,
companionSession: node.companionSession,
// Blank summaries are not usable; normalize them as missing so generation
// can repair blank summary records.
summaryMarkdown: getString(node.summary?.markdown),
storedSummaryMarkdown:
typeof node.summary?.markdown === 'string'
? node.summary.markdown
: undefined,
createdBy: {
source: getString(node.createdBy?.source),
name: getString(node.createdBy?.name),
},
};
};

View File

@@ -0,0 +1,109 @@
import { isUndefined } from '@sniptt/guards';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { CallRecordingRequestStatus } from 'src/logic-functions/constants/CallRecordingRequestStatus';
import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/TWENTY_PAGE_SIZE';
import { type CallRecordingRecord } from 'src/logic-functions/types/CallRecordingRecord';
import { fetchAllNodes } from 'src/logic-functions/data/utils/fetchAllNodes';
import { type ConnectionPage } from 'src/logic-functions/types/ConnectionPage';
import { normalizeOptionalString } from 'src/logic-functions/utils/normalizeOptionalString';
import { isDesktopAudioRecording } from 'src/logic-functions/domain/utils/isDesktopAudioRecording';
type CallRecordingNode = {
id: string;
title?: string | null;
status?: string | null;
recordingRequestStatus?: unknown;
createdAt?: string | null;
updatedAt?: string | null;
startedAt?: string | null;
endedAt?: string | null;
calendarEventId?: string | null;
externalBotId?: string | null;
companionSession?: unknown;
externalRecordingId?: string | null;
companionFailureReason?: string | null;
};
export const findCallRecordingsByFilter = async (
client: CoreApiClient,
filter: Record<string, unknown>,
shouldStartPageRequest: () => boolean = () => true,
): Promise<CallRecordingRecord[]> => {
const callRecordingNodes = await fetchAllNodes<CallRecordingNode>(
async (afterCursor) => {
const queryResult = await client.query({
callRecordings: {
__args: {
filter: { and: [filter, { companionSession: { is: 'NOT_NULL' } }] },
first: TWENTY_PAGE_SIZE,
...(isUndefined(afterCursor) ? {} : { after: afterCursor }),
},
pageInfo: {
hasNextPage: true,
endCursor: true,
},
edges: {
node: {
id: true,
title: true,
status: true,
recordingRequestStatus: true,
createdAt: true,
updatedAt: true,
startedAt: true,
endedAt: true,
calendarEventId: true,
externalBotId: true,
companionSession: true,
externalRecordingId: true,
companionFailureReason: true,
},
},
},
});
return queryResult.callRecordings as
| ConnectionPage<CallRecordingNode>
| undefined;
},
shouldStartPageRequest,
);
return callRecordingNodes
.filter((recording) => isDesktopAudioRecording(recording.companionSession))
.map((callRecording) => ({
id: callRecording.id,
title: callRecording.title ?? undefined,
status: callRecording.status ?? undefined,
recordingRequestStatus: normalizeCallRecordingRequestStatus(
callRecording.recordingRequestStatus,
),
createdAt: callRecording.createdAt ?? undefined,
updatedAt: callRecording.updatedAt ?? undefined,
startedAt: callRecording.startedAt ?? undefined,
endedAt: callRecording.endedAt ?? undefined,
calendarEventId: callRecording.calendarEventId ?? undefined,
externalBotId: normalizeOptionalString(callRecording.externalBotId),
companionSession: callRecording.companionSession,
externalRecordingId: normalizeOptionalString(
callRecording.externalRecordingId,
),
companionFailureReason: normalizeOptionalString(
callRecording.companionFailureReason,
),
}));
};
const normalizeCallRecordingRequestStatus = (
recordingRequestStatus: unknown,
): CallRecordingRequestStatus | undefined => {
if (recordingRequestStatus === CallRecordingRequestStatus.REQUESTED) {
return recordingRequestStatus;
}
if (recordingRequestStatus === CallRecordingRequestStatus.CANCELED) {
return recordingRequestStatus;
}
return undefined;
};

View File

@@ -0,0 +1,35 @@
import { isUndefined } from '@sniptt/guards';
import { asRecord } from 'src/logic-functions/utils/asRecord';
import { getString } from 'src/logic-functions/utils/getString';
const APP_ACCESS_TOKEN_ENV_VAR_NAME = 'TWENTY_APP_ACCESS_TOKEN';
export const getCurrentWorkspaceId = (): string | undefined => {
const accessToken = getString(process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME]);
if (isUndefined(accessToken)) {
return undefined;
}
return getWorkspaceIdFromAccessToken(accessToken);
};
const getWorkspaceIdFromAccessToken = (
accessToken: string,
): string | undefined => {
const encodedPayload = accessToken.split('.')[1];
if (isUndefined(encodedPayload)) {
return undefined;
}
try {
const payload = asRecord(
JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')),
);
return getString(payload?.workspaceId);
} catch {
return undefined;
}
};

View File

@@ -0,0 +1,19 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
export const releaseCallRecordingArtifactsImportClaim = async (
client: Pick<CoreApiClient, 'mutation'>,
{ callRecordingId, claimedAt }: { callRecordingId: string; claimedAt: Date },
): Promise<void> => {
await client.mutation({
updateCallRecordings: {
__args: {
filter: {
id: { eq: callRecordingId },
companionImportClaimedAt: { eq: claimedAt.toISOString() },
},
data: { companionImportClaimedAt: null },
},
id: true,
},
});
};

View File

@@ -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;

View File

@@ -0,0 +1,58 @@
import { isUndefined } from '@sniptt/guards';
import { type CallRecordingStatus } from 'src/logic-functions/constants/CallRecordingStatus';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type CallRecordingUpdateFields } from 'src/logic-functions/types/CallRecordingUpdateFields';
export const updateCallRecording = async (
client: CoreApiClient,
{
id,
data,
expectedStatuses,
expectedSummary,
}: {
id: string;
data: CallRecordingUpdateFields;
expectedStatuses?: CallRecordingStatus[];
expectedSummary?: { markdown: string | undefined };
},
): Promise<boolean> => {
if (!isUndefined(expectedStatuses) || !isUndefined(expectedSummary)) {
const result = await client.mutation({
updateCallRecordings: {
__args: {
filter: {
id: { eq: id },
...(!isUndefined(expectedStatuses)
? { status: { in: expectedStatuses } }
: {}),
...(!isUndefined(expectedSummary)
? !isUndefined(expectedSummary.markdown)
? { summary: { markdown: { eq: expectedSummary.markdown } } }
: {
or: [
{ summary: { markdown: { is: 'NULL' as const } } },
{ summary: { markdown: { eq: '' } } },
],
}
: {}),
},
data,
},
id: true,
},
});
return (result.updateCallRecordings ?? []).length > 0;
}
await client.mutation({
updateCallRecording: {
__args: {
id,
data,
},
id: true,
},
});
return true;
};

View File

@@ -0,0 +1,16 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { desktopCompanionHandler } from 'src/logic-functions/utils/desktopCompanionHandler';
export default defineLogicFunction({
universalIdentifier: '4ca4fc91-70f8-4bee-84f2-558053b1224a',
name: 'desktop-companion',
description:
'Authenticated personal agenda and Recall desktop upload provisioning for Desktop Recorder.',
timeoutSeconds: 60,
handler: desktopCompanionHandler,
httpRouteTriggerSettings: {
path: '/companion/desktop',
httpMethod: 'POST',
isAuthRequired: true,
},
});

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { shouldCompleteCallRecordingImport } from 'src/logic-functions/domain/utils/shouldCompleteCallRecordingImport';
import { parseRecallWebhookEvent } from 'src/logic-functions/recall-api/utils/parseRecallWebhookEvent';
import { isRecallRecordingDoneSignal } from 'src/logic-functions/domain/utils/isRecallRecordingDoneSignal';
const recording = {
status: 'PROCESSING',
startedAt: '2026-09-07T12:00:00Z',
endedAt: '2026-09-07T12:05:00Z',
transcript: [{ participant: { id: 1 }, words: [] }],
audio: [{ fileId: 'audio-1', label: 'audio.mp3' }],
};
describe('desktop import compatibility', () => {
it('completes audio-only desktop recordings while still requiring bot video', () => {
expect(
shouldCompleteCallRecordingImport({
current: {
...recording,
companionSession: { source: 'desktop', media: 'audio' },
},
updateData: {},
}),
).toBe(true);
expect(
shouldCompleteCallRecordingImport({ current: recording, updateData: {} }),
).toBe(false);
expect(
shouldCompleteCallRecordingImport({
current: {
...recording,
audio: [],
companionSession: { source: 'desktop', media: 'audio' },
},
updateData: {},
}),
).toBe(false);
});
it('routes desktop completion using upload metadata when recording metadata is empty', () => {
const event = parseRecallWebhookEvent({
event: 'sdk_upload.complete',
data: {
data: { code: 'complete', updated_at: '2026-09-07T12:05:00.123456Z' },
sdk_upload: {
id: 'upload-1',
metadata: { twentyCallRecordingId: 'recording-1' },
},
recording: { id: 'recall-recording-1', metadata: {} },
},
});
expect(event).toMatchObject({
callRecordingIdFromMetadata: 'recording-1',
externalRecordingId: 'recall-recording-1',
statusTimestamp: '2026-09-07T12:05:00.123Z',
});
expect(
isRecallRecordingDoneSignal({
event: event?.event ?? '',
statusCode: event?.statusCode,
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import { buildCallRecordingSummaryPrompt } from 'src/logic-functions/domain/utils/buildCallRecordingSummaryPrompt';
const TRANSCRIPT = [
{
participant: { name: 'Alex' },
words: [{ text: 'Hello' }, { text: 'there' }],
},
{
participant: { name: 'Sam' },
words: [{ text: 'Hi' }, { text: 'Alex' }],
},
];
describe('buildCallRecordingSummaryPrompt', () => {
it('flattens entries into speaker-labelled lines', () => {
expect(
buildCallRecordingSummaryPrompt({ transcript: TRANSCRIPT }),
).toContain('Transcript:\nAlex: Hello there\nSam: Hi Alex');
});
it('appends the workspace admin instructions when provided', () => {
expect(
buildCallRecordingSummaryPrompt({
transcript: TRANSCRIPT,
additionalSummaryPrompt: ' Write short sales notes. ',
}),
).toBe(
'Additional instructions from the workspace admin:\nWrite short sales notes.\n\nTranscript:\nAlex: Hello there\nSam: Hi Alex',
);
});
it('prefixes a [mm:ss] (or h:mm:ss) timestamp when the entry carries one', () => {
expect(
buildCallRecordingSummaryPrompt({
transcript: [
{
participant: { name: 'Alex' },
words: [
{ text: 'Hello', start_timestamp: { relative: 5 } },
{ text: 'there', start_timestamp: { relative: 6 } },
],
},
{
participant: { name: 'Sam' },
words: [{ text: 'Later', start_timestamp: { relative: 3725 } }],
},
],
}),
).toContain('Transcript:\n[0:05] Alex: Hello there\n[1:02:05] Sam: Later');
});
it('includes the meeting title when provided', () => {
expect(
buildCallRecordingSummaryPrompt({
transcript: TRANSCRIPT,
title: 'Weekly sync',
}),
).toContain(
'Meeting title: Weekly sync\n\nTranscript:\nAlex: Hello there\nSam: Hi Alex',
);
});
it('falls back to a placeholder speaker name', () => {
expect(
buildCallRecordingSummaryPrompt({
transcript: [{ participant: {}, words: [{ text: 'Hey' }] }],
}),
).toContain('Transcript:\nUnknown speaker: Hey');
});
it('skips entries without usable words', () => {
expect(
buildCallRecordingSummaryPrompt({
transcript: [
{ participant: { name: 'Alex' }, words: [] },
{ participant: { name: 'Sam' }, words: [{ text: 'Real' }] },
],
}),
).toContain('Transcript:\nSam: Real');
});
it('returns undefined when there is no usable dialogue', () => {
expect(buildCallRecordingSummaryPrompt({ transcript: [] })).toBeUndefined();
expect(
buildCallRecordingSummaryPrompt({ transcript: { status: 'PENDING' } }),
).toBeUndefined();
expect(
buildCallRecordingSummaryPrompt({
transcript: [{ participant: { name: 'A' }, words: [{ text: ' ' }] }],
}),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { computeRealMeetingKey } from 'src/logic-functions/domain/utils/computeRealMeetingKey';
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:',
);
});
});

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import { extractConferenceLinkUrlFromText } from 'src/logic-functions/domain/utils/extractConferenceLinkUrlFromText';
describe('extractConferenceLinkUrlFromText', () => {
it.each([
[
'extracts a Zoom link from a plain text invitation',
'Felix is inviting you to a scheduled Zoom meeting.\n\nJoin Zoom Meeting\nhttps://us02web.zoom.us/j/81234567890?pwd=aBcDeF123\n\nMeeting ID: 812 3456 7890',
'https://us02web.zoom.us/j/81234567890?pwd=aBcDeF123',
],
[
'extracts a bare Zoom link (location field)',
'https://zoom.us/j/81234567890',
'https://zoom.us/j/81234567890',
],
[
'extracts a Zoom personal room link',
'Join me at https://company.zoom.us/my/felix today',
'https://company.zoom.us/my/felix',
],
[
'extracts a Zoom Government link',
'Join: https://example.zoomgov.com/j/1609618851',
'https://example.zoomgov.com/j/1609618851',
],
[
'extracts a Zoom link from an HTML body and decodes &amp;',
'<div><a href="https://us02web.zoom.us/j/81234567890?pwd=aBcDeF123&amp;uname=Felix">Join Zoom Meeting</a></div>',
'https://us02web.zoom.us/j/81234567890?pwd=aBcDeF123&uname=Felix',
],
[
'strips trailing sentence punctuation',
'Join here: https://zoom.us/j/81234567890.',
'https://zoom.us/j/81234567890',
],
[
'extracts a Google Meet link',
'Join the call on https://meet.google.com/abc-defg-hij',
'https://meet.google.com/abc-defg-hij',
],
[
'extracts a Microsoft Teams link',
'Click here to join: https://teams.microsoft.com/l/meetup-join/19%3ameeting_ABC123%40thread.v2/0?context=%7b%22Tid%22%3a%22111%22%7d',
'https://teams.microsoft.com/l/meetup-join/19%3ameeting_ABC123%40thread.v2/0?context=%7b%22Tid%22%3a%22111%22%7d',
],
[
'extracts a Webex link',
'Join at https://company.webex.com/company/j.php?MTID=m1234abcd',
'https://company.webex.com/company/j.php?MTID=m1234abcd',
],
[
'extracts a GoTo Meeting link',
'https://global.gotomeeting.com/join/123456789',
'https://global.gotomeeting.com/join/123456789',
],
[
'prefers a Zoom link over a Google Meet link when both are present',
'Video call: https://meet.google.com/abc-defg-hij\nJoin Zoom Meeting\nhttps://zoom.us/j/81234567890',
'https://zoom.us/j/81234567890',
],
])('%s', (_label, text, expectedUrl) => {
expect(extractConferenceLinkUrlFromText(text)).toBe(expectedUrl);
});
it.each([
[
'a non-conferencing URL',
'Agenda: https://docs.google.com/document/d/abc',
],
['a Zoom marketing page URL', 'Learn more at https://zoom.us/pricing'],
['plain text without links', 'Conference Room A'],
['an empty string', ''],
['a blank string', ' '],
])('returns undefined for %s', (_label, text) => {
expect(extractConferenceLinkUrlFromText(text)).toBeUndefined();
});
it('returns undefined for null and undefined', () => {
expect(extractConferenceLinkUrlFromText(null)).toBeUndefined();
expect(extractConferenceLinkUrlFromText(undefined)).toBeUndefined();
});
});

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import { isCallRecordingImportComplete } from 'src/logic-functions/domain/utils/isCallRecordingImportComplete';
const AUDIO_VALUE = [{ fileId: 'file-audio-1', label: 'audio.mp3' }];
const VIDEO_VALUE = [{ fileId: 'file-video-1', label: 'video.mp4' }];
const TRANSCRIPT_CONTENT = [{ participant: { id: 1 }, words: [] }];
describe('isCallRecordingImportComplete', () => {
it('is complete when transcript content and both media files are present', () => {
expect(
isCallRecordingImportComplete({
transcript: TRANSCRIPT_CONTENT,
audio: AUDIO_VALUE,
video: VIDEO_VALUE,
companionFailureReason: undefined,
}),
).toBe(true);
});
it('is incomplete while the transcript holds a marker', () => {
expect(
isCallRecordingImportComplete({
transcript: {
recallTranscriptId: 'recall-transcript-1',
status: 'PENDING',
},
audio: AUDIO_VALUE,
video: VIDEO_VALUE,
companionFailureReason: undefined,
}),
).toBe(false);
});
it('is incomplete when the transcript is unset', () => {
expect(
isCallRecordingImportComplete({
transcript: null,
audio: AUDIO_VALUE,
video: VIDEO_VALUE,
companionFailureReason: undefined,
}),
).toBe(false);
});
it('is incomplete while any media field is empty', () => {
expect(
isCallRecordingImportComplete({
transcript: TRANSCRIPT_CONTENT,
audio: undefined,
video: VIDEO_VALUE,
companionFailureReason: undefined,
}),
).toBe(false);
expect(
isCallRecordingImportComplete({
transcript: TRANSCRIPT_CONTENT,
audio: AUDIO_VALUE,
video: [],
companionFailureReason: undefined,
}),
).toBe(false);
});
it('treats a media file skipped for size as resolved', () => {
expect(
isCallRecordingImportComplete({
transcript: TRANSCRIPT_CONTENT,
audio: AUDIO_VALUE,
video: undefined,
companionFailureReason: 'video_file_too_large',
}),
).toBe(true);
expect(
isCallRecordingImportComplete({
transcript: TRANSCRIPT_CONTENT,
audio: undefined,
video: VIDEO_VALUE,
companionFailureReason: 'audio_file_too_large',
}),
).toBe(true);
expect(
isCallRecordingImportComplete({
transcript: TRANSCRIPT_CONTENT,
audio: undefined,
video: undefined,
companionFailureReason: 'video_file_too_large,audio_file_too_large',
}),
).toBe(true);
});
it('does not let a size marker excuse the other missing artifact', () => {
expect(
isCallRecordingImportComplete({
transcript: TRANSCRIPT_CONTENT,
audio: undefined,
video: VIDEO_VALUE,
companionFailureReason: 'video_file_too_large',
}),
).toBe(false);
});
it('ignores unrelated failure reasons', () => {
expect(
isCallRecordingImportComplete({
transcript: TRANSCRIPT_CONTENT,
audio: AUDIO_VALUE,
video: undefined,
companionFailureReason: 'recall_bot_not_found',
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/utils/isCallRecordingStatusDowngrade';
describe('isCallRecordingStatusDowngrade', () => {
it.each([
['SCHEDULED', 'JOINING', false],
['JOINING', 'RECORDING', false],
['RECORDING', 'PROCESSING', false],
['PROCESSING', 'FAILED', false],
['PROCESSING', 'COMPLETED', false],
['RECORDING', 'RECORDING', false],
['COMPLETED', 'RECORDING', true],
['PROCESSING', 'JOINING', true],
['FAILED', 'RECORDING', true],
['JOINING', 'SCHEDULED', true],
['PROCESSING', 'NOT_RECORDED', false],
['NOT_RECORDED', 'PROCESSING', true],
['NOT_RECORDED', 'FAILED', true],
['FAILED', 'NOT_RECORDED', true],
])('from %s to %s -> %s', (fromStatus, toStatus, expected) => {
expect(isCallRecordingStatusDowngrade({ fromStatus, toStatus })).toBe(
expected,
);
});
it('never treats transitions from unknown statuses as downgrades', () => {
expect(
isCallRecordingStatusDowngrade({
fromStatus: undefined,
toStatus: 'COMPLETED',
}),
).toBe(false);
expect(
isCallRecordingStatusDowngrade({
fromStatus: 'NOT_A_STATUS',
toStatus: 'SCHEDULED',
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { isRealTranscript } from 'src/logic-functions/domain/utils/isRealTranscript';
describe('isRealTranscript', () => {
it('is true for a non-empty diarized array', () => {
expect(isRealTranscript([{ participant: { name: 'A' }, words: [] }])).toBe(
true,
);
});
it('is false for an empty array', () => {
expect(isRealTranscript([])).toBe(false);
});
it('is false for PENDING/FAILED markers', () => {
expect(
isRealTranscript({ status: 'PENDING', recallTranscriptId: 'x' }),
).toBe(false);
expect(isRealTranscript({ status: 'FAILED' })).toBe(false);
});
it('is false when unset', () => {
expect(isRealTranscript(null)).toBe(false);
expect(isRealTranscript(undefined)).toBe(false);
});
});

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { parseCallRecordingSummaryAgentResponse } from 'src/logic-functions/domain/utils/parseCallRecordingSummaryAgentResponse';
describe('parseCallRecordingSummaryAgentResponse', () => {
it('returns the trimmed response markdown on success', () => {
expect(
parseCallRecordingSummaryAgentResponse({
success: true,
error: null,
result: { response: ' ## Overview\nGreat call. ' },
}),
).toEqual({ outcome: 'summarized', markdown: '## Overview\nGreat call.' });
});
it('returns undefined when the run failed', () => {
expect(
parseCallRecordingSummaryAgentResponse({
success: false,
error: 'no more available credits',
result: null,
}),
).toBeUndefined();
});
it('returns undefined when the response is empty or missing', () => {
expect(
parseCallRecordingSummaryAgentResponse({
success: true,
error: null,
result: { response: ' ' },
}),
).toBeUndefined();
expect(
parseCallRecordingSummaryAgentResponse({
success: true,
error: null,
result: {},
}),
).toBeUndefined();
});
it('extracts a grounded reason when the transcript cannot be summarized', () => {
expect(
parseCallRecordingSummaryAgentResponse({
success: true,
error: null,
result: {
response:
'SUMMARY_UNAVAILABLE: The transcript contains only greetings and audio checks.',
},
}),
).toEqual({
outcome: 'not-summarizable',
reason: 'The transcript contains only greetings and audio checks.',
});
});
it('rejects an unavailable marker without a reason', () => {
expect(
parseCallRecordingSummaryAgentResponse({
success: true,
error: null,
result: { response: 'SUMMARY_UNAVAILABLE:' },
}),
).toBeUndefined();
});
});

Some files were not shown because too many files have changed in this diff Show More