Compare commits

..
Author SHA1 Message Date
martmull 2c63371e7c Remove cli tools packages from the ageGate 2026-05-19 16:10:47 +02:00
4534 changed files with 48614 additions and 81847 deletions
+21 -21
View File
@@ -106,24 +106,24 @@ Replace `{VERSION}` with the actual version number (e.g., `1.9.0`)
### 2. Create File Structure
**Create changelog file:**
- Path: `packages/twenty-website/src/content/releases/{VERSION}.mdx`
- Example: `packages/twenty-website/src/content/releases/1.9.0.mdx`
- Path: `packages/twenty-website-new/src/content/releases/{VERSION}.mdx`
- Example: `packages/twenty-website-new/src/content/releases/1.9.0.mdx`
**Create image folder:**
- Path: `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
- Example for version 1.9.0: `packages/twenty-website/public/images/releases/1.9/`
- Example for version 2.0.0: `packages/twenty-website/public/images/releases/2.0/`
- Path: `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
- Example for version 1.9.0: `packages/twenty-website-new/public/images/releases/1.9/`
- Example for version 2.0.0: `packages/twenty-website-new/public/images/releases/2.0/`
```bash
# Create the image folder
mkdir -p packages/twenty-website/public/images/releases/{MINOR_VERSION}
mkdir -p packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
```
### 3. Move Illustration Files
**Source:** `/Users/thomascolasdesfrancs/Downloads/🆕`
**Destination:** `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
**Destination:** `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
**Naming Convention:** `{VERSION}-descriptive-name.webp`
@@ -133,8 +133,8 @@ Examples:
```bash
# Move and rename source files, then convert to webp if needed
cp ~/Downloads/🆕/source-file.png packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
cd packages/twenty-website && node scripts/convert-png-to-webp.mjs
cp ~/Downloads/🆕/source-file.png packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
cd packages/twenty-website-new && node scripts/convert-png-to-webp.mjs
```
### 4. Research Features (if needed)
@@ -183,7 +183,7 @@ Description of the third feature.
- **NEVER mention the brand name "Twenty"** in changelog text - use "your workspace", "the platform", or similar neutral references instead
**Reference Previous Changelogs:**
- Check `packages/twenty-website/src/content/releases/` for examples
- Check `packages/twenty-website-new/src/content/releases/` for examples
- Recent releases: 1.7.0.mdx, 1.6.0.mdx, 1.5.0.mdx
### 6. Review
@@ -191,10 +191,10 @@ Description of the third feature.
Open the changelog file for review:
```bash
# Open in Cursor
cursor packages/twenty-website/src/content/releases/{VERSION}.mdx
cursor packages/twenty-website-new/src/content/releases/{VERSION}.mdx
# Open image folder to verify illustrations
open packages/twenty-website/public/images/releases/{MINOR_VERSION}
open packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
```
Review checklist:
@@ -222,8 +222,8 @@ I've created the changelog for version {VERSION}. Here's the content for your re
[Show full MDX content]
Images moved to:
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp
Please review the content. Once you approve, I'll commit the changes and create the pull request.
```
@@ -242,8 +242,8 @@ Possible user responses:
git status
# Add files
git add packages/twenty-website/src/content/releases/{VERSION}.mdx
git add packages/twenty-website/public/images/releases/{MINOR_VERSION}/
git add packages/twenty-website-new/src/content/releases/{VERSION}.mdx
git add packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
# Commit
git commit -m "Add {VERSION} release changelog"
@@ -266,7 +266,7 @@ This release includes:
- Feature 2
- Feature 3
Changelog file: \`packages/twenty-website/src/content/releases/{VERSION}.mdx\`
Changelog file: \`packages/twenty-website-new/src/content/releases/{VERSION}.mdx\`
Release date: {DATE}" \
--base main \
--head {VERSION}
@@ -280,13 +280,13 @@ Or visit: `https://github.com/twentyhq/twenty/pull/new/{VERSION}`
- **Format**: `{MAJOR}.{MINOR}.{PATCH}.mdx`
- **Convention**: One file per complete version
- **Examples**: `1.6.0.mdx`, `1.7.0.mdx`, `2.0.0.mdx`
- **Location**: `packages/twenty-website/src/content/releases/`
- **Location**: `packages/twenty-website-new/src/content/releases/`
### Image Folders
- **Format**: `{MAJOR}.{MINOR}/`
- **Convention**: One folder per minor version (shared across patches)
- **Examples**: `1.6/`, `1.7/`, `2.0/`
- **Location**: `packages/twenty-website/public/images/releases/`
- **Location**: `packages/twenty-website-new/public/images/releases/`
### Image Files
- **Format**: `{VERSION}-descriptive-name.webp`
@@ -311,8 +311,8 @@ Features to document:
3. ___________________________
Branch name: {VERSION}
Changelog path: packages/twenty-website/src/content/releases/{VERSION}.mdx
Images path: packages/twenty-website/public/images/releases/{MINOR_VERSION}/
Changelog path: packages/twenty-website-new/src/content/releases/{VERSION}.mdx
Images path: packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
```
## Tips
+1 -1
View File
@@ -50,4 +50,4 @@ runs:
- name: Deploy
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn twenty app:publish --private --remote target
run: yarn twenty deploy --remote target
@@ -50,4 +50,4 @@ runs:
- name: Install
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn twenty app:install --remote target
run: yarn twenty install --remote target
+2 -2
View File
@@ -1,5 +1,5 @@
#
# Crowdin CLI configuration for Website translations (twenty-website)
# Crowdin CLI configuration for Website translations (twenty-website-new)
# Project ID: 4
# See https://crowdin.github.io/crowdin-cli/configuration for more information
#
@@ -16,7 +16,7 @@ files:
#
# Source file - PO file for Lingui
#
- source: packages/twenty-website/src/locales/en.po
- source: packages/twenty-website-new/src/locales/en.po
#
# Translation files path
#
@@ -105,7 +105,7 @@ jobs:
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --url http://localhost:3000
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --workspace-url http://localhost:3000
- name: Install scaffolded app dependencies
run: |
@@ -153,7 +153,7 @@ jobs:
- name: Authenticate with twenty-server
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote:add --api-key ${{ env.TWENTY_API_KEY }} --url ${{ env.TWENTY_API_URL }}
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
- name: Run scaffolded app integration test (deploys, installs, and verifies the app)
run: |
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
files: |
package.json
yarn.lock
packages/twenty-website/**
packages/twenty-website-new/**
packages/twenty-shared/**
website-task:
needs: changed-files-check
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
steps:
- name: Trigger preview environment workflow
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REPOSITORY: ${{ github.repository }}
+5 -5
View File
@@ -54,7 +54,7 @@ jobs:
# Strict mode fails if there are missing website translations.
- name: Compile website translations
id: compile_translations_strict
run: npx nx run twenty-website:lingui:compile --strict
run: npx nx run twenty-website-new:lingui:compile --strict
continue-on-error: true
- name: Stash any changes before pulling translations
@@ -71,8 +71,8 @@ jobs:
upload_sources: false
upload_translations: false
download_translations: true
source: 'packages/twenty-website/src/locales/en.po'
translation: 'packages/twenty-website/src/locales/%locale%.po'
source: 'packages/twenty-website-new/src/locales/en.po'
translation: 'packages/twenty-website-new/src/locales/%locale%.po'
export_only_approved: false
localization_branch_name: i18n-website
base_url: 'https://twenty.api.crowdin.com'
@@ -101,9 +101,9 @@ jobs:
- name: Compile website translations
id: compile_translations
run: |
npx nx run twenty-website:lingui:compile
npx nx run twenty-website-new:lingui:compile
git status
git add packages/twenty-website/src/locales
git add packages/twenty-website-new/src/locales
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: compile website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
+5 -5
View File
@@ -10,7 +10,7 @@ on:
push:
branches: ['main']
paths:
- 'packages/twenty-website/**'
- 'packages/twenty-website-new/**'
- '.github/crowdin-website.yml'
- '.github/workflows/website-i18n-push.yaml'
@@ -41,14 +41,14 @@ jobs:
run: npx nx build twenty-shared
- name: Extract website translations
run: npx nx run twenty-website:lingui:extract
run: npx nx run twenty-website-new:lingui:extract
- name: Check and commit extracted files
id: check_extract_changes
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
git add packages/twenty-website/src/locales
git add packages/twenty-website-new/src/locales
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: extract website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
@@ -57,14 +57,14 @@ jobs:
fi
- name: Compile website translations
run: npx nx run twenty-website:lingui:compile
run: npx nx run twenty-website-new:lingui:compile
- name: Check and commit compiled files
id: check_compile_changes
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
git add packages/twenty-website/src/locales/generated
git add packages/twenty-website-new/src/locales/generated
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: compile website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
@@ -1,69 +0,0 @@
name: 'Website Preview Dispatch'
permissions:
contents: read
on:
pull_request:
types: [opened, synchronize, reopened, closed, labeled]
paths:
- packages/twenty-website/**
- .github/workflows/website-preview-dispatch.yaml
concurrency:
# Keyed on PR number so independent PRs don't cancel each other. `github.ref`
# would resolve to the base branch under pull_request and collide.
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
trigger-build:
# Same fork PRs from outside the org don't have `secrets.*` so the dispatch
# call would fail anyway — skip explicitly to avoid noise.
if: |
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.action != 'closed' && (
(github.event.action == 'labeled' && github.event.label.name == 'preview-website') ||
(
(
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'COLLABORATOR'
) && contains(fromJSON('["opened","synchronize","reopened"]'), github.event.action)
)
)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Dispatch website-preview-build to ci-privileged
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=website-preview-build \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[pr_head_sha]=$PR_HEAD_SHA" \
-f "client_payload[pr_head_ref]=$PR_HEAD_REF"
trigger-cleanup:
# Covers both merge and close-without-merge — pull_request `closed` fires
# for both. PRs left open forever are covered by OpenNext's
# `maxVersionAgeDays: 14` + `maxNumberOfVersions: 50` auto-pruning in
# open-next.config.ts, so nothing leaks even if cleanup never runs.
if: |
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.action == 'closed'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Dispatch website-preview-cleanup to ci-privileged
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=website-preview-cleanup \
-f "client_payload[pr_number]=$PR_NUMBER"
-26
View File
@@ -1,26 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "lf",
"printWidth": 80,
"sortPackageJson": false,
"ignorePatterns": [
"**/dist/**",
"**/build/**",
"**/lib/**",
"**/.next/**",
"**/coverage/**",
"**/generated/**",
"**/generated-admin/**",
"**/generated-metadata/**",
"**/.cache/**",
"**/node_modules/**",
"**/*.min.js",
"**/*.snap",
"**/*.md",
"**/*.mdx",
"**/seed-project/**/*.mjs",
"packages/twenty-zapier/build/**"
]
}
+2 -2
View File
@@ -81,8 +81,8 @@
"path": "../packages/twenty-sdk"
},
{
"name": "packages/twenty-website",
"path": "../packages/twenty-website"
"name": "packages/twenty-website-new",
"path": "../packages/twenty-website-new"
}
],
"settings": {
+5
View File
@@ -10,4 +10,9 @@ nodeLinker: node-modules
npmMinimalAgeGate: 3d
npmPreapprovedPackages:
- twenty-sdk
- twenty-client-sdk
- create-twenty-app
yarnPath: .yarn/releases/yarn-4.13.0.cjs
+1 -1
View File
@@ -110,7 +110,7 @@ packages/
├── twenty-ui/ # Shared UI components library
├── twenty-shared/ # Common types and utilities
├── twenty-emails/ # Email templates with React Email
├── twenty-website/ # Next.js marketing website
├── twenty-website-new/ # Next.js marketing website
├── twenty-docs/ # Documentation website
├── twenty-zapier/ # Zapier integration
└── twenty-e2e-testing/ # Playwright E2E tests
+44 -44
View File
@@ -1,19 +1,19 @@
<p align="center">
<a href="https://www.twenty.com">
<img src="./packages/twenty-website/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
<img src="./packages/twenty-website-new/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
</a>
</p>
<h2 align="center" >The #1 Open-Source CRM</h2>
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.webp" width="12" height="12"/> Figma</a></p>
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website-new/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website-new/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website-new/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website-new/public/images/readme/figma-icon.webp" width="12" height="12"/> Figma</a></p>
<p align="center">
<a href="https://www.twenty.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/github-cover-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/github-cover-light.webp" />
<img src="./packages/twenty-website/public/images/readme/github-cover-light.webp" alt="Twenty banner" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/github-cover-light.webp" alt="Twenty banner" />
</picture>
</a>
</p>
@@ -24,17 +24,17 @@
Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.
<a href="https://twenty.com/resources/why-twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
<a href="https://twenty.com/resources/why-twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
<br />
# Installation
### <img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
### <img src="./packages/twenty-website-new/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
The fastest way to get started. Sign up at [twenty.com](https://twenty.com) and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.
### <img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
### <img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
Scaffold a new app with the Twenty CLI:
@@ -63,12 +63,12 @@ export default defineObject({
Then ship it to your workspace:
```bash
npx twenty app:publish --private
npx twenty deploy
```
See the [app development guide](https://docs.twenty.com/developers/extend/apps/getting-started) for objects, views, agents, and logic functions.
### <img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
### <img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.com/developers/self-host/capabilities/docker-compose), or contribute locally via the [local setup guide](https://docs.twenty.com/developers/contribute/capabilities/local-setup).
@@ -79,61 +79,61 @@ Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.
Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website-new/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
<table align="center">
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-build-apps-light.webp" alt="Create your apps" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp" alt="Create your apps" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website-new/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-version-control-light.webp" alt="Stay on top with version control" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-version-control-light.webp" alt="Stay on top with version control" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website-new/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-all-tools-light.webp" alt="All the tools you need to build anything" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp" alt="All the tools you need to build anything" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-tools-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-tools-light.webp" alt="Customize your layouts" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-tools-light.webp" alt="Customize your layouts" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website-new/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-ai-agents-light.webp" alt="AI agents and chats" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp" alt="AI agents and chats" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website-new/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-crm-tools-light.webp" alt="Plus all the tools of a good CRM" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp" alt="Plus all the tools of a good CRM" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
</td>
</tr>
</table>
@@ -142,23 +142,23 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
# Stack
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
- <a href="https://nx.dev/"><img src="./packages/twenty-website/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
- <a href="https://nestjs.com/"><img src="./packages/twenty-website/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
- <a href="https://reactjs.org/"><img src="./packages/twenty-website/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
- <a href="https://nx.dev/"><img src="./packages/twenty-website-new/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
- <a href="https://nestjs.com/"><img src="./packages/twenty-website-new/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website-new/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
- <a href="https://reactjs.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
# Thanks
<p align="center">
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.webp" height="28" alt="Chromatic" /></a>
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website-new/public/images/readme/chromatic.webp" height="28" alt="Chromatic" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.webp" height="28" alt="Greptile" /></a>
<a href="https://greptile.com"><img src="./packages/twenty-website-new/public/images/readme/greptile.webp" height="28" alt="Greptile" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.webp" height="28" alt="Sentry" /></a>
<a href="https://sentry.io/"><img src="./packages/twenty-website-new/public/images/readme/sentry.webp" height="28" alt="Sentry" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.webp" height="28" alt="Crowdin" /></a>
<a href="https://crowdin.com/"><img src="./packages/twenty-website-new/public/images/readme/crowdin.webp" height="28" alt="Crowdin" /></a>
</p>
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
@@ -166,4 +166,4 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
# Join the Community
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website-new/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website-new/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website-new/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website-new/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website-new/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website-new/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
+13 -9
View File
@@ -44,12 +44,12 @@
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "npx oxlint -c .oxlintrc.json . && (npx oxfmt --check . || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
"command": "npx oxlint -c .oxlintrc.json . && (prettier . --check --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
},
"configurations": {
"ci": {},
"fix": {
"command": "npx oxlint --fix -c .oxlintrc.json . && npx oxfmt ."
"command": "npx oxlint --fix -c .oxlintrc.json . && prettier . --write --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata"
}
},
"dependsOn": ["^build", "twenty-oxlint-rules:build"]
@@ -59,12 +59,12 @@
"cache": false,
"dependsOn": ["twenty-oxlint-rules:build"],
"options": {
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (npx oxfmt --check $FILES || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (prettier --check $FILES || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
"pattern": "\\.(ts|tsx|js|jsx)$"
},
"configurations": {
"fix": {
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && npx oxfmt $FILES)"
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && prettier --write $FILES)"
}
}
},
@@ -73,14 +73,18 @@
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "npx oxfmt --check {args.files} {args.write}",
"files": ".",
"write": ""
"command": "prettier {args.files} --check --cache {args.cache} --cache-location {args.cacheLocation} --write {args.write} --cache-strategy {args.cacheStrategy}",
"cache": true,
"cacheLocation": "../../.cache/prettier/{projectRoot}",
"cacheStrategy": "metadata",
"write": false
},
"configurations": {
"ci": {},
"ci": {
"cacheStrategy": "content"
},
"fix": {
"command": "npx oxfmt {args.files}"
"write": true
}
},
"dependsOn": ["^build"]
+1 -2
View File
@@ -13,7 +13,6 @@
"concurrently": "^8.2.2",
"http-server": "^14.1.1",
"nx": "22.5.4",
"oxfmt": "0.50.0",
"tsx": "^4.17.0",
"verdaccio": "^6.3.1"
},
@@ -54,7 +53,7 @@
"packages/twenty-ui",
"packages/twenty-utils",
"packages/twenty-zapier",
"packages/twenty-website",
"packages/twenty-website-new",
"packages/twenty-docs",
"packages/twenty-e2e-testing",
"packages/twenty-shared",
+4 -4
View File
@@ -1,7 +1,7 @@
<div align="center">
<a href="https://twenty.com">
<picture>
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-website/public/images/core/logo.svg" height="128">
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-website-new/public/images/core/logo.svg" height="128">
</picture>
</a>
<h1>Create Twenty App</h1>
@@ -35,7 +35,7 @@ The scaffolder will:
| `--name <name>` | Set the app name |
| `--display-name <displayName>` | Set the display name |
| `--description <description>` | Set the description |
| `--url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
| `--workspace-url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
| `--authentication-method <method>` | `oauth` or `apiKey` (default: `apiKey` for local, `oauth` for remote) |
## Documentation
@@ -48,8 +48,8 @@ Full documentation is available at **[docs.twenty.com/developers/extend/apps](ht
## Troubleshooting
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty docker:logs`.
- Auth not working: run `yarn twenty remote:add --local` to re-authenticate.
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`.
- Auth not working: run `yarn twenty remote add --local` to re-authenticate.
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
## Contributing
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.7.0",
"version": "2.5.1",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
+10 -7
View File
@@ -18,8 +18,11 @@ const program = new Command(packageJson.name)
.option('-n, --name <name>', 'Application name')
.option('-d, --display-name <displayName>', 'Application display name')
.option('--description <description>', 'Application description')
.option('--url <url>', 'Twenty server URL (default: http://localhost:2020)')
.option('--api-url <apiUrl>', '[deprecated: use --url]')
.option(
'--workspace-url <workspaceUrl>',
'Twenty workspace URL (default: http://localhost:2020)',
)
.option('--api-url <apiUrl>', '[deprecated: use --workspace-url]')
.option(
'--authentication-method <method>',
'Authentication method: oauth or apiKey (default: apiKey for local, oauth for remote)',
@@ -32,7 +35,7 @@ const program = new Command(packageJson.name)
name?: string;
displayName?: string;
description?: string;
url?: string;
workspaceUrl?: string;
apiUrl?: string;
authenticationMethod?: AuthenticationMethod;
},
@@ -65,18 +68,18 @@ const program = new Command(packageJson.name)
if (options?.apiUrl) {
console.warn(
chalk.yellow('Warning: --api-url is deprecated. Use --url instead.'),
chalk.yellow(
'Warning: --api-url is deprecated. Use --workspace-url instead.',
),
);
}
const serverUrl = (options?.url ?? options?.apiUrl)?.replace(/\/+$/, '');
await new CreateAppCommand().execute({
directory,
name: options?.name,
displayName: options?.displayName,
description: options?.description,
serverUrl,
workspaceUrl: options?.workspaceUrl ?? options?.apiUrl,
authenticationMethod: options?.authenticationMethod,
});
},
@@ -49,19 +49,19 @@
## Best practice
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
It's highly recommended to create new app entities using `yarn twenty 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` |
| Entity type | Command | Generated file |
| -------------------- | ------------------------------------ | ------------------------------------- |
| Object | `yarn twenty add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty add pageLayout` | `src/page-layouts/<name>.ts` |
This helps automatically generate required IDs etc.
@@ -11,8 +11,8 @@ Run `yarn twenty help` to list all available commands.
## Useful Commands
- `yarn twenty dev` - Start the development server and sync your app
- `yarn twenty docker:status` - Check the local Twenty server status
- `yarn twenty docker:start` - Start the local Twenty server
- `yarn twenty server status` - Check the local Twenty server status
- `yarn twenty server start` - Start the local Twenty server
- `yarn test` - Run integration tests
## Learn More
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -1,11 +1,4 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
Avatar,
IconBox,
IconHierarchy,
IconLayout,
IconSettingsAutomation,
} from 'twenty-sdk/ui';
import { useState } from 'react';
import {
@@ -37,7 +30,7 @@ const CATEGORIES = [
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
label: 'LOGIC FUNCTION',
label: 'SERVERLESS FUNCT.',
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
@@ -66,6 +59,15 @@ const CATEGORIES = [
},
] as const;
const ItemIcon = ({ color }: { color: string }) => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="2" y="2" width="5" height="5" rx="1" fill={color} />
<rect x="9" y="2" width="5" height="5" rx="1" fill={color} opacity="0.6" />
<rect x="2" y="9" width="5" height="5" rx="1" fill={color} opacity="0.6" />
<rect x="9" y="9" width="5" height="5" rx="1" fill={color} opacity="0.3" />
</svg>
);
const ArrowUpRight = ({ color = '#999' }: { color?: string }) => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
@@ -91,18 +93,6 @@ const CategoryCard = ({
}) => {
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
const CategoryIcon = () => {
if (title === 'Data model') {
return <IconHierarchy color={color} size={'20px'} />;
}
if (title === 'Logic') {
return <IconSettingsAutomation color={color} size={'20px'} />;
}
if (title === 'Layout') {
return <IconLayout color={color} size={'20px'} />;
}
};
return (
<div
style={{
@@ -121,12 +111,8 @@ const CategoryCard = ({
style={{
padding: '16px 20px',
background: `${color}22`,
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<CategoryIcon />
<span
style={{
fontSize: '16px',
@@ -168,7 +154,7 @@ const CategoryCard = ({
transition: 'background 0.15s',
}}
>
<IconBox color={color} size={'20px'} />
<ItemIcon color={color} />
<span
style={{
fontSize: '13px',
@@ -204,11 +190,13 @@ const MainPage = () => {
padding: '40px',
}}
>
<Avatar
{/*<Avatar
avatarUrl={getPublicAssetUrl('logo.svg')}
placeholder={APP_DISPLAY_NAME}
placeholderColorSeed={APP_DISPLAY_NAME}
type="squared"
size="xl"
/>
/>*/}
<span
style={{
fontSize: '24px',
@@ -232,7 +220,7 @@ const MainPage = () => {
You can now add content to your app.
</span>
<a
href="/settings/applications#installed"
href="/settings/applications"
style={{
display: 'inline-flex',
alignItems: 'center',
@@ -17,7 +17,7 @@ import {
DEV_API_URL,
serverStart,
} from 'twenty-sdk/cli';
import { isDefined, normalizeUrl } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
import {
getDockerInstallInstructions,
isDockerInstalled,
@@ -33,7 +33,7 @@ type CreateAppOptions = {
name?: string;
displayName?: string;
description?: string;
serverUrl?: string;
workspaceUrl?: string;
authenticationMethod?: AuthenticationMethod;
};
@@ -45,9 +45,9 @@ export class CreateAppCommand {
const { appName, appDisplayName, appDirectory, appDescription } =
this.getAppInfos(options);
const serverUrl = options.serverUrl ?? DEV_API_URL;
const workspaceUrl = options.workspaceUrl ?? DEV_API_URL;
const skipLocalInstance = serverUrl !== DEV_API_URL;
const skipLocalInstance = workspaceUrl !== DEV_API_URL;
if (!skipLocalInstance && !isDockerInstalled()) {
console.log(chalk.yellow('\n' + getDockerInstallInstructions() + '\n'));
@@ -118,7 +118,7 @@ export class CreateAppCommand {
console.log('');
let authSucceeded = false;
let resolvedServerUrl = serverUrl;
let resolvedWorkspaceUrl = workspaceUrl;
let serverReady = skipLocalInstance;
if (!skipLocalInstance) {
@@ -126,7 +126,7 @@ export class CreateAppCommand {
const serverResult = await this.ensureDockerServer(dockerPullPromise);
if (isDefined(serverResult.url)) {
resolvedServerUrl = serverResult.url;
resolvedWorkspaceUrl = serverResult.url;
serverReady = true;
}
}
@@ -134,16 +134,18 @@ export class CreateAppCommand {
if (serverReady) {
this.logNextStep('Authenticating');
authSucceeded = await this.tryExistingAuth(resolvedServerUrl);
authSucceeded = await this.tryExistingAuth(resolvedWorkspaceUrl);
if (authSucceeded) {
this.logDetail('Reusing existing credentials');
} else if (authenticationMethod === 'oauth') {
this.logDetail('Starting OAuth flow');
authSucceeded = await this.authenticateWithOAuth(resolvedServerUrl);
authSucceeded =
await this.authenticateWithOAuth(resolvedWorkspaceUrl);
} else {
this.logDetail('Using development API key');
authSucceeded = await this.authenticateWithDevKey(resolvedServerUrl);
authSucceeded =
await this.authenticateWithDevKey(resolvedWorkspaceUrl);
}
}
@@ -163,10 +165,10 @@ export class CreateAppCommand {
}
if (syncSucceeded) {
await this.openMainPage(appDirectory, resolvedServerUrl);
await this.openMainPage(appDirectory, resolvedWorkspaceUrl);
}
this.logSuccess(appDirectory, resolvedServerUrl, authSucceeded);
this.logSuccess(appDirectory, resolvedWorkspaceUrl, authSucceeded);
} catch (error) {
console.error(
chalk.red('\nCreate application failed:'),
@@ -313,7 +315,7 @@ export class CreateAppCommand {
private async openMainPage(
appDirectory: string,
serverUrl: string,
workspaceUrl: string,
): Promise<void> {
try {
const configService = new ConfigService();
@@ -326,7 +328,7 @@ export class CreateAppCommand {
const [universalIdentifier, frontUrl] = await Promise.all([
this.readMainPageLayoutUniversalIdentifier(appDirectory),
this.resolveWorkspaceFrontUrl(serverUrl, token),
this.resolveWorkspaceFrontUrl(workspaceUrl, token),
]);
if (!universalIdentifier || !frontUrl) {
@@ -334,7 +336,7 @@ export class CreateAppCommand {
}
const pageLayoutId = await this.resolvePageLayoutId(
serverUrl,
workspaceUrl,
universalIdentifier,
token,
);
@@ -353,12 +355,12 @@ export class CreateAppCommand {
}
private async resolveWorkspaceFrontUrl(
serverUrl: string,
workspaceUrl: string,
token: string,
): Promise<string | null> {
const query = `{ currentWorkspace { workspaceUrls { subdomainUrl customUrl } } }`;
const response = await fetch(`${serverUrl}/metadata`, {
const response = await fetch(`${workspaceUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -387,7 +389,7 @@ export class CreateAppCommand {
const frontUrl = urls.customUrl ?? urls.subdomainUrl;
return frontUrl ? normalizeUrl(frontUrl) : null;
return frontUrl?.replace(/\/+$/, '') ?? null;
}
private async readMainPageLayoutUniversalIdentifier(
@@ -408,13 +410,13 @@ export class CreateAppCommand {
}
private async resolvePageLayoutId(
serverUrl: string,
workspaceUrl: string,
universalIdentifier: string,
token: string,
): Promise<string | null> {
const query = `{ getPageLayouts { id universalIdentifier } }`;
const response = await fetch(`${serverUrl}/metadata`, {
const response = await fetch(`${workspaceUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -501,7 +503,7 @@ export class CreateAppCommand {
});
}
private async tryExistingAuth(serverUrl: string): Promise<boolean> {
private async tryExistingAuth(workspaceUrl: string): Promise<boolean> {
try {
const configService = new ConfigService();
const remoteNames = await configService.getRemotes();
@@ -509,7 +511,7 @@ export class CreateAppCommand {
for (const remoteName of remoteNames) {
const remoteConfig = await configService.getConfigForRemote(remoteName);
if (remoteConfig.apiUrl !== serverUrl) {
if (remoteConfig.apiUrl !== workspaceUrl) {
continue;
}
@@ -519,7 +521,7 @@ export class CreateAppCommand {
continue;
}
const response = await fetch(`${serverUrl}/metadata`, {
const response = await fetch(`${workspaceUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -553,11 +555,11 @@ export class CreateAppCommand {
}
}
private async authenticateWithDevKey(serverUrl: string): Promise<boolean> {
private async authenticateWithDevKey(workspaceUrl: string): Promise<boolean> {
try {
const result = await authLogin({
apiKey: DEV_API_KEY,
apiUrl: serverUrl,
apiUrl: workspaceUrl,
remote: 'local',
});
@@ -572,7 +574,7 @@ export class CreateAppCommand {
console.log(
chalk.yellow(
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
' Authentication failed. Run `yarn twenty remote add --local` manually.',
),
);
@@ -580,7 +582,7 @@ export class CreateAppCommand {
} catch {
console.log(
chalk.yellow(
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
' Authentication failed. Run `yarn twenty remote add --local` manually.',
),
);
@@ -596,21 +598,21 @@ export class CreateAppCommand {
}
}
private async authenticateWithOAuth(serverUrl: string): Promise<boolean> {
private async authenticateWithOAuth(workspaceUrl: string): Promise<boolean> {
try {
const remoteName = this.deriveRemoteName(serverUrl);
const remoteName = this.deriveRemoteName(workspaceUrl);
ConfigService.setActiveRemote(remoteName);
this.logDetail('Opening browser for OAuth...');
const result = await authLoginOAuth({ apiUrl: serverUrl });
const result = await authLoginOAuth({ apiUrl: workspaceUrl });
if (result.success) {
const configService = new ConfigService();
await configService.setDefaultRemote(remoteName);
this.logDetail(`Authenticated via OAuth to ${serverUrl}`);
this.logDetail(`Authenticated via OAuth to ${workspaceUrl}`);
return true;
}
@@ -618,7 +620,7 @@ export class CreateAppCommand {
console.log(
chalk.yellow(
` OAuth failed: ${result.error.message}\n` +
` Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
` Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
),
);
@@ -626,7 +628,7 @@ export class CreateAppCommand {
} catch {
console.log(
chalk.yellow(
` Authentication failed. Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
` Authentication failed. Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
),
);
@@ -636,7 +638,7 @@ export class CreateAppCommand {
private logSuccess(
appDirectory: string,
serverUrl: string,
workspaceUrl: string,
authSucceeded: boolean,
): void {
const dirName = basename(appDirectory);
@@ -654,7 +656,9 @@ export class CreateAppCommand {
if (!authSucceeded) {
console.log(chalk.white(` ${stepNumber}. Connect to a Twenty instance`));
console.log(
chalk.cyan(' yarn twenty remote:add --url <your-instance-url>\n'),
chalk.cyan(
' yarn twenty remote add --api-url <your-instance-url>\n',
),
);
stepNumber++;
}
@@ -664,7 +668,7 @@ export class CreateAppCommand {
stepNumber++;
console.log(chalk.white(` ${stepNumber}. Open your twenty instance`));
console.log(chalk.cyan(` ${serverUrl}\n`));
console.log(chalk.cyan(` ${workspaceUrl}\n`));
console.log(
chalk.gray(
@@ -17,7 +17,6 @@ const UNIVERSAL_IDENTIFIERS_PATH = join(
'constants',
'universal-identifiers.ts',
);
const YARNRC_PATH = 'yarnrc.yml';
// Template content matching template/src/constants/universal-identifiers.ts
const TEMPLATE_UNIVERSAL_IDENTIFIERS = `export const APP_DISPLAY_NAME = 'DISPLAY-NAME-TO-BE-GENERATED';
@@ -174,27 +173,6 @@ describe('copyBaseApplicationProject', () => {
expect(publicDirectoryContents).toHaveLength(0);
});
it('should rename yarnrc.yml to .yarnrc.yml in the scaffolded project', async () => {
await fs.writeFile(
join(testAppDirectory, YARNRC_PATH),
'nodeLinker: node-modules',
);
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
expect(await fs.pathExists(join(testAppDirectory, YARNRC_PATH))).toBe(
false,
);
expect(await fs.pathExists(join(testAppDirectory, '.yarnrc.yml'))).toBe(
true,
);
});
it('should handle empty description', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
@@ -22,7 +22,7 @@ export const copyBaseApplicationProject = async ({
onProgress?.('Copying base template');
await fs.copy(join(__dirname, './constants/template'), appDirectory);
onProgress?.('Configuring dotfiles (.gitignore, .github, .yarnrc.yml)');
onProgress?.('Configuring dotfiles (.gitignore, .github)');
await renameDotfiles({ appDirectory });
onProgress?.('Mirroring AGENTS.md to CLAUDE.md');
@@ -47,7 +47,6 @@ const renameDotfiles = async ({ appDirectory }: { appDirectory: string }) => {
const renames = [
{ from: 'gitignore', to: '.gitignore' },
{ from: 'github', to: '.github' },
{ from: 'yarnrc.yml', to: '.yarnrc.yml' },
];
for (const { from, to } of renames) {
@@ -28,6 +28,6 @@ export const getDockerInstallInstructions = (): string => {
' Then run this command again.',
'',
' Alternatively, connect to an existing Twenty instance:',
' npx create-twenty-app@latest my-twenty-app --url <your-instance-url>',
' npx create-twenty-app@latest my-twenty-app --workspace-url <your-instance-url>',
].join('\n');
};
@@ -93,7 +93,7 @@ yarn install
# Register your local Twenty server as a remote (interactive prompt).
# When asked for the URL use http://localhost:2021 and paste an API key
# from Settings -> Developers in the Twenty UI.
yarn twenty remote:add
yarn twenty remote add
# Build, install, and watch for changes.
yarn twenty dev
@@ -107,8 +107,8 @@ watching `src/`. Edit any file and the change is re-synced within seconds.
```bash
cd packages/twenty-apps/community/github-connector
yarn install
yarn twenty remote:add # same prompts as above
yarn twenty app:install # builds and installs once
yarn twenty remote add # same prompts as above
yarn twenty install # builds and installs once
```
## Configure authentication
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty remote:add --api-url http://localhost:2020 --as local
yarn twenty remote add --api-url http://localhost:2020 --as local
```
Then, start development mode to sync your app and watch for changes:
@@ -22,18 +22,18 @@ Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & Authentication
yarn twenty remote:add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote:status # Check auth status
yarn twenty remote:use # Set default remote
yarn twenty remote:list # List all configured remotes
yarn twenty remote:remove <name> # Remove a remote
yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
yarn twenty remote remove <name> # Remove a remote
# Application
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty dev:add # Scaffold a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty dev:function:logs # Stream function logs
yarn twenty dev:function:exec # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty logs # Stream function logs
yarn twenty exec # Execute a function with JSON payload
yarn twenty uninstall # Uninstall app from workspace
```
## Integration Tests
@@ -13,7 +13,7 @@ beforeAll(async () => {
if (!apiUrl || !token) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -10,8 +10,8 @@
"packageManager": "yarn@4.9.2",
"scripts": {
"dev": "twenty dev",
"exec": "twenty dev:fn-exec",
"uninstall": "twenty app:uninstall",
"exec": "twenty exec",
"uninstall": "twenty uninstall",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty remote:add --api-url http://localhost:2020 --as local
yarn twenty remote add --api-url http://localhost:2020 --as local
```
Then, start development mode to sync your app and watch for changes:
@@ -22,18 +22,18 @@ Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & Authentication
yarn twenty remote:add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote:status # Check auth status
yarn twenty remote:use # Set default remote
yarn twenty remote:list # List all configured remotes
yarn twenty remote:remove <name> # Remove a remote
yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
yarn twenty remote remove <name> # Remove a remote
# Application
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty dev:add # Scaffold a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty dev:function:logs # Stream function logs
yarn twenty dev:function:exec # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty logs # Stream function logs
yarn twenty exec # Execute a function with JSON payload
yarn twenty uninstall # Uninstall app from workspace
```
## LLMs instructions
@@ -13,7 +13,7 @@ beforeAll(async () => {
if (!apiUrl || !token) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -1,18 +0,0 @@
{
"$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": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}
@@ -1,267 +0,0 @@
# Fireflies for Twenty
Sync [Fireflies](https://fireflies.ai) call transcripts and AI summaries onto
the matching `CalendarEvent` in your Twenty CRM — searchable, in context, and
ready for AI agents and workflows to act on. Plus on-demand workflow tools to
sync, list, and search Fireflies calls from the AI chat or workflow builder.
## What this app does
1. Fireflies records and transcribes your Zoom / Meet / Teams / phone call.
2. When the transcript is ready, Fireflies fires a `meeting.transcribed`
webhook; once Fireflies finishes its AI summary, it fires a separate
`meeting.summarized` webhook.
3. For each event, this app fetches the relevant data via the Fireflies
GraphQL API.
4. It finds the matching `CalendarEvent` in Twenty and writes the content
into either the **Transcript** or **Summary** field on that event.
Alongside the webhook, three [Workflow tools](#workflow-tools) let you
trigger Fireflies actions from the AI chat or as steps inside a workflow,
without waiting for Fireflies to push.
### How a transcript is matched to a CalendarEvent
The matcher tries two provider-ID strategies in priority order and stops at
the first hit:
1. **Provider-native event ID** — Fireflies' `calendar_id` / `cal_id` is
matched against `CalendarChannelEventAssociation.eventExternalId`. Covers
events synced into Twenty from Google Calendar (including individual
instances of recurring events, where Fireflies returns the per-instance
id with timestamp on `cal_id`).
2. **iCalUID** — Fireflies' `calendar_id` is matched against
`CalendarEvent.iCalUid`. Covers events synced from Outlook / CalDAV,
where Fireflies returns the RFC 5545 iCalUID directly.
Both identifiers are populated by Twenty's calendar drivers on every synced
CalendarEvent, so any meeting that's been pulled in via Google / Outlook /
CalDAV calendar sync will match exactly. The matcher does **not** fall back
to fuzzy URL matching — if the transcript can't be tied to a synced calendar
event, the call is treated as an orphan and skipped (see
[Limitations](#limitations) below). This avoids silently writing transcripts
to the wrong event.
## What gets added to your Twenty workspace
Two new fields on the standard **CalendarEvent** object:
- **Transcript** — rich-text field, speaker-attributed (e.g. *"**Sarah:**
Hi there"*, then *"**John:** Doing well, thanks."*).
- **Summary** — rich-text field with the Fireflies AI summary: a bullet-list
overview, action items grouped by speaker, topics discussed, and keywords.
Plus three workflow tools — see [Workflow tools](#workflow-tools) below.
## Workflow tools
Once the API key is configured, three tools become available in the workflow
builder and the AI chat — covering the cases the webhook can't:
- **Sync Fireflies Call** — *"sync the Fireflies call `01HXYZ...` onto its
CalendarEvent now"*. As a workflow step: provide `transcriptId`. Runs the
same pipeline as the webhook (fetch transcript + AI summary, find matching
CalendarEvent, write Transcript + Summary fields) on demand. Use cases:
**backfilling** historical calls that happened before the app was
installed; **recovering** from a missed webhook (e.g. the calendar event
hadn't synced yet when Fireflies pushed); or triggering a sync from a
workflow instead of waiting for Fireflies. Output includes
`calendarEventId`, `updatedFields`, and a per-field outcome breakdown so
partial successes are visible.
- **List Fireflies Calls By Participant** — *"show me my last 5 calls with
john@acme.com"*. As a workflow step: provide `participantEmail` (and
optional `limit`, max 50). Returns recent Fireflies calls — newest first —
where that email was an attendee, with title, date, duration, host, and
transcript URL. The natural first step in workflows triggered on
`Person.created`*"find what we've talked about with this contact"*.
- **Search Fireflies Calls***"find any call where we discussed pricing"*.
As a workflow step: provide `keyword` (and optional `limit`, max 50).
Matches the keyword against both meeting titles and the words actually
spoken in meetings. Returns the same call-summary shape as the
participant tool. Best for AI-chat-driven research.
The list-by-participant and search tools return the same compact call shape:
`id`, `title`, `date`, `durationMinutes`, `participants`, `hostEmail`,
`transcriptUrl`, `meetingLink`. To then sync any of those calls onto its
CalendarEvent, pass the `id` from a list result into **Sync Fireflies Call**.
## Installing
1. Open **Settings → Applications** in your Twenty workspace.
2. Find **Fireflies** in the available apps and click **Install**.
3. Follow [Self-hosting setup](#self-hosting-setup-admin-only) below to wire
up the API key and webhook (admin-only, one-time).
> **Heads up:** if you see *"Fireflies is not configured"* on the first
> webhook, your Twenty admin needs to follow the
> [Self-hosting setup](#self-hosting-setup-admin-only) section.
## Limitations
What this connector intentionally does **not** support in v1:
- **Calls without a matching CalendarEvent (orphan calls).** Ad-hoc calls
that were never on anyone's synced calendar are skipped. The webhook logs
the skip reason; the transcript still lives in Fireflies. Synthetic event
creation for orphans is planned for v2.
- **Fireflies sentiment, speaker analytics, transcript chapters.** Only
the raw transcript and the AI summary (overview, action items, topics,
keywords) are synced today.
- **Per-user Fireflies accounts.** All transcripts come through one
workspace-shared API key (set by the admin). Per-user OAuth-style
connections require extending Twenty's connection provider system and are
planned once we have evidence that workspace-shared is too coarse.
- **Editing transcripts or summaries in Twenty.** The fields are writable
but the next Fireflies sync overwrites any manual edits — treat them as
read-only.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Webhook returns `Fireflies is not configured` | `FIREFLIES_API_KEY` not set | Admin: paste the API key in **Settings → Applications → Fireflies → Settings** |
| Webhook returns `Invalid webhook signature` | `FIREFLIES_WEBHOOK_SECRET` mismatch between Fireflies and Twenty | Re-copy the signing secret from the Fireflies webhook configuration and paste it into the Twenty app settings |
| Webhook returns `skipped: No CalendarEvent matched the transcript by external ID or iCalUid` | The meeting was never on a synced calendar in Twenty, or the workspace has no Google/Outlook/CalDAV calendar connection set up | Connect the relevant calendar provider in **Settings → Accounts** so the calendar event lands in Twenty with `eventExternalId` and `iCalUid` populated. Manually-created CalendarEvents are intentionally not matched in v1 |
| Transcript appears empty | Fireflies returned no sentences (call too short, audio failed) | Check the call in the Fireflies dashboard; nothing this app can do |
| Summary appears empty | Fireflies hasn't summarized the call yet, or the call was too short to summarize | Fireflies sends `meeting.summarized` separately from `meeting.transcribed` (typically a minute or two later); ensure that event is subscribed to in your Webhooks V2 config |
| Summary is populated but Transcript isn't (or vice versa) | Only one of the two Fireflies events is subscribed to | Subscribe to both `meeting.transcribed` and `meeting.summarized` in your Fireflies Webhooks V2 configuration |
| Fireflies API errors with `401` | API key wrong, rotated, or revoked | Generate a new key in Fireflies → Integrations → Fireflies API → Regenerate, then update `FIREFLIES_API_KEY` |
| **Sync Fireflies Call** reports `No fields were updated` | The Fireflies call's `calendar_id` / `cal_id` doesn't match any CalendarEvent's `iCalUid` or `eventExternalId` (orphan call), or the per-field outcomes show transient Fireflies API failures | Check the `fieldOutcomes` array in the result — `skipped` means orphan call (same limitation as the webhook); `error` means Fireflies-side failure (retry, or inspect the error message) |
| **List / Search** tools return `count: 0` for a contact you've definitely talked to | Email mismatch — Fireflies stores the address as the participant joined the meeting with, which may differ from the contact's primary address in Twenty (aliases, plus-addressing, work vs. personal) | Try the contact's other known email addresses; cross-check the `participants` list on a known matching call |
---
## Self-hosting setup (admin-only)
This section is for Twenty server admins. If you're on Twenty Cloud, skip
this — the credentials may already be configured.
### 1. Generate a Fireflies API key
1. Visit https://app.fireflies.ai and sign in.
2. Go to **Integrations → Fireflies API**.
3. Click **Generate API key** and copy the value (it's only shown once).
### 2. Configure a Webhooks V2 endpoint in Fireflies
This integration targets [Fireflies Webhooks V2](https://docs.fireflies.ai/graphql-api/webhooks-v2)
(snake_case payload, granular event subscriptions). The legacy V1 webhook
format (`meetingId` / `eventType: "Transcription completed"`) is **not**
supported.
1. Open the Webhooks V2 page: https://app.fireflies.ai/integrations/api/webhook
2. Set the **Webhook URL** to your Twenty deployment's webhook endpoint:
`https://<your-twenty-domain>/webhook/fireflies`. Twenty resolves the
target workspace from the request's `Host` header, so the URL must match
the workspace's public domain — `localhost` is not valid in the
Fireflies UI. For local development, expose your dev server with a
tunnel like `ngrok http 3000` and paste the HTTPS forwarding URL here,
or skip the Fireflies UI entirely and POST a signed payload directly to
your local endpoint (see [Local webhook testing](#local-webhook-testing)
in the developer section below).
3. Set a **Signing Secret** (a long random string — generate one with
`openssl rand -hex 32`). Save it; you'll paste it into Twenty next.
4. Under **Events**, subscribe to **both**:
- **`meeting.transcribed`** — fires when the transcript is ready and
writes it to the **Transcript** field.
- **`meeting.summarized`** — fires once Fireflies finishes its AI summary
and writes it to the **Summary** field.
Subscribing to only one is fine if you don't want the other field
populated; the app dispatches per event.
5. **Save** the configuration.
### 3. Wire the credentials into Twenty
1. In Twenty: **Settings → Applications → Fireflies → Settings tab**.
2. Paste the Fireflies API key into the `FIREFLIES_API_KEY` row.
3. Paste the signing secret into the `FIREFLIES_WEBHOOK_SECRET` row.
After saving, the next time Fireflies finishes processing a recording, the
transcript will land on the matching CalendarEvent within a few seconds;
the summary follows once Fireflies finishes the AI summarization step
(typically a minute or two later — Fireflies sends two separate webhooks).
---
## Why `transcript` / `summary` fields on `CalendarEvent` instead of a new object?
Storing the transcript and AI summary as rich-text fields directly on the
existing `CalendarEvent`:
- Keeps everything about a meeting in one place (no joins)
- Avoids inventing a new object that other call-recording apps would each
need to coordinate on
- Works today without lookup fields
If later integrations (Gong, Otter, Zoom AI, etc.) make one pair of fields
too restrictive — for example, needing to distinguish *which* tool produced
the transcript — we'll promote the fields to a platform-level concept rather
than keep extending this app.
---
## Developers only
If you're working on this app rather than installing the published version:
```bash
cd packages/twenty-apps/internal/twenty-fireflies
# Day-to-day development (publish + install + watch in one):
yarn twenty dev
# Run unit tests:
yarn test
# Lint:
yarn lint
```
`twenty dev` is recommended for iteration — it publishes to your local Twenty
server, installs the app, and watches for changes in one command.
The Fireflies GraphQL API is called directly via `fetch` — no `fireflies` SDK
dependency. See `src/logic-functions/utils/fireflies-api-request.ts` for the
auth + error-handling wrapper that all queries go through.
### Local webhook testing
Fireflies' Webhooks V2 UI only accepts a publicly reachable HTTPS URL, so
pointing it at `http://localhost:*` directly is not possible. Two paths:
**End-to-end via tunnel.** Run a tunnel that fronts your local server with
a public HTTPS URL (`ngrok http 3000`, `cloudflared tunnel`, etc.), paste
the HTTPS forwarding URL into the Fireflies webhook UI as the **Webhook
URL**, and exercise the integration by ending a real Fireflies meeting.
**Backend-only via signed `curl`.** Skip the Fireflies UI entirely and POST
a signed payload straight to the local endpoint. The signature must be
HMAC-SHA256 over the **raw** request body, keyed by your
`FIREFLIES_WEBHOOK_SECRET`, prefixed with `sha256=`:
```bash
export FIREFLIES_WEBHOOK_SECRET='<the secret you set in Twenty app settings>'
BODY='{"event":"meeting.transcribed","meeting_id":"<a-real-fireflies-transcript-id>"}'
SIG=$(printf '%s' "$BODY" \
| openssl dgst -sha256 -hmac "$FIREFLIES_WEBHOOK_SECRET" \
| awk '{print $NF}')
curl -X POST http://localhost:3000/webhook/fireflies \
-H 'Content-Type: application/json' \
-H "x-hub-signature: sha256=$SIG" \
--data-binary "$BODY"
```
`--data-binary` (not `--data`) is important: it preserves the bytes
verbatim so the HMAC the server computes matches the one `openssl`
computed above. Twenty resolves the workspace from the `Host` header, so
the default dev workspace (mapped to `localhost:3000` in a standard
`yarn start` setup) receives the request.
To match a real `CalendarEvent`, the transcript ID you pass must belong to
a Fireflies call whose `calendar_id` / `cal_id` matches an existing
`CalendarEvent.iCalUid` or `CalendarChannelEventAssociation.eventExternalId`
in your local Twenty workspace. The easiest local seed is to manually
insert a row with one of those identifiers and use a Fireflies transcript
whose calendar fields point at it.
@@ -1,32 +0,0 @@
{
"name": "twenty-fireflies",
"version": "0.1.0",
"description": "Fireflies call-transcript connector for Twenty",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"twenty-client-sdk": "2.4.0",
"twenty-sdk": "2.4.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
"oxlint": "^0.16.0",
"typescript": "^5.9.3",
"vitest": "^3.1.1"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

@@ -1,44 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 64 64"
fill="none"
role="img"
>
<title>Fireflies</title>
<defs>
<linearGradient id="fireflies-tl" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#8E2A8B" />
<stop offset="100%" stop-color="#D02D7E" />
</linearGradient>
<linearGradient id="fireflies-tr" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#C2287D" />
<stop offset="100%" stop-color="#E83C8E" />
</linearGradient>
<linearGradient id="fireflies-mid" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#B0297B" />
<stop offset="100%" stop-color="#D9357F" />
</linearGradient>
<linearGradient id="fireflies-bl" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#7C1F6E" />
<stop offset="100%" stop-color="#C8327F" />
</linearGradient>
</defs>
<path
fill="url(#fireflies-tl)"
d="M4 12a8 8 0 0 1 8-8h14v22H4V12z"
/>
<path
fill="url(#fireflies-tr)"
d="M30 4h22a8 8 0 0 1 8 8v14H30V4z"
/>
<path
fill="url(#fireflies-mid)"
d="M30 30h18v18H30V30z"
/>
<path
fill="url(#fireflies-bl)"
d="M4 30h22v22a8 8 0 0 1-8 8H4V30z"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1,43 +0,0 @@
import { defineApplication } from 'twenty-sdk/define';
import { ABOUT_DESCRIPTION } from 'src/constants/ABOUT_DESCRIPTION.md';
import {
APPLICATION_UNIVERSAL_IDENTIFIER,
FIREFLIES_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER,
FIREFLIES_WEBHOOK_SECRET_VARIABLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Twenty Fireflies',
description:
'Sync Fireflies call transcripts and AI summaries onto matching CalendarEvent records in Twenty, and trigger sync / list / search of Fireflies calls from workflows and the AI chat.',
logoUrl: 'public/twenty-fireflies.svg',
author: 'Twenty',
category: 'Productivity',
aboutDescription: ABOUT_DESCRIPTION,
screenshots: [
'public/gallery/transcript-on-calendar-event.png',
'public/gallery/summary-on-calendar-event.png',
'public/gallery/workflow-builder-actions.png',
'public/gallery/app-settings.png',
],
websiteUrl: 'https://docs.twenty.com/developers/extend/apps/getting-started',
termsUrl: 'https://www.twenty.com/terms',
emailSupport: 'contact@twenty.com',
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
applicationVariables: {
FIREFLIES_API_KEY: {
universalIdentifier: FIREFLIES_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER,
description:
'API key from Fireflies (Integrations → Fireflies API → Generate). Used as a Bearer token against https://api.fireflies.ai/graphql to fetch full transcript content after a webhook fires.',
isSecret: true,
},
FIREFLIES_WEBHOOK_SECRET: {
universalIdentifier: FIREFLIES_WEBHOOK_SECRET_VARIABLE_UNIVERSAL_IDENTIFIER,
description:
'Signing secret for verifying Fireflies Webhooks V2 payloads (sent in the X-Hub-Signature header as sha256=<hex-hmac-sha256-of-body>). Configure the same value on the Fireflies V2 webhook setup page.',
isSecret: true,
},
},
});
@@ -1,64 +0,0 @@
export const ABOUT_DESCRIPTION = `Bring your Fireflies meeting recordings into Twenty. When Fireflies finishes processing a call, the transcript and AI summary land on the matching CalendarEvent automatically — no copy-pasting, no extra tabs.
## What gets added to your workspace
Two new fields appear on the standard **CalendarEvent** object:
- **Transcript** speaker-attributed rich text, e.g. *"**Sarah:** Hi there"* followed by *"**John:** Doing well, thanks"*.
- **Summary** Fireflies' AI-generated overview of the meeting (key points, action items, decisions).
Both update in real time through a Fireflies Webhooks V2 subscription.
## How it syncs
The connector subscribes to two Fireflies V2 events:
- \`meeting.transcribed\` writes the **Transcript** field.
- \`meeting.summarized\` writes the **Summary** field.
Each webhook delivery is HMAC-SHA256 verified against your signing secret before anything touches your data.
## How calls are matched to CalendarEvents
The matcher uses provider-native identifiers never fuzzy URL matching so transcripts always land on the right event:
1. **Provider event ID** Fireflies' \`calendar_id\` / \`calendar_event_uid\` against \`CalendarChannelEventAssociation.eventExternalId\`. Covers events synced from Google Calendar, including individual instances of recurring meetings.
2. **iCalUID** Fireflies' \`calendar_id\` against \`CalendarEvent.iCalUid\`. Covers events synced from Outlook / CalDAV.
Both identifiers are populated automatically when calendars are synced into Twenty. If a recording can't be matched (orphan recording, no calendar sync configured), the webhook reports a clear skip reason and writes nothing.
## Tools for workflows and the AI chat
Beyond the automatic sync, three Fireflies tools become available in **workflows** and the **AI chat**:
- **Sync Fireflies Call** Pull a single Fireflies call onto its CalendarEvent on demand. Useful for backfilling history or recovering from a missed webhook. Same matching rules as the webhook.
- **Search Fireflies Calls** Keyword search across **both** meeting titles and the words spoken during meetings. Ask the AI chat *"find any call where we discussed pricing"* and it returns matching calls with titles, dates, participants, and transcript links.
- **List Fireflies Calls By Participant** List every call a given email address attended. Great as the first step of a workflow triggered when a Person record is created, or to answer *"what calls have we had with this contact?"* from the AI chat.
## Installing
1. Open **Settings Applications** in your Twenty workspace.
2. Find **Twenty Fireflies** in the available apps and click **Install**.
Then your admin completes the one-time wiring (see below).
## One-time setup (admin)
1. Generate an API key at [Fireflies Integrations Fireflies API](https://app.fireflies.ai/settings/developer-settings) and paste it into the **FIREFLIES_API_KEY** application variable.
2. Generate a long random string (\`openssl rand -hex 32\`). Paste it into the **FIREFLIES_WEBHOOK_SECRET** application variable.
3. Configure a Webhooks V2 endpoint at [Fireflies Integrations Webhooks V2](https://app.fireflies.ai/integrations/api/webhook):
- **Webhook URL**: \`https://<your-twenty-domain>/webhook/fireflies\`
- **Signing Secret**: the same value as \`FIREFLIES_WEBHOOK_SECRET\`
- **Events**: subscribe to \`meeting.transcribed\` (required) and \`meeting.summarized\` (optional, for AI summaries)
That's it the next call Fireflies processes will start syncing automatically.
## Limitations
What this connector intentionally does **not** support in v1:
- **Orphan calls** (recordings with no matching CalendarEvent in Twenty) are skipped fuzzy URL matching is avoided so transcripts never land on the wrong event.
- **Per-user Fireflies accounts** all sync goes through one workspace-shared API key set by the admin.
- **Editing transcripts in Twenty** the field is writable in principle, but future Fireflies syncs will overwrite manual edits.
- **Speaker analytics, sentiment, action items as structured fields** only raw transcript and summary text are synced; structured insights stay in the Fireflies dashboard.
`;
@@ -1,29 +0,0 @@
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'97d24431-ebc7-4156-9705-b6900e73edc8';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'ed0a61a4-7638-4fd8-a2c1-982b50aca4ff';
export const FIREFLIES_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER =
'876ebb9f-0fb1-48b3-bd6f-f4138f999ba7';
export const FIREFLIES_WEBHOOK_SECRET_VARIABLE_UNIVERSAL_IDENTIFIER =
'a1ef34d9-f0e7-483e-9909-fce2757bdd23';
export const TRANSCRIPT_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'e444c83b-0d28-41e2-8c3f-4ede9eb88a75';
export const SUMMARY_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'c5f8a3e2-7b91-4d2e-b6a4-9f3e1d5c8a02';
export const FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'e0c2d033-0ef4-4b92-9e64-2d8f2e4c497e';
export const FIREFLIES_SYNC_CALL_UNIVERSAL_IDENTIFIER =
'c715a476-929e-4e5b-b692-6350fc0adb73';
export const FIREFLIES_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER =
'3148f5c7-8bcb-48d3-a7b9-aa811608e256';
export const FIREFLIES_SEARCH_CALLS_UNIVERSAL_IDENTIFIER =
'cb2dd01d-8dca-4222-acce-1d1dbdef9146';
@@ -1,53 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { FIREFLIES_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { firefliesListCallsByParticipantHandler } from 'src/logic-functions/handlers/fireflies-list-calls-by-participant-handler';
import { firefliesListCallsByParticipantInputSchema } from 'src/logic-functions/schemas/fireflies-list-calls-by-participant-input.schema';
const callSummaryProperties = {
id: { type: 'string' },
title: { type: 'string' },
date: { type: 'string' },
durationMinutes: { type: 'number' },
participants: { type: 'array', items: { type: 'string' } },
hostEmail: { type: 'string' },
transcriptUrl: { type: 'string' },
meetingLink: { type: 'string' },
} as const;
export default defineLogicFunction({
universalIdentifier: FIREFLIES_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER,
name: 'fireflies-list-calls-by-participant',
description:
'List Fireflies calls that include a given participant email. Returns each call\'s ID, title, date, duration, participants, host, Fireflies transcript URL, and original meeting link. Use this to answer "what calls have we had with this contact?" — for example as the first step of a workflow triggered when a Person record is created.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: firefliesListCallsByParticipantInputSchema,
},
workflowActionTriggerSettings: {
label: 'List Fireflies Calls By Participant',
inputSchema: jsonSchemaToInputSchema(
firefliesListCallsByParticipantInputSchema,
),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
count: { type: 'number' },
calls: {
type: 'array',
items: {
type: 'object',
properties: callSummaryProperties,
},
},
},
},
],
},
handler: firefliesListCallsByParticipantHandler,
});
@@ -1,51 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { FIREFLIES_SEARCH_CALLS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { firefliesSearchCallsHandler } from 'src/logic-functions/handlers/fireflies-search-calls-handler';
import { firefliesSearchCallsInputSchema } from 'src/logic-functions/schemas/fireflies-search-calls-input.schema';
const callSummaryProperties = {
id: { type: 'string' },
title: { type: 'string' },
date: { type: 'string' },
durationMinutes: { type: 'number' },
participants: { type: 'array', items: { type: 'string' } },
hostEmail: { type: 'string' },
transcriptUrl: { type: 'string' },
meetingLink: { type: 'string' },
} as const;
export default defineLogicFunction({
universalIdentifier: FIREFLIES_SEARCH_CALLS_UNIVERSAL_IDENTIFIER,
name: 'fireflies-search-calls',
description:
'Search Fireflies calls by keyword. Matches the keyword against both meeting titles and the words spoken during meetings (full transcript content). Returns each match\'s ID, title, date, duration, participants, host, Fireflies transcript URL, and meeting link. Use this for AI-chat questions like "find any call where we discussed pricing".',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: firefliesSearchCallsInputSchema,
},
workflowActionTriggerSettings: {
label: 'Search Fireflies Calls',
inputSchema: jsonSchemaToInputSchema(firefliesSearchCallsInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
count: { type: 'number' },
calls: {
type: 'array',
items: {
type: 'object',
properties: callSummaryProperties,
},
},
},
},
],
},
handler: firefliesSearchCallsHandler,
});
@@ -1,47 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { FIREFLIES_SYNC_CALL_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { firefliesSyncCallHandler } from 'src/logic-functions/handlers/fireflies-sync-call-handler';
import { firefliesSyncCallInputSchema } from 'src/logic-functions/schemas/fireflies-sync-call-input.schema';
export default defineLogicFunction({
universalIdentifier: FIREFLIES_SYNC_CALL_UNIVERSAL_IDENTIFIER,
name: 'fireflies-sync-call',
description:
'Sync a single Fireflies call onto its matching CalendarEvent on demand: fetches both transcript and AI summary from Fireflies and writes them to the Transcript and Summary fields. Same matching rules as the webhook (Fireflies calendar_id / cal_id ↔ Twenty eventExternalId or iCalUid). Useful for backfilling history, recovering from a missed webhook, or syncing on a workflow trigger instead of waiting for Fireflies to push.',
timeoutSeconds: 60,
toolTriggerSettings: {
inputSchema: firefliesSyncCallInputSchema,
},
workflowActionTriggerSettings: {
label: 'Sync Fireflies Call',
inputSchema: jsonSchemaToInputSchema(firefliesSyncCallInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
transcriptId: { type: 'string' },
calendarEventId: { type: 'string' },
updatedFields: { type: 'array', items: { type: 'string' } },
fieldOutcomes: {
type: 'array',
items: {
type: 'object',
properties: {
field: { type: 'string' },
status: { type: 'string' },
reason: { type: 'string' },
error: { type: 'string' },
},
},
},
},
},
],
},
handler: firefliesSyncCallHandler,
});
@@ -1,68 +0,0 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { isDefined } from 'twenty-shared/utils';
import { FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { firefliesWebhookHandler } from 'src/logic-functions/handlers/fireflies-webhook-handler';
import {
type FirefliesWebhookPayload,
type FirefliesWebhookResult,
} from 'src/logic-functions/types/fireflies-webhook-payload.type';
import { getFirefliesWebhookSecret } from 'src/logic-functions/utils/get-fireflies-webhook-secret';
import { verifyFirefliesWebhookSignature } from 'src/logic-functions/utils/verify-fireflies-webhook-signature';
const firefliesWebhookRouteHandler = async (
routePayload: RoutePayload<FirefliesWebhookPayload>,
): Promise<FirefliesWebhookResult> => {
const secretResult = getFirefliesWebhookSecret();
if (!secretResult.success) {
return { error: secretResult.error };
}
const { rawBody } = routePayload;
if (!isDefined(rawBody)) {
return {
error:
'Invalid webhook signature: raw request body was not forwarded by the server, cannot verify HMAC',
};
}
const signatureHeader = routePayload.headers['x-hub-signature'];
const signatureCheck = verifyFirefliesWebhookSignature({
rawBody,
signatureHeader,
secret: secretResult.secret,
});
if (!signatureCheck.valid) {
return { error: `Invalid webhook signature: ${signatureCheck.error}` };
}
const body = routePayload.body;
if (!isDefined(body)) {
return { error: 'Webhook payload was empty' };
}
return firefliesWebhookHandler({
meetingId: body.meeting_id,
eventType: body.event,
});
};
export default defineLogicFunction({
universalIdentifier: FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'fireflies-webhook',
description:
'Receives Fireflies webhook events when a transcript is ready, then writes the transcript onto the matching CalendarEvent.',
timeoutSeconds: 60,
handler: firefliesWebhookRouteHandler,
httpRouteTriggerSettings: {
path: '/webhook/fireflies',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: ['x-hub-signature'],
},
});
@@ -1,62 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type FirefliesCallListResult } from 'src/logic-functions/types/fireflies-call-list-result.type';
import { type FirefliesListCallsByParticipantInput } from 'src/logic-functions/types/fireflies-list-calls-by-participant-input.type';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import { listFirefliesTranscripts } from 'src/logic-functions/utils/list-fireflies-transcripts';
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 50;
const clampLimit = (limit: number | undefined): number => {
if (!Number.isFinite(limit) || limit === undefined) {
return DEFAULT_LIMIT;
}
return Math.max(1, Math.min(MAX_LIMIT, Math.trunc(limit)));
};
export const firefliesListCallsByParticipantHandler = async (
parameters: FirefliesListCallsByParticipantInput,
): Promise<FirefliesCallListResult> => {
const participantEmail = parameters.participantEmail?.trim();
if (!isNonEmptyString(participantEmail)) {
return {
success: false,
message: 'Failed to list Fireflies calls',
error: '`participantEmail` is required.',
};
}
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
return {
success: false,
message: 'Fireflies is not configured',
error: apiKeyResult.error,
};
}
const result = await listFirefliesTranscripts({
apiKey: apiKeyResult.apiKey,
participants: [participantEmail],
limit: clampLimit(parameters.limit),
});
if (!result.ok) {
return {
success: false,
message: 'Failed to list Fireflies calls',
error: result.errorMessage,
};
}
return {
success: true,
message: `Found ${result.data.length} Fireflies call(s) with ${participantEmail}.`,
calls: result.data,
count: result.data.length,
};
};
@@ -1,63 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type FirefliesCallListResult } from 'src/logic-functions/types/fireflies-call-list-result.type';
import { type FirefliesSearchCallsInput } from 'src/logic-functions/types/fireflies-search-calls-input.type';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import { listFirefliesTranscripts } from 'src/logic-functions/utils/list-fireflies-transcripts';
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 50;
const clampLimit = (limit: number | undefined): number => {
if (!Number.isFinite(limit) || limit === undefined) {
return DEFAULT_LIMIT;
}
return Math.max(1, Math.min(MAX_LIMIT, Math.trunc(limit)));
};
export const firefliesSearchCallsHandler = async (
parameters: FirefliesSearchCallsInput,
): Promise<FirefliesCallListResult> => {
const keyword = parameters.keyword?.trim();
if (!isNonEmptyString(keyword)) {
return {
success: false,
message: 'Failed to search Fireflies calls',
error: '`keyword` is required.',
};
}
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
return {
success: false,
message: 'Fireflies is not configured',
error: apiKeyResult.error,
};
}
const result = await listFirefliesTranscripts({
apiKey: apiKeyResult.apiKey,
keyword,
keywordScope: 'all',
limit: clampLimit(parameters.limit),
});
if (!result.ok) {
return {
success: false,
message: 'Failed to search Fireflies calls',
error: result.errorMessage,
};
}
return {
success: true,
message: `Found ${result.data.length} Fireflies call(s) matching "${keyword}".`,
calls: result.data,
count: result.data.length,
};
};
@@ -1,125 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { type FirefliesSyncCallInput } from 'src/logic-functions/types/fireflies-sync-call-input.type';
import {
type FirefliesSyncCallFieldOutcome,
type FirefliesSyncCallResult,
} from 'src/logic-functions/types/fireflies-sync-call-result.type';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import {
type FirefliesSyncableField,
syncFirefliesFieldToCalendarEvent,
} from 'src/logic-functions/utils/sync-fireflies-field-to-calendar-event';
const ALL_FIELDS: FirefliesSyncableField[] = ['transcript', 'summary'];
const buildFailure = (
message: string,
error: string,
transcriptId?: string,
): FirefliesSyncCallResult => ({
success: false,
message,
error,
transcriptId,
});
export const firefliesSyncCallHandler = async (
parameters: FirefliesSyncCallInput,
): Promise<FirefliesSyncCallResult> => {
const transcriptId = parameters.transcriptId?.trim();
if (!isNonEmptyString(transcriptId)) {
return buildFailure(
'Failed to sync Fireflies call',
'`transcriptId` is required.',
);
}
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
return buildFailure(
'Fireflies is not configured',
apiKeyResult.error,
transcriptId,
);
}
const client = new CoreApiClient();
const results = await Promise.all(
ALL_FIELDS.map((field) =>
syncFirefliesFieldToCalendarEvent({
apiKey: apiKeyResult.apiKey,
client,
transcriptId,
field,
}),
),
);
const fieldOutcomes: FirefliesSyncCallFieldOutcome[] = results.map(
(result) => {
if (result.status === 'skipped') {
return {
field: result.field,
status: 'skipped',
reason: result.reason,
};
}
if (result.status === 'error') {
return { field: result.field, status: 'error', error: result.error };
}
return { field: result.field, status: 'updated' };
},
);
const updatedFields = fieldOutcomes
.filter((outcome) => outcome.status === 'updated')
.map((outcome) => outcome.field);
const calendarEventId = results.find(
(result) => result.status === 'updated',
)?.calendarEventId;
if (updatedFields.length === 0) {
const skipReasons = fieldOutcomes
.filter((outcome) => outcome.status === 'skipped')
.map((outcome) => `${outcome.field}: ${outcome.reason}`);
const errors = fieldOutcomes
.filter((outcome) => outcome.status === 'error')
.map((outcome) => `${outcome.field}: ${outcome.error}`);
return {
success: false,
message: `No fields were updated on the matching CalendarEvent for Fireflies transcript ${transcriptId}.`,
error: [...errors, ...skipReasons].join(' | ') || 'No fields updated.',
transcriptId,
fieldOutcomes,
};
}
const partialFailures = fieldOutcomes.filter(
(outcome) => outcome.status === 'error',
);
return {
success: true,
message:
partialFailures.length > 0
? `Synced ${updatedFields.join(
' + ',
)} for Fireflies transcript ${transcriptId} (with errors on ${partialFailures
.map((outcome) => outcome.field)
.join(', ')}).`
: `Synced ${updatedFields.join(
' + ',
)} for Fireflies transcript ${transcriptId}.`,
transcriptId,
calendarEventId,
updatedFields,
fieldOutcomes,
};
};
@@ -1,71 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { type FirefliesWebhookResult } from 'src/logic-functions/types/fireflies-webhook-payload.type';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import {
type FirefliesSyncableField,
syncFirefliesFieldToCalendarEvent,
} from 'src/logic-functions/utils/sync-fireflies-field-to-calendar-event';
const TRANSCRIPT_READY_EVENT = 'meeting.transcribed';
const SUMMARY_READY_EVENT = 'meeting.summarized';
const FIELD_BY_EVENT: Record<string, FirefliesSyncableField> = {
[TRANSCRIPT_READY_EVENT]: 'transcript',
[SUMMARY_READY_EVENT]: 'summary',
};
export const firefliesWebhookHandler = async ({
meetingId,
eventType,
}: {
meetingId?: string | null;
eventType?: string | null;
}): Promise<FirefliesWebhookResult> => {
if (!isNonEmptyString(meetingId)) {
return { error: 'Webhook payload is missing meetingId' };
}
const field = isNonEmptyString(eventType)
? FIELD_BY_EVENT[eventType]
: undefined;
if (field === undefined) {
return {
skipped: true,
reason: `Unsupported Fireflies Webhooks V2 event "${
eventType ?? '<missing>'
}"; expected "${TRANSCRIPT_READY_EVENT}" or "${SUMMARY_READY_EVENT}"`,
meetingId,
};
}
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
return { error: apiKeyResult.error, meetingId };
}
const syncResult = await syncFirefliesFieldToCalendarEvent({
apiKey: apiKeyResult.apiKey,
client: new CoreApiClient(),
transcriptId: meetingId,
field,
});
if (syncResult.status === 'error') {
return { error: syncResult.error, meetingId };
}
if (syncResult.status === 'skipped') {
return { skipped: true, reason: syncResult.reason, meetingId };
}
return {
action: 'updated',
field: syncResult.field,
calendarEventId: syncResult.calendarEventId,
meetingId,
};
};
@@ -1,23 +0,0 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const firefliesListCallsByParticipantInputSchema: InputJsonSchema = {
type: 'object',
properties: {
participantEmail: {
type: 'string',
label: 'Participant email',
description:
'Email address of a meeting attendee. Returns Fireflies calls where this email appears in the participants list (case-insensitive match performed by Fireflies). Useful to answer "what calls have we had with this contact?" before reaching out to them.',
},
limit: {
type: 'integer',
label: 'Maximum number of calls',
description:
'Optional. Maximum number of calls to return. Defaults to 20. Fireflies caps the limit at 50 per query.',
minimum: 1,
maximum: 50,
},
},
required: ['participantEmail'],
additionalProperties: false,
};
@@ -1,23 +0,0 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const firefliesSearchCallsInputSchema: InputJsonSchema = {
type: 'object',
properties: {
keyword: {
type: 'string',
label: 'Keyword to search for',
description:
'Keyword or phrase to search across Fireflies meetings. Matches against both meeting titles and the words spoken during meetings. Useful for finding "the call where we discussed pricing" or "any meeting that mentioned the new integration".',
},
limit: {
type: 'integer',
label: 'Maximum number of calls',
description:
'Optional. Maximum number of calls to return. Defaults to 20. Fireflies caps the limit at 50 per query.',
minimum: 1,
maximum: 50,
},
},
required: ['keyword'],
additionalProperties: false,
};
@@ -1,15 +0,0 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const firefliesSyncCallInputSchema: InputJsonSchema = {
type: 'object',
properties: {
transcriptId: {
type: 'string',
label: 'Fireflies call ID',
description:
'The ID of the Fireflies call to sync (also referred to as the "transcript ID" in Fireflies\' API and docs). Found at the end of the Fireflies meeting URL (`https://app.fireflies.ai/view/<id>`) or in the `meeting_id` field of a Fireflies webhook payload. Runs the same pipeline as the webhook: fetches transcript + AI summary from Fireflies and writes both onto the matching CalendarEvent.',
},
},
required: ['transcriptId'],
additionalProperties: false,
};
@@ -1,18 +0,0 @@
export type FirefliesCallSummary = {
id: string;
title: string | null;
date: string | null;
durationMinutes: number | null;
participants: string[];
hostEmail: string | null;
transcriptUrl: string | null;
meetingLink: string | null;
};
export type FirefliesCallListResult = {
success: boolean;
message: string;
error?: string;
calls?: FirefliesCallSummary[];
count?: number;
};
@@ -1,4 +0,0 @@
export type FirefliesListCallsByParticipantInput = {
participantEmail: string;
limit?: number;
};
@@ -1,4 +0,0 @@
export type FirefliesSearchCallsInput = {
keyword: string;
limit?: number;
};
@@ -1,3 +0,0 @@
export type FirefliesSyncCallInput = {
transcriptId: string;
};
@@ -1,16 +0,0 @@
import { type FirefliesSyncableField } from 'src/logic-functions/utils/sync-fireflies-field-to-calendar-event';
export type FirefliesSyncCallFieldOutcome =
| { field: FirefliesSyncableField; status: 'updated' }
| { field: FirefliesSyncableField; status: 'skipped'; reason: string }
| { field: FirefliesSyncableField; status: 'error'; error: string };
export type FirefliesSyncCallResult = {
success: boolean;
message: string;
error?: string;
transcriptId?: string;
calendarEventId?: string;
updatedFields?: FirefliesSyncableField[];
fieldOutcomes?: FirefliesSyncCallFieldOutcome[];
};
@@ -1,30 +0,0 @@
export type FirefliesTranscriptSentence = {
speaker_name: string | null;
text: string;
start_time: number | null;
};
export type FirefliesSummary = {
overview: string | null;
action_items: string | null;
keywords: string[] | null;
topics_discussed: string[] | null;
short_summary: string | null;
};
export type FirefliesTranscript = {
id: string;
title: string | null;
duration: number | null;
meeting_link: string | null;
participants: string[];
organizer_email: string | null;
host_email?: string | null;
date?: number | null;
transcript_url?: string | null;
sentences?: FirefliesTranscriptSentence[] | null;
summary?: FirefliesSummary | null;
calendar_id?: string | null;
cal_id?: string | null;
calendar_type?: string | null;
};
@@ -1,18 +0,0 @@
export type FirefliesWebhookPayload = {
event: string;
meeting_id: string;
timestamp?: number;
client_reference_id?: string | null;
};
export type FirefliesSyncedField = 'transcript' | 'summary';
export type FirefliesWebhookResult =
| {
action: 'updated';
field: FirefliesSyncedField;
calendarEventId: string;
meetingId: string;
}
| { skipped: true; reason: string; meetingId?: string }
| { error: string; meetingId?: string };
@@ -1,109 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
type FirefliesSummary,
type FirefliesTranscript,
} from 'src/logic-functions/types/fireflies-transcript.type';
import { formatSummaryAsMarkdown } from 'src/logic-functions/utils/format-summary-as-markdown';
const buildTranscript = (
summary: FirefliesSummary | null | undefined,
): FirefliesTranscript => ({
id: 'abc',
title: 'Test call',
duration: 30,
meeting_link: 'https://zoom.us/j/1234',
participants: ['a@example.com'],
organizer_email: 'a@example.com',
summary,
});
describe('formatSummaryAsMarkdown', () => {
it('returns a placeholder when summary is undefined', () => {
const result = formatSummaryAsMarkdown(buildTranscript(undefined));
expect(result).toContain('Fireflies returned no summary content');
});
it('returns a placeholder when summary is null', () => {
const result = formatSummaryAsMarkdown(buildTranscript(null));
expect(result).toContain('Fireflies returned no summary content');
});
it('returns a placeholder when every summary section is empty', () => {
const result = formatSummaryAsMarkdown(
buildTranscript({
overview: '',
action_items: ' ',
keywords: null,
topics_discussed: null,
short_summary: null,
}),
);
expect(result).toContain('Fireflies returned no summary content');
});
it('renders overview, action items, topics and keywords in order with headers', () => {
const result = formatSummaryAsMarkdown(
buildTranscript({
overview: '- **Item 1:** First point',
action_items: '**Abdul**\nDo something (00:10)',
keywords: ['Twenty', 'Fireflies'],
topics_discussed: ['Integration', 'Roadmap'],
short_summary: 'irrelevant',
}),
);
expect(result).toBe(
[
'## Overview',
'',
'- **Item 1:** First point',
'',
'## Action items',
'',
'**Abdul**\nDo something (00:10)',
'',
'## Topics discussed',
'',
'Integration, Roadmap',
'',
'## Keywords',
'',
'Twenty, Fireflies',
].join('\n'),
);
});
it('omits sections that are empty', () => {
const result = formatSummaryAsMarkdown(
buildTranscript({
overview: 'Some overview',
action_items: null,
keywords: [],
topics_discussed: null,
short_summary: null,
}),
);
expect(result).toBe('## Overview\n\nSome overview');
});
it('trims overview and action_items whitespace', () => {
const result = formatSummaryAsMarkdown(
buildTranscript({
overview: ' \n Overview body \n ',
action_items: '\n**A**\nDo it\n',
keywords: null,
topics_discussed: null,
short_summary: null,
}),
);
expect(result).toBe(
'## Overview\n\nOverview body\n\n## Action items\n\n**A**\nDo it',
);
});
});
@@ -1,87 +0,0 @@
import { describe, expect, it } from 'vitest';
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
import { formatTranscriptAsMarkdown } from 'src/logic-functions/utils/format-transcript-as-markdown';
const buildTranscript = (
sentences: FirefliesTranscript['sentences'],
): FirefliesTranscript => ({
id: 'abc',
title: 'Test call',
date: 1700000000000,
duration: 30,
meeting_link: 'https://zoom.us/j/1234',
participants: ['a@example.com'],
organizer_email: 'a@example.com',
sentences,
});
describe('formatTranscriptAsMarkdown', () => {
it('returns a placeholder when there are no sentences', () => {
const result = formatTranscriptAsMarkdown(buildTranscript([]));
expect(result).toContain('Fireflies returned no transcript content');
});
it('returns a placeholder when sentences is null', () => {
const result = formatTranscriptAsMarkdown(buildTranscript(null));
expect(result).toContain('Fireflies returned no transcript content');
});
it('groups consecutive sentences from the same speaker into one paragraph', () => {
const result = formatTranscriptAsMarkdown(
buildTranscript([
{ speaker_name: 'Sarah', text: 'Hi there', start_time: 0 },
{ speaker_name: 'Sarah', text: 'How are you?', start_time: 1 },
{ speaker_name: 'John', text: 'Doing well, thanks.', start_time: 2 },
]),
);
expect(result).toBe(
'**Sarah:** Hi there How are you?\n\n**John:** Doing well, thanks.',
);
});
it('falls back to "Speaker" when the speaker name is missing', () => {
const result = formatTranscriptAsMarkdown(
buildTranscript([{ speaker_name: null, text: 'Hello', start_time: 0 }]),
);
expect(result).toBe('**Speaker:** Hello');
});
it('falls back to "Speaker" when the speaker name is whitespace-only', () => {
const result = formatTranscriptAsMarkdown(
buildTranscript([
{ speaker_name: ' ', text: 'Hello', start_time: 0 },
{ speaker_name: '\n\t', text: 'World', start_time: 1 },
]),
);
expect(result).toBe('**Speaker:** Hello World');
});
it('skips empty sentence text', () => {
const result = formatTranscriptAsMarkdown(
buildTranscript([
{ speaker_name: 'Sarah', text: ' ', start_time: 0 },
{ speaker_name: 'Sarah', text: 'Hello', start_time: 1 },
]),
);
expect(result).toBe('**Sarah:** Hello');
});
it('returns the placeholder when every sentence is whitespace-only', () => {
const result = formatTranscriptAsMarkdown(
buildTranscript([
{ speaker_name: 'Sarah', text: ' ', start_time: 0 },
{ speaker_name: 'John', text: '\n\t', start_time: 1 },
{ speaker_name: null, text: '', start_time: 2 },
]),
);
expect(result).toContain('Fireflies returned no transcript content');
});
});
@@ -1,101 +0,0 @@
import { createHmac } from 'crypto';
import { describe, expect, it } from 'vitest';
import { verifyFirefliesWebhookSignature } from 'src/logic-functions/utils/verify-fireflies-webhook-signature';
const SECRET = 'test-secret-abc123';
const sign = (body: string): string =>
createHmac('sha256', SECRET).update(body, 'utf8').digest('hex');
describe('verifyFirefliesWebhookSignature', () => {
it('accepts a valid bare-hex signature', () => {
const body = JSON.stringify({ meetingId: 'm1' });
const signature = sign(body);
const result = verifyFirefliesWebhookSignature({
rawBody: body,
signatureHeader: signature,
secret: SECRET,
});
expect(result).toEqual({ valid: true });
});
it('accepts a valid signature with sha256= prefix', () => {
const body = JSON.stringify({ meetingId: 'm1' });
const signature = `sha256=${sign(body)}`;
const result = verifyFirefliesWebhookSignature({
rawBody: body,
signatureHeader: signature,
secret: SECRET,
});
expect(result).toEqual({ valid: true });
});
it('rejects when signature header is missing', () => {
const result = verifyFirefliesWebhookSignature({
rawBody: '{}',
signatureHeader: undefined,
secret: SECRET,
});
expect(result).toEqual({
valid: false,
error: 'Missing x-hub-signature header',
});
});
it('rejects when signature header is empty', () => {
const result = verifyFirefliesWebhookSignature({
rawBody: '{}',
signatureHeader: '',
secret: SECRET,
});
expect(result).toEqual({
valid: false,
error: 'Missing x-hub-signature header',
});
});
it('rejects when the signature was computed from a different body', () => {
const signature = sign(JSON.stringify({ meetingId: 'm1' }));
const result = verifyFirefliesWebhookSignature({
rawBody: JSON.stringify({ meetingId: 'tampered' }),
signatureHeader: signature,
secret: SECRET,
});
expect(result.valid).toBe(false);
});
it('rejects when the signature was computed with a different secret', () => {
const body = JSON.stringify({ meetingId: 'm1' });
const signature = createHmac('sha256', 'other-secret')
.update(body, 'utf8')
.digest('hex');
const result = verifyFirefliesWebhookSignature({
rawBody: body,
signatureHeader: signature,
secret: SECRET,
});
expect(result.valid).toBe(false);
});
it('rejects malformed signature strings', () => {
const result = verifyFirefliesWebhookSignature({
rawBody: '{}',
signatureHeader: 'not-a-real-signature',
secret: SECRET,
});
expect(result.valid).toBe(false);
});
});
@@ -1,60 +0,0 @@
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
import {
firefliesApiRequest,
type FirefliesApiResult,
} from 'src/logic-functions/utils/fireflies-api-request';
const SUMMARY_QUERY = `
query Transcript($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
duration
meeting_link
participants
organizer_email
calendar_id
cal_id
calendar_type
summary {
overview
action_items
keywords
topics_discussed
short_summary
}
}
}
`;
type FirefliesSummaryResponse = {
transcript: FirefliesTranscript | null;
};
export const fetchFirefliesSummary = async ({
apiKey,
transcriptId,
}: {
apiKey: string;
transcriptId: string;
}): Promise<FirefliesApiResult<FirefliesTranscript>> => {
const result = await firefliesApiRequest<FirefliesSummaryResponse>({
apiKey,
query: SUMMARY_QUERY,
variables: { transcriptId },
});
if (!result.ok) {
return result;
}
if (result.data.transcript === null) {
return {
ok: false,
status: 404,
errorMessage: `Fireflies transcript ${transcriptId} not found (may have been deleted or access was revoked)`,
};
}
return { ok: true, status: result.status, data: result.data.transcript };
};
@@ -1,58 +0,0 @@
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
import {
firefliesApiRequest,
type FirefliesApiResult,
} from 'src/logic-functions/utils/fireflies-api-request';
const TRANSCRIPT_QUERY = `
query Transcript($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
duration
meeting_link
participants
organizer_email
calendar_id
cal_id
calendar_type
sentences {
speaker_name
text
start_time
}
}
}
`;
type FirefliesTranscriptResponse = {
transcript: FirefliesTranscript | null;
};
export const fetchFirefliesTranscript = async ({
apiKey,
transcriptId,
}: {
apiKey: string;
transcriptId: string;
}): Promise<FirefliesApiResult<FirefliesTranscript>> => {
const result = await firefliesApiRequest<FirefliesTranscriptResponse>({
apiKey,
query: TRANSCRIPT_QUERY,
variables: { transcriptId },
});
if (!result.ok) {
return result;
}
if (result.data.transcript === null) {
return {
ok: false,
status: 404,
errorMessage: `Fireflies transcript ${transcriptId} not found (may have been deleted or access was revoked)`,
};
}
return { ok: true, status: result.status, data: result.data.transcript };
};
@@ -1,117 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-shared/utils';
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
type CalendarChannelEventAssociationsConnection = {
edges: Array<{ node: { calendarEventId: string } }>;
};
type CalendarEventsConnection = {
edges: Array<{ node: { id: string } }>;
};
const findByEventExternalId = async (
client: CoreApiClient,
externalIds: string[],
): Promise<string | null> => {
if (externalIds.length === 0) {
return null;
}
const { calendarChannelEventAssociations } = await client.query({
calendarChannelEventAssociations: {
__args: {
filter: {
eventExternalId: { in: externalIds },
},
first: 1,
},
edges: {
node: {
calendarEventId: true,
},
},
},
});
const node = (
calendarChannelEventAssociations as
| CalendarChannelEventAssociationsConnection
| undefined
)?.edges?.[0]?.node;
return node?.calendarEventId ?? null;
};
const findByICalUid = async (
client: CoreApiClient,
iCalUid: string,
): Promise<string | null> => {
const { calendarEvents } = await client.query({
calendarEvents: {
__args: {
filter: {
iCalUid: { eq: iCalUid },
},
first: 1,
},
edges: {
node: {
id: true,
},
},
},
});
const node = (calendarEvents as CalendarEventsConnection | undefined)
?.edges?.[0]?.node;
return node?.id ?? null;
};
export const findMatchingCalendarEvent = async ({
client,
transcript,
}: {
client: CoreApiClient;
transcript: FirefliesTranscript;
}): Promise<
| {
matched: true;
calendarEventId: string;
matchedBy: 'externalId' | 'iCalUid';
}
| { matched: false; reason: string }
> => {
const externalIdCandidates = [
transcript.calendar_id,
transcript.cal_id,
].filter(isNonEmptyString);
if (externalIdCandidates.length > 0) {
const calendarEventId = await findByEventExternalId(
client,
externalIdCandidates,
);
if (isDefined(calendarEventId)) {
return { matched: true, calendarEventId, matchedBy: 'externalId' };
}
}
if (isNonEmptyString(transcript.calendar_id)) {
const calendarEventId = await findByICalUid(client, transcript.calendar_id);
if (isDefined(calendarEventId)) {
return { matched: true, calendarEventId, matchedBy: 'iCalUid' };
}
}
return {
matched: false,
reason:
'No CalendarEvent matched the transcript by external ID or iCalUid. Either the meeting was never on a synced calendar, or its calendar sync (Google/Outlook/CalDAV) is not configured in Twenty. Orphan calls are skipped in v1.',
};
};
@@ -1,122 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
const FIREFLIES_API_URL = 'https://api.fireflies.ai/graphql';
type FirefliesApiSuccess<TData> = {
ok: true;
status: number;
data: TData;
};
type FirefliesApiFailure = {
ok: false;
status: number;
errorMessage: string;
};
export type FirefliesApiResult<TData> =
| FirefliesApiSuccess<TData>
| FirefliesApiFailure;
type FirefliesGraphqlError = {
message?: string;
extensions?: {
code?: string;
};
};
type FirefliesGraphqlEnvelope<TData> = {
data?: TData;
errors?: FirefliesGraphqlError[];
};
type FirefliesApiRequestParams = {
apiKey: string;
query: string;
variables?: Record<string, unknown>;
};
export const firefliesApiRequest = async <TData = unknown>({
apiKey,
query,
variables,
}: FirefliesApiRequestParams): Promise<FirefliesApiResult<TData>> => {
let response: Response;
try {
response = await fetch(FIREFLIES_API_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables }),
});
} catch (error) {
return {
ok: false,
status: 0,
errorMessage: `Fireflies API request failed: ${(error as Error).message}`,
};
}
let envelope: FirefliesGraphqlEnvelope<TData> | null = null;
let parseError: Error | null = null;
try {
envelope = (await response.json()) as FirefliesGraphqlEnvelope<TData>;
} catch (error) {
parseError = error as Error;
}
if (
envelope !== null &&
isDefined(envelope.errors) &&
envelope.errors.length > 0
) {
return {
ok: false,
status: response.status,
errorMessage: `Fireflies GraphQL error: ${formatFirefliesGraphqlError(
envelope.errors[0],
)}`,
};
}
if (!response.ok) {
return {
ok: false,
status: response.status,
errorMessage: `Fireflies API responded with HTTP ${response.status}`,
};
}
if (parseError !== null) {
return {
ok: false,
status: response.status,
errorMessage: `Fireflies API returned a non-JSON response: ${parseError.message}`,
};
}
if (envelope === null || !isDefined(envelope.data)) {
return {
ok: false,
status: response.status,
errorMessage: 'Fireflies GraphQL response was missing a `data` field',
};
}
return { ok: true, status: response.status, data: envelope.data };
};
const formatFirefliesGraphqlError = (
error: FirefliesGraphqlError | undefined,
): string => {
const message = error?.message ?? 'Unknown Fireflies GraphQL error';
const code = error?.extensions?.code;
return isDefined(code)
? `${message} (Fireflies error code ${code})`
: message;
};
@@ -1,43 +0,0 @@
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
const EMPTY_SUMMARY_MESSAGE =
'_Fireflies returned no summary content for this meeting._';
export const formatSummaryAsMarkdown = (
transcript: FirefliesTranscript,
): string => {
const summary = transcript.summary;
if (!isDefined(summary)) {
return EMPTY_SUMMARY_MESSAGE;
}
const sections: string[] = [];
if (isNonEmptyString(summary.overview?.trim())) {
sections.push(`## Overview\n\n${summary.overview.trim()}`);
}
if (isNonEmptyString(summary.action_items?.trim())) {
sections.push(`## Action items\n\n${summary.action_items.trim()}`);
}
if (isNonEmptyArray(summary.topics_discussed)) {
sections.push(
`## Topics discussed\n\n${summary.topics_discussed.join(', ')}`,
);
}
if (isNonEmptyArray(summary.keywords)) {
sections.push(`## Keywords\n\n${summary.keywords.join(', ')}`);
}
if (sections.length === 0) {
return EMPTY_SUMMARY_MESSAGE;
}
return sections.join('\n\n');
};
@@ -1,57 +0,0 @@
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
const UNKNOWN_SPEAKER_LABEL = 'Speaker';
const EMPTY_TRANSCRIPT_MESSAGE =
'_Fireflies returned no transcript content for this meeting._';
export const formatTranscriptAsMarkdown = (
transcript: FirefliesTranscript,
): string => {
const sentences = transcript.sentences ?? [];
const lines: string[] = [];
let currentSpeaker: string | null = null;
let currentLines: string[] = [];
const flush = () => {
if (!isNonEmptyArray(currentLines)) {
return;
}
const speakerLabel = isDefined(currentSpeaker)
? currentSpeaker
: UNKNOWN_SPEAKER_LABEL;
lines.push(`**${speakerLabel}:** ${currentLines.join(' ')}`);
currentLines = [];
};
for (const sentence of sentences) {
const text = sentence.text.trim();
if (!isNonEmptyString(text)) {
continue;
}
const trimmedSpeaker = sentence.speaker_name?.trim();
const speaker = isNonEmptyString(trimmedSpeaker) ? trimmedSpeaker : null;
if (speaker !== currentSpeaker) {
flush();
currentSpeaker = speaker;
}
currentLines.push(text);
}
flush();
if (!isNonEmptyArray(lines)) {
return EMPTY_TRANSCRIPT_MESSAGE;
}
return lines.join('\n\n');
};
@@ -1,19 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
export const FIREFLIES_API_KEY_ENV_VAR = 'FIREFLIES_API_KEY';
export const getFirefliesApiKey = ():
| { success: true; apiKey: string }
| { success: false; error: string } => {
const apiKey = process.env[FIREFLIES_API_KEY_ENV_VAR];
if (!isNonEmptyString(apiKey)) {
return {
success: false,
error:
'Fireflies is not configured. Open the Twenty Fireflies app settings and set the FIREFLIES_API_KEY application variable (Fireflies → Integrations → Fireflies API → Generate API key).',
};
}
return { success: true, apiKey };
};
@@ -1,19 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
export const FIREFLIES_WEBHOOK_SECRET_ENV_VAR = 'FIREFLIES_WEBHOOK_SECRET';
export const getFirefliesWebhookSecret = ():
| { success: true; secret: string }
| { success: false; error: string } => {
const secret = process.env[FIREFLIES_WEBHOOK_SECRET_ENV_VAR];
if (!isNonEmptyString(secret)) {
return {
success: false,
error:
'FIREFLIES_WEBHOOK_SECRET application variable is not set. Set it in Twenty Fireflies app settings, then configure the same value on the Fireflies side when registering the webhook URL.',
};
}
return { success: true, secret };
};
@@ -1,101 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
import { type FirefliesCallSummary } from 'src/logic-functions/types/fireflies-call-list-result.type';
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
import {
firefliesApiRequest,
type FirefliesApiResult,
} from 'src/logic-functions/utils/fireflies-api-request';
const TRANSCRIPTS_QUERY = `
query Transcripts(
$keyword: String
$scope: String
$participants: [String!]
$fromDate: DateTime
$toDate: DateTime
$limit: Int
) {
transcripts(
keyword: $keyword
scope: $scope
participants: $participants
fromDate: $fromDate
toDate: $toDate
limit: $limit
) {
id
title
date
duration
participants
host_email
transcript_url
meeting_link
}
}
`;
type TranscriptsResponse = {
transcripts: FirefliesTranscript[] | null;
};
export type FirefliesKeywordScope = 'title' | 'sentences' | 'all';
export type ListFirefliesTranscriptsArgs = {
apiKey: string;
keyword?: string;
keywordScope?: FirefliesKeywordScope;
participants?: string[];
fromDate?: string;
toDate?: string;
limit?: number;
};
const toCallSummary = (transcript: FirefliesTranscript): FirefliesCallSummary => ({
id: transcript.id,
title: transcript.title,
date: isDefined(transcript.date) ? new Date(transcript.date).toISOString() : null,
durationMinutes: transcript.duration,
participants: transcript.participants,
hostEmail: transcript.host_email ?? transcript.organizer_email ?? null,
transcriptUrl: transcript.transcript_url ?? null,
meetingLink: transcript.meeting_link,
});
export const listFirefliesTranscripts = async ({
apiKey,
keyword,
keywordScope,
participants,
fromDate,
toDate,
limit,
}: ListFirefliesTranscriptsArgs): Promise<
FirefliesApiResult<FirefliesCallSummary[]>
> => {
const result = await firefliesApiRequest<TranscriptsResponse>({
apiKey,
query: TRANSCRIPTS_QUERY,
variables: {
keyword,
scope: keywordScope,
participants,
fromDate,
toDate,
limit,
},
});
if (!result.ok) {
return result;
}
const transcripts = result.data.transcripts ?? [];
return {
ok: true,
status: result.status,
data: transcripts.map(toCallSummary),
};
};
@@ -1,102 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
import { fetchFirefliesSummary } from 'src/logic-functions/utils/fetch-fireflies-summary';
import { fetchFirefliesTranscript } from 'src/logic-functions/utils/fetch-fireflies-transcript';
import { type FirefliesApiResult } from 'src/logic-functions/utils/fireflies-api-request';
import { findMatchingCalendarEvent } from 'src/logic-functions/utils/find-matching-calendar-event';
import { formatSummaryAsMarkdown } from 'src/logic-functions/utils/format-summary-as-markdown';
import { formatTranscriptAsMarkdown } from 'src/logic-functions/utils/format-transcript-as-markdown';
import { updateCalendarEventSummary } from 'src/logic-functions/utils/update-calendar-event-summary';
import { updateCalendarEventTranscript } from 'src/logic-functions/utils/update-calendar-event-transcript';
export type FirefliesSyncableField = 'transcript' | 'summary';
export type SyncFirefliesFieldResult =
| {
status: 'updated';
field: FirefliesSyncableField;
calendarEventId: string;
}
| { status: 'skipped'; field: FirefliesSyncableField; reason: string }
| { status: 'error'; field: FirefliesSyncableField; error: string };
type FieldSyncStrategy = {
fetch: (args: {
apiKey: string;
transcriptId: string;
}) => Promise<FirefliesApiResult<FirefliesTranscript>>;
format: (transcript: FirefliesTranscript) => string;
update: (args: {
client: CoreApiClient;
calendarEventId: string;
markdown: string;
}) => Promise<void>;
};
const FIELD_SYNC_STRATEGIES: Record<FirefliesSyncableField, FieldSyncStrategy> =
{
transcript: {
fetch: fetchFirefliesTranscript,
format: formatTranscriptAsMarkdown,
update: updateCalendarEventTranscript,
},
summary: {
fetch: fetchFirefliesSummary,
format: formatSummaryAsMarkdown,
update: updateCalendarEventSummary,
},
};
export const syncFirefliesFieldToCalendarEvent = async ({
apiKey,
client,
transcriptId,
field,
}: {
apiKey: string;
client: CoreApiClient;
transcriptId: string;
field: FirefliesSyncableField;
}): Promise<SyncFirefliesFieldResult> => {
const strategy = FIELD_SYNC_STRATEGIES[field];
const fetchResult = await strategy.fetch({ apiKey, transcriptId });
if (!fetchResult.ok) {
return { status: 'error', field, error: fetchResult.errorMessage };
}
const match = await findMatchingCalendarEvent({
client,
transcript: fetchResult.data,
});
if (!match.matched) {
return { status: 'skipped', field, reason: match.reason };
}
const markdown = strategy.format(fetchResult.data);
try {
await strategy.update({
client,
calendarEventId: match.calendarEventId,
markdown,
});
} catch (error) {
return {
status: 'error',
field,
error: `Failed to update CalendarEvent ${match.calendarEventId} ${field}: ${
(error as Error).message
}`,
};
}
return {
status: 'updated',
field,
calendarEventId: match.calendarEventId,
};
};
@@ -1,26 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
export const updateCalendarEventSummary = async ({
client,
calendarEventId,
markdown,
}: {
client: CoreApiClient;
calendarEventId: string;
markdown: string;
}): Promise<void> => {
await client.mutation({
updateCalendarEvent: {
__args: {
id: calendarEventId,
data: {
summary: {
markdown,
blocknote: null,
},
},
},
id: true,
},
});
};
@@ -1,26 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
export const updateCalendarEventTranscript = async ({
client,
calendarEventId,
markdown,
}: {
client: CoreApiClient;
calendarEventId: string;
markdown: string;
}): Promise<void> => {
await client.mutation({
updateCalendarEvent: {
__args: {
id: calendarEventId,
data: {
transcript: {
markdown,
blocknote: null,
},
},
},
id: true,
},
});
};
@@ -1,49 +0,0 @@
import { createHmac, timingSafeEqual } from 'crypto';
import { isNonEmptyString } from '@sniptt/guards';
const SIGNATURE_PREFIX = 'sha256=';
const stripPrefix = (signature: string): string =>
signature.startsWith(SIGNATURE_PREFIX)
? signature.slice(SIGNATURE_PREFIX.length)
: signature;
export const verifyFirefliesWebhookSignature = ({
rawBody,
signatureHeader,
secret,
}: {
rawBody: string;
signatureHeader: string | undefined;
secret: string;
}): { valid: true } | { valid: false; error: string } => {
if (!isNonEmptyString(signatureHeader)) {
return { valid: false, error: 'Missing x-hub-signature header' };
}
const provided = stripPrefix(signatureHeader.trim()).toLowerCase();
const expected = createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex');
if (provided.length !== expected.length) {
return { valid: false, error: 'Signature length mismatch' };
}
const providedBuffer = Buffer.from(provided, 'hex');
const expectedBuffer = Buffer.from(expected, 'hex');
if (
providedBuffer.length === 0 ||
providedBuffer.length !== expectedBuffer.length
) {
return { valid: false, error: 'Malformed signature' };
}
if (!timingSafeEqual(providedBuffer, expectedBuffer)) {
return { valid: false, error: 'Signature verification failed' };
}
return { valid: true };
};
@@ -1,42 +0,0 @@
import {
defineApplicationRole,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Twenty Fireflies sync role',
description:
'Reads CalendarEvent and CalendarChannelEventAssociation to locate the meeting matching a Fireflies call (from an incoming webhook or from the on-demand Sync Fireflies Call workflow tool), and updates that CalendarEvent to write the synced transcript and summary fields. The list / search workflow tools only call the Fireflies API and do not require any Twenty object permissions.',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarChannelEventAssociation
.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [],
permissionFlags: [],
});
@@ -1,20 +0,0 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { SUMMARY_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: SUMMARY_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
type: FieldType.RICH_TEXT,
name: 'summary',
label: 'Summary',
description:
'AI-generated meeting summary synced from Fireflies. Includes overview, action items, and keywords. Populated automatically when Fireflies finishes summarizing a recording that matches this calendar event.',
icon: 'IconNotes',
isNullable: true,
});
@@ -1,20 +0,0 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { TRANSCRIPT_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: TRANSCRIPT_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
type: FieldType.RICH_TEXT,
name: 'transcript',
label: 'Transcript',
description:
'Call transcript synced from Fireflies. Populated automatically when a Fireflies recording finishes processing and matches this calendar event.',
icon: 'IconMicrophone',
isNullable: true,
});
@@ -1,31 +0,0 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2020",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
@@ -1,7 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
});
File diff suppressed because it is too large Load Diff
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -64,15 +64,15 @@ cd packages/twenty-apps/internal/twenty-linear
# For day-to-day development (publish + install + watch in one):
yarn twenty dev
# Manual publish flow (publish registers the app, install activates it):
yarn twenty app:publish --private
yarn twenty app:install
# Manual publish flow (deploy registers the app, install activates it):
yarn twenty deploy
yarn twenty install
```
`twenty dev` is recommended for iteration — it publishes, installs, and
watches for changes in one command. Use `twenty app:publish --private` +
`twenty app:install` when you want to control each step separately (e.g.
deploying to a production server without auto-installing).
watches for changes in one command. Use `twenty deploy` + `twenty install`
when you want to control each step separately (e.g. deploying to a
production server without auto-installing).
This serves as the reference implementation for Twenty's
`defineConnectionProvider({ type: 'oauth' })` flow — useful as a template
@@ -1,18 +0,0 @@
{
"$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": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}
@@ -1,126 +0,0 @@
# twenty-slack
Slack tools for **Twenty workflows** and **agents** (the same logic functions
are available as workflow steps and as tools where your deployment exposes
them). Uses the official
[`@slack/web-api`](https://github.com/slackapi/node-slack-sdk) `WebClient`
(Slack retries and error types).
## What you can do
Once the app is installed and Slack is **connected** (see **Twenty setup**
below):
- **Workflow steps** — post, update, or delete bot messages; send ephemerals;
add reactions; list channels. Pick a **workspace shared** or **just for me**
connection; steps run with that token.
- **Agents / AI** — when your Twenty instance surfaces app tools to the model,
these functions can be invoked the same way as other app logic functions.
- **Quick-send** — command menu **Send Slack message** opens a side panel to
pick a channel and post (same Slack connection as workflows).
## Tools
| Name | Slack API |
|------|-----------|
| `slack-post-message` | `chat.postMessage` |
| `slack-post-ephemeral-message` | `chat.postEphemeral` |
| `slack-update-message` | `chat.update` |
| `slack-delete-message` | `chat.delete` |
| `slack-add-reaction` | `reactions.add` |
| `slack-list-channels` | `conversations.list` |
### Workflow field names (for authors)
Fields use camelCase names in the step UI, for example **`slackChannelId`** (Slack channel or DM: **name** or **ID**), **`messageText`**, and **`messageTimestamp`** (Slacks per-message id — same value as tool output **`slackTs`** when chaining steps). Optional **`parentMessageTimestamp`** is only for **thread replies**. Post / update / ephemeral steps support optional **`messageFormat`**: **`markdown`** sends the body as Slack **`markdown_text`** (e.g. **`**bold**`**), **`plain`** sends **`text`** with markup disabled, omit uses Slacks default for **`text`**. Ephemeral steps use **`recipientSlackUserId`**; reactions use **`emojiName`** (Slack shortcode, for example `white_check_mark`). Updating a message uses **`newMessageText`**.
### Quick-send command menu item
This app also ships a global command menu item — **Send Slack message** — that opens a side-panel form to pick a channel (from `conversations.list`) and post a message via `chat.postMessage`. The form is backed by two HTTP routes exposed by the app:
- `GET /slack/channels` — lists channels visible to the bot (mirrors `slack-list-channels`).
- `POST /slack/messages` — posts a message (mirrors `slack-post-message`).
Both routes require an authenticated Twenty user and use the same shared Slack connection as the workflow tools.
### Prerequisites (Slack workspace)
- You can **install** the Slack app on a workspace you administer (or get an
admin to approve it).
- For **posting**: invite the bot to the channel, **or** rely on
**`chat:write.public`** (included in OAuth) to post to **public** channels
without joining — private channels still require membership.
- **`slack-list-channels`** and the quick-send channel picker need
**`channels:read`** / **`groups:read`** on the token (requested at connect
time; see below).
## Slack app setup
1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps)
(dedicated to this Twenty app — do not reuse for other Twenty apps).
2. **OAuth & Permissions****Bot Token Scopes**. Twenty uses Slacks **bot**
OAuth (`oauth/v2/authorize` with `scope=…`). You must add scopes here — not
only under **User Token Scopes** — or Slack will refuse install with *“doesnt
have a bot user to install”* until at least one bot scope exists.
The scopes **requested at connect time** are defined in
`src/connection-providers/slack-connection.ts` and must also appear under
**Bot Token Scopes** on the Slack app (Slack validates the set). Current
list:
- `channels:read``conversations.list` / channel picker (public)
- `chat:write` — post, update, delete, ephemeral
- `chat:write.public` — post to public channels without the bot joining
- `groups:read` — list private channels the bot is in
- `reactions:write` — add reactions
If you **add or remove** scopes in that file or in the Slack app, existing
installs must **re-authorize** (disconnect and **Add connection** again, or
reinstall the Slack app to the workspace) so the token picks up new scopes.
3. Set the **Redirect URL** on the Slack app to
`<YOUR_TWENTY_SERVER_URL>/apps/oauth/callback` — the same origin your
Twenty **server** uses for API routes (the callback is not served by the SPA
alone). Local monorepo dev often uses `http://localhost:3000` (confirm the
port your `twenty-server` / `SERVER_URL` actually uses).
**Slack “PKCE” app setting vs `localhost`:** If you turn on **PKCE** for the
Slack app under **OAuth & Permissions**, Slack treats `http://localhost…`
redirect URLs as **desktop** redirects. **Desktop redirects cannot request
bot scopes**, so OAuth will fail for this integration while you use a
localhost callback. For local dev you can either **leave Slacks PKCE
opt-in disabled** on that Slack app, or use an **`https://` redirect** (for
example a tunnel such as ngrok or Cloudflare Tunnel to your local server),
register that URL in the Slack app, and point Twentys `SERVER_URL` at the
same public base URL. See Slacks [Using
PKCE](https://docs.slack.dev/authentication/using-pkce) docs (this is
separate from Twenty sending a PKCE challenge on the authorize request).
4. Copy the Slack **Client ID** and **Client Secret**.
## Twenty setup
1. Register / install this app on your Twenty server (`twenty-slack`).
2. In **Settings → Applications → Twenty Slack**, open the **Application registration**
tab (admin-only) and set:
- `SLACK_CLIENT_ID`
- `SLACK_CLIENT_SECRET`
3. In the same app, open the **Connections** tab and click **Add connection**.
Choose **Just for me** or **Workspace shared**, then complete the Slack sign-in.
Once connected, workflow steps use the connection access token: a
**workspace** connection is preferred when present; otherwise the first
connection returned for the Slack provider is used (see
`src/logic-functions/utils/get-slack-connection.ts`).
## Development
```bash
cd packages/twenty-apps/internal/twenty-slack
yarn install
yarn lint
yarn test
```
Use `yarn twenty dev` from this directory to develop against a local Twenty
instance (see other internal apps in this monorepo).
@@ -1,35 +0,0 @@
{
"name": "twenty-slack",
"version": "0.1.0",
"description": "Slack workflow connector for Twenty",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@slack/web-api": "^7.8.0",
"twenty-sdk": "2.4.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^18.2.0",
"oxlint": "^0.16.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.9.3",
"vitest": "^3.1.1"
}
}
@@ -1,26 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 127 127"
fill="none"
role="img"
>
<title>Slack</title>
<path
fill="#E01E5A"
d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z"
/>
<path
fill="#36C5F0"
d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z"
/>
<path
fill="#2EB67D"
d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z"
/>
<path
fill="#ECB22E"
d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,37 +0,0 @@
import { defineApplication } from 'twenty-sdk/define';
import {
APPLICATION_UNIVERSAL_IDENTIFIER,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Twenty Slack',
description:
'Connect Slack to Twenty. Each workspace member (or a shared workspace connection) can authenticate Slack; workflow steps then post messages, ephemerals, updates, deletes, and reactions on behalf of that connection.',
logoUrl: 'public/twenty-slack.svg',
author: 'Twenty',
category: 'Communication',
aboutDescription:
'Official Slack connector for Twenty CRM. Install a Slack app on api.slack.com, add the OAuth client ID and secret as server variables, then connect Slack per member or as a shared workspace connection. Use workflow actions to post, update, or delete messages, send ephemeral notes, and add reactions using the connected account.',
websiteUrl: 'https://docs.twenty.com/developers/extend/apps/getting-started',
termsUrl: 'https://www.twenty.com/terms',
emailSupport: 'contact@twenty.com',
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
serverVariables: {
SLACK_CLIENT_ID: {
description:
'OAuth client ID from your Slack app (api.slack.com/apps). Public in OAuth flows; only the client secret must stay confidential.',
isSecret: false,
isRequired: true,
},
SLACK_CLIENT_SECRET: {
description:
'OAuth client secret from your Slack app. Stored encrypted; never exposed in API responses.',
isSecret: true,
isRequired: true,
},
},
});
@@ -1,17 +0,0 @@
import { defineCommandMenuItem } from 'twenty-sdk/define';
import {
SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
SEND_SLACK_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineCommandMenuItem({
universalIdentifier: SEND_SLACK_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER,
label: 'Send Slack message',
shortLabel: 'Slack message',
icon: 'IconBrandSlack',
isPinned: false,
availabilityType: 'GLOBAL',
frontComponentUniversalIdentifier:
SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
});
@@ -1,673 +0,0 @@
import {
useCallback,
useEffect,
useState,
type CSSProperties,
type SyntheticEvent,
} from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
closeSidePanel,
enqueueSnackbar,
unmountFrontComponent,
} from 'twenty-sdk/front-component';
import { themeCssVariables } from 'twenty-sdk/ui';
import { SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
type SlackChannel = {
id: string;
name: string;
isPrivate: boolean;
isArchived: boolean;
isMember: boolean;
numMembers: number;
topic: string;
purpose: string;
};
type ListChannelsResponse = {
success: boolean;
channels?: SlackChannel[];
error?: string;
message?: string;
};
type PostMessageResponse = {
success: boolean;
slackTs?: string;
error?: string;
message?: string;
};
type PostedMessage = {
channelId: string;
channelName: string;
slackTs?: string;
};
type MessageFormat = 'plain' | 'markdown';
const FORMAT_OPTIONS: { value: MessageFormat; label: string }[] = [
{ value: 'plain', label: 'Plain text' },
{ value: 'markdown', label: 'Markdown' },
];
const isMessageFormat = (value: string): value is MessageFormat =>
value === 'plain' || value === 'markdown';
const readSerializedValue = (
event: SyntheticEvent<HTMLElement>,
): string | undefined => {
const object = event as {
detail?: { value?: string };
value?: string;
target?: { value?: string };
};
if (typeof object.detail?.value === 'string') {
return object.detail.value;
}
if (typeof object.value === 'string') {
return object.value;
}
if (typeof object.target?.value === 'string') {
return object.target.value;
}
return undefined;
};
const onValueChange =
(setValue: (value: string) => void) =>
(event: SyntheticEvent<HTMLElement>) => {
const value = readSerializedValue(event);
if (typeof value === 'string') {
setValue(value);
}
};
const callAppRoute = async <TResponse,>(
path: string,
method: 'GET' | 'POST',
body?: Record<string, unknown>,
): Promise<TResponse> => {
const apiBaseUrl = process.env.TWENTY_API_URL;
const token =
process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
if (!apiBaseUrl || !token) {
throw new Error('App is missing API URL or access token configuration.');
}
const response = await fetch(`${apiBaseUrl}/s${path}`, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
...(body ? { body: JSON.stringify(body) } : {}),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
let errorMessage: string | undefined;
try {
const parsed = JSON.parse(text) as {
messages?: string[];
message?: string;
error?: string;
};
errorMessage = parsed.messages?.[0] ?? parsed.message ?? parsed.error;
} catch {
// Body is not JSON; fall through to raw text or status message.
}
throw new Error(
errorMessage ??
(text.length > 0
? text.slice(0, 200)
: `Request failed with status ${response.status}.`),
);
}
return response.json() as Promise<TResponse>;
};
const sortChannels = (channels: SlackChannel[]): SlackChannel[] =>
[...channels].sort((a, b) => {
if (a.isMember !== b.isMember) {
return a.isMember ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
const formatChannelOptionLabel = (channel: SlackChannel): string => {
const prefix = channel.isPrivate ? '🔒' : '#';
const suffix = channel.isMember ? '' : ' — bot is not a member';
return `${prefix} ${channel.name}${suffix}`;
};
const getChannelHelperText = (
selectedChannel: SlackChannel | undefined,
): string => {
if (selectedChannel === undefined) {
return 'Pick a channel to post to.';
}
if (selectedChannel.isMember) {
return selectedChannel.isPrivate
? 'Private channel · bot is a member.'
: 'Public channel · bot is a member.';
}
return 'Bot is not a member of this channel — it must be invited before it can post.';
};
const getStyles = (): Record<string, CSSProperties> => ({
container: {
fontFamily: themeCssVariables.font.family,
fontSize: themeCssVariables.font.size.sm,
color: themeCssVariables.font.color.primary,
background: themeCssVariables.background.primary,
display: 'flex',
flexDirection: 'column',
height: '100%',
boxSizing: 'border-box',
},
header: {
padding: themeCssVariables.spacing[4],
borderBottom: `1px solid ${themeCssVariables.border.color.light}`,
flexShrink: 0,
},
headerTitleBlock: {
marginBottom: themeCssVariables.spacing[4],
},
pageTitle: {
fontSize: themeCssVariables.font.size.md,
fontWeight: themeCssVariables.font.weight.semiBold,
color: themeCssVariables.font.color.primary,
margin: 0,
},
pageSubtitle: {
fontSize: themeCssVariables.font.size.md,
fontWeight: themeCssVariables.font.weight.regular,
color: themeCssVariables.font.color.tertiary,
margin: 0,
marginTop: themeCssVariables.spacing[2],
lineHeight: 1.5,
},
body: {
flex: 1,
minHeight: 0,
padding: themeCssVariables.spacing[4],
display: 'flex',
flexDirection: 'column',
gap: themeCssVariables.spacing[4],
overflowY: 'auto',
},
field: {
display: 'flex',
flexDirection: 'column',
gap: themeCssVariables.spacing[1],
},
fieldGrowing: {
display: 'flex',
flexDirection: 'column',
gap: themeCssVariables.spacing[1],
flex: 1,
minHeight: 0,
},
label: {
fontSize: themeCssVariables.font.size.xs,
fontWeight: themeCssVariables.font.weight.medium,
color: themeCssVariables.font.color.secondary,
},
helperText: {
fontSize: themeCssVariables.font.size.xs,
color: themeCssVariables.font.color.tertiary,
},
select: {
appearance: 'none',
WebkitAppearance: 'none',
background: themeCssVariables.background.secondary,
border: `1px solid ${themeCssVariables.border.color.medium}`,
borderRadius: themeCssVariables.border.radius.sm,
padding: `${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[3]}`,
color: themeCssVariables.font.color.primary,
fontSize: themeCssVariables.font.size.sm,
fontFamily: themeCssVariables.font.family,
height: themeCssVariables.spacing[8],
cursor: 'pointer',
outline: 'none',
width: '100%',
boxSizing: 'border-box',
},
textarea: {
background: themeCssVariables.background.secondary,
border: `1px solid ${themeCssVariables.border.color.medium}`,
borderRadius: themeCssVariables.border.radius.sm,
padding: themeCssVariables.spacing[3],
color: themeCssVariables.font.color.primary,
fontSize: themeCssVariables.font.size.sm,
fontFamily: themeCssVariables.font.family,
lineHeight: 1.5,
width: '100%',
boxSizing: 'border-box',
outline: 'none',
resize: 'none',
flex: 1,
minHeight: themeCssVariables.spacing[20],
},
footer: {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: themeCssVariables.spacing[2],
padding: themeCssVariables.spacing[3],
borderTop: `1px solid ${themeCssVariables.border.color.light}`,
flexShrink: 0,
},
buttonBase: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: themeCssVariables.spacing[1],
height: themeCssVariables.spacing[8],
padding: `0 ${themeCssVariables.spacing[3]}`,
borderRadius: themeCssVariables.border.radius.sm,
fontSize: themeCssVariables.font.size.sm,
fontFamily: themeCssVariables.font.family,
fontWeight: themeCssVariables.font.weight.medium,
cursor: 'pointer',
border: '1px solid transparent',
boxSizing: 'border-box',
},
secondaryButton: {
background: themeCssVariables.background.secondary,
color: themeCssVariables.font.color.secondary,
border: `1px solid ${themeCssVariables.border.color.medium}`,
},
primaryButton: {
background: themeCssVariables.color.blue,
color: themeCssVariables.font.color.inverted,
},
primaryButtonDisabled: {
background: themeCssVariables.accent.accent4060,
cursor: 'not-allowed',
},
centeredState: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: themeCssVariables.spacing[4],
height: '100%',
boxSizing: 'border-box',
},
stateBlock: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: themeCssVariables.spacing[3],
maxWidth: '320px',
textAlign: 'center',
},
stateTitle: {
fontSize: themeCssVariables.font.size.md,
fontWeight: themeCssVariables.font.weight.medium,
color: themeCssVariables.font.color.primary,
margin: 0,
},
stateDescription: {
fontSize: themeCssVariables.font.size.sm,
color: themeCssVariables.font.color.tertiary,
margin: 0,
lineHeight: 1.5,
},
stateError: {
fontSize: themeCssVariables.font.size.sm,
color: themeCssVariables.font.color.danger,
margin: 0,
lineHeight: 1.5,
},
});
const SendMessageForm = () => {
const [channels, setChannels] = useState<SlackChannel[]>([]);
const [channelsLoading, setChannelsLoading] = useState(true);
const [channelsError, setChannelsError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [postedMessage, setPostedMessage] = useState<PostedMessage | null>(
null,
);
const [channelId, setChannelId] = useState('');
const [messageText, setMessageText] = useState('');
const [messageFormat, setMessageFormat] = useState<MessageFormat>('markdown');
const styles = getStyles();
const fetchChannels = useCallback(async () => {
try {
setChannelsError(null);
setChannelsLoading(true);
const result = await callAppRoute<ListChannelsResponse>(
'/slack/channels?limit=200',
'GET',
);
if (!result.success) {
setChannelsError(
result.error ?? result.message ?? 'Failed to load Slack channels.',
);
return;
}
const sorted = sortChannels(result.channels ?? []);
setChannels(sorted);
const firstMemberChannel = sorted.find((channel) => channel.isMember);
if (firstMemberChannel !== undefined) {
setChannelId(firstMemberChannel.id);
} else if (sorted.length === 1) {
setChannelId(sorted[0].id);
}
} catch (error) {
setChannelsError(
error instanceof Error
? error.message
: 'Failed to load Slack channels.',
);
} finally {
setChannelsLoading(false);
}
}, []);
useEffect(() => {
fetchChannels();
}, [fetchChannels]);
const handleClose = () => {
unmountFrontComponent();
closeSidePanel();
};
const handleSendAnother = () => {
setMessageText('');
setPostedMessage(null);
};
const handleSubmit = async () => {
const trimmedMessage = messageText.trim();
if (channelId === '' || trimmedMessage === '') {
return;
}
setSubmitting(true);
try {
const result = await callAppRoute<PostMessageResponse>(
'/slack/messages',
'POST',
{
slackChannelId: channelId,
messageText: trimmedMessage,
messageFormat,
},
);
if (!result.success) {
await enqueueSnackbar({
message:
result.error ?? result.message ?? 'Failed to send Slack message.',
variant: 'error',
});
return;
}
const channel = channels.find(
(channelItem) => channelItem.id === channelId,
);
setPostedMessage({
channelId,
channelName: channel?.name ?? channelId,
slackTs: result.slackTs,
});
await enqueueSnackbar({
message: `Message sent to #${channel?.name ?? channelId}`,
variant: 'success',
});
} catch (error) {
await enqueueSnackbar({
message:
error instanceof Error
? error.message
: 'Failed to send Slack message.',
variant: 'error',
});
} finally {
setSubmitting(false);
}
};
if (channelsLoading) {
return (
<div style={styles.centeredState}>
<div style={styles.stateBlock}>
<h2 style={styles.stateTitle}>Loading channels</h2>
<p style={styles.stateDescription}>
Fetching the list of Slack channels your bot can post to.
</p>
</div>
</div>
);
}
if (channelsError !== null) {
return (
<div style={styles.centeredState}>
<div style={styles.stateBlock}>
<h2 style={styles.stateTitle}>Couldn't load channels</h2>
<p style={styles.stateError}>{channelsError}</p>
<button
type="button"
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
onClick={fetchChannels}
>
Retry
</button>
</div>
</div>
);
}
if (channels.length === 0) {
return (
<div style={styles.centeredState}>
<div style={styles.stateBlock}>
<h2 style={styles.stateTitle}>No channels available</h2>
<p style={styles.stateDescription}>
The bot doesn't have access to any Slack channels yet. Invite it to
a channel and try again.
</p>
<button
type="button"
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
onClick={fetchChannels}
>
Refresh
</button>
</div>
</div>
);
}
if (postedMessage !== null) {
return (
<div style={styles.container}>
<div style={styles.header}>
<div style={styles.headerTitleBlock}>
<h2 style={styles.pageTitle}>Message sent</h2>
<p style={styles.pageSubtitle}>
{`Posted to #${postedMessage.channelName}.`}
</p>
</div>
</div>
<div style={styles.body}>
<p style={styles.stateDescription}>
Your message is now visible in Slack.
</p>
</div>
<div style={styles.footer}>
<button
type="button"
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
onClick={handleClose}
>
Close
</button>
<button
type="button"
style={{ ...styles.buttonBase, ...styles.primaryButton }}
onClick={handleSendAnother}
>
Send another
</button>
</div>
</div>
);
}
const trimmedMessage = messageText.trim();
const canSubmit = channelId !== '' && trimmedMessage !== '' && !submitting;
const selectedChannel = channels.find(
(channelItem) => channelItem.id === channelId,
);
const channelHelperText = getChannelHelperText(selectedChannel);
return (
<div style={styles.container}>
<div style={styles.header}>
<div style={styles.headerTitleBlock}>
<h2 style={styles.pageTitle}>Send Slack message</h2>
<p style={styles.pageSubtitle}>
Post a message to any channel your bot has access to.
</p>
</div>
</div>
<div style={styles.body}>
<div style={styles.field}>
<label htmlFor="slack-channel-select" style={styles.label}>
Channel
</label>
<select
id="slack-channel-select"
value={channelId}
onChange={onValueChange(setChannelId)}
style={styles.select}
disabled={submitting}
>
<option value="" disabled>
Select a channel
</option>
{channels.map((channel) => (
<option key={channel.id} value={channel.id}>
{formatChannelOptionLabel(channel)}
</option>
))}
</select>
<span style={styles.helperText}>{channelHelperText}</span>
</div>
<div style={styles.field}>
<label htmlFor="slack-message-format-select" style={styles.label}>
Format
</label>
<select
id="slack-message-format-select"
value={messageFormat}
onChange={onValueChange((value) => {
if (isMessageFormat(value)) {
setMessageFormat(value);
}
})}
style={styles.select}
disabled={submitting}
>
{FORMAT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<span style={styles.helperText}>
{messageFormat === 'markdown'
? 'Markdown is rendered using Slack mrkdwn (bold, italics, code, links).'
: 'The message will be sent exactly as written, with no formatting.'}
</span>
</div>
<div style={styles.fieldGrowing}>
<label htmlFor="slack-message-textarea" style={styles.label}>
Message
</label>
<textarea
id="slack-message-textarea"
value={messageText}
onInput={onValueChange(setMessageText)}
onChange={onValueChange(setMessageText)}
placeholder="Write your message…"
style={styles.textarea}
disabled={submitting}
/>
</div>
</div>
<div style={styles.footer}>
<button
type="button"
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
onClick={handleClose}
disabled={submitting}
>
Cancel
</button>
<button
type="button"
style={{
...styles.buttonBase,
...styles.primaryButton,
...(canSubmit ? {} : styles.primaryButtonDisabled),
}}
onClick={handleSubmit}
disabled={!canSubmit}
>
{submitting ? 'Sending…' : 'Send message'}
</button>
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'send-slack-message-form',
description:
'Form to send a Slack message to any channel the bot can post to, with plain-text or markdown formatting.',
component: SendMessageForm,
});
@@ -1,26 +0,0 @@
import { defineConnectionProvider } from 'twenty-sdk/define';
import { SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineConnectionProvider({
universalIdentifier: SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER,
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://slack.com/oauth/v2/authorize',
tokenEndpoint: 'https://slack.com/api/oauth.v2.access',
revokeEndpoint: 'https://slack.com/api/auth.revoke',
scopes: [
'channels:read',
'chat:write',
'chat:write.public',
'groups:read',
'reactions:write',
],
clientIdVariable: 'SLACK_CLIENT_ID',
clientSecretVariable: 'SLACK_CLIENT_SECRET',
tokenRequestContentType: 'form-urlencoded',
usePkce: true,
},
});
@@ -1,38 +0,0 @@
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'a8c47f21-3b9e-4d2a-8f61-9c0e7d4a2b51';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b7d36e10-2a8d-4c1b-9e50-8bfd6c3a1940';
export const SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER =
'8b6c6fd9-8d61-4b6f-9f25-3d92a0f2cc5b';
export const SLACK_POST_MESSAGE_UNIVERSAL_IDENTIFIER =
'c6f25d09-1b7c-4e3f-ad42-7aec5b29830f';
export const SLACK_POST_EPHEMERAL_MESSAGE_UNIVERSAL_IDENTIFIER =
'd5e14c98-0a6b-4e2e-ac31-69db4a18720e';
export const SLACK_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER =
'e4d03b87-9a5b-4f1d-8b20-58ca3917620d';
export const SLACK_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER =
'f3c92a76-8b4a-4a09-ba19-47b9280651c9';
export const SLACK_ADD_REACTION_UNIVERSAL_IDENTIFIER =
'2a8c7f91-4d3e-5b6f-a7c8-9d0e1f2a3b4c';
export const SLACK_LIST_CHANNELS_UNIVERSAL_IDENTIFIER =
'3b7d8a92-5e4f-4c7a-b8d9-0e1f2a3b4c5d';
export const SLACK_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER =
'6e0c3d5f-9a4f-4f62-bc0e-3d5a7f9b4c6e';
export const SLACK_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER =
'7f1d4e60-ab50-4273-9d1f-4e6b8a0c5d7f';
export const SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'4c8a1b3f-7e2d-4f50-9a8c-1b3e5d7f2a4c';
export const SEND_SLACK_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER =
'5d9b2c4e-8f3e-4f50-ab9d-2c4f6e8a3b5d';
@@ -1,37 +0,0 @@
import { type SlackAddReactionInput } from 'src/logic-functions/types/slack-add-reaction-input.type';
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
export const slackAddReactionHandler = async (
parameters: SlackAddReactionInput,
): Promise<SlackToolResult> => {
const slackClientResult = await getSlackClient();
if (!slackClientResult.success) {
return {
success: false,
message: 'Slack is not connected',
error: slackClientResult.error,
};
}
const { client } = slackClientResult;
try {
await client.reactions.add({
channel: parameters.slackChannelId,
timestamp: parameters.messageTimestamp,
name: parameters.emojiName.trim(),
});
return {
success: true,
message: `Reaction "${parameters.emojiName.trim()}" added to the message.`,
slackTs: parameters.messageTimestamp,
channel: parameters.slackChannelId,
};
} catch (error) {
return slackToolFailure('Failed to add Slack reaction', error);
}
};
@@ -1,36 +0,0 @@
import { type SlackDeleteMessageInput } from 'src/logic-functions/types/slack-delete-message-input.type';
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
export const slackDeleteMessageHandler = async (
parameters: SlackDeleteMessageInput,
): Promise<SlackToolResult> => {
const slackClientResult = await getSlackClient();
if (!slackClientResult.success) {
return {
success: false,
message: 'Slack is not connected',
error: slackClientResult.error,
};
}
const { client } = slackClientResult;
try {
await client.chat.delete({
channel: parameters.slackChannelId,
ts: parameters.messageTimestamp,
});
return {
success: true,
message: 'Slack message deleted.',
slackTs: parameters.messageTimestamp,
channel: parameters.slackChannelId,
};
} catch (error) {
return slackToolFailure('Failed to delete Slack message', error);
}
};

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