Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da16556c58 |
@@ -0,0 +1,201 @@
|
||||
name: CI Hello world App E2E
|
||||
|
||||
on:
|
||||
# Temporarily disabled — will be re-enabled when example apps are published.
|
||||
# push:
|
||||
# branches:
|
||||
# - main
|
||||
#
|
||||
# pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
packages/create-twenty-app/**
|
||||
packages/twenty-sdk/**
|
||||
packages/twenty-client-sdk/**
|
||||
packages/twenty-shared/**
|
||||
!packages/create-twenty-app/package.json
|
||||
!packages/twenty-sdk/package.json
|
||||
!packages/twenty-client-sdk/package.json
|
||||
!packages/twenty-shared/package.json
|
||||
create-app-e2e-hello-world:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-4-cores
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
|
||||
TWENTY_API_URL: http://localhost:3000
|
||||
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 10
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Set CI version and prepare packages for publish
|
||||
run: |
|
||||
CI_VERSION="0.0.0-ci.$(date +%s)"
|
||||
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
|
||||
for pkg in $PUBLISHABLE_PACKAGES; do
|
||||
npx nx run $pkg:set-local-version --releaseVersion=$CI_VERSION
|
||||
done
|
||||
|
||||
- name: Build packages
|
||||
run: |
|
||||
for pkg in $PUBLISHABLE_PACKAGES; do
|
||||
npx nx build $pkg
|
||||
done
|
||||
|
||||
- name: Install and start Verdaccio
|
||||
run: |
|
||||
npx verdaccio --config .github/verdaccio-config.yaml &
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if curl -s http://localhost:4873 > /dev/null 2>&1; then
|
||||
echo "Verdaccio is ready"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for Verdaccio... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Publish packages to local registry
|
||||
run: |
|
||||
yarn config set npmRegistryServer http://localhost:4873
|
||||
yarn config set unsafeHttpWhitelist --json '["localhost"]'
|
||||
yarn config set npmAuthToken ci-auth-token
|
||||
|
||||
for pkg in $PUBLISHABLE_PACKAGES; do
|
||||
cd packages/$pkg
|
||||
yarn npm publish --tag ci
|
||||
cd ../..
|
||||
done
|
||||
|
||||
- name: Scaffold app using published create-twenty-app
|
||||
run: |
|
||||
npm install -g create-twenty-app@$CI_VERSION --registry http://localhost:4873
|
||||
create-twenty-app --version
|
||||
mkdir -p /tmp/e2e-test-workspace
|
||||
cd /tmp/e2e-test-workspace
|
||||
create-twenty-app test-app --example hello-world --display-name "Test hello-world app" --description "E2E test hello-world app" --skip-local-instance
|
||||
|
||||
- name: Install scaffolded app dependencies
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
echo 'npmRegistryServer: "http://localhost:4873"' >> .yarnrc.yml
|
||||
echo 'unsafeHttpWhitelist: ["localhost"]' >> .yarnrc.yml
|
||||
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --no-immutable
|
||||
echo "--- Installing last SDK versions ---"
|
||||
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn add twenty-sdk twenty-client-sdk
|
||||
|
||||
- name: Verify installed app versions
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
echo "--- Checking package.json references correct SDK version ---"
|
||||
node -e "
|
||||
const pkg = require('./package.json');
|
||||
const sdkVersion = pkg.dependencies['twenty-sdk'];
|
||||
if (!sdkVersion.startsWith('0.0.0-ci.')) {
|
||||
console.error('Expected twenty-sdk version to start with 0.0.0-ci., got:', sdkVersion);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('SDK version in scaffolded app:', sdkVersion);
|
||||
"
|
||||
|
||||
- name: Verify SDK CLI is available
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty --version
|
||||
|
||||
- name: Setup server environment
|
||||
run: npx nx reset:env:e2e-testing-server twenty-server
|
||||
|
||||
- name: Create databases
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
|
||||
- name: Setup database
|
||||
run: npx nx run twenty-server:database:reset
|
||||
|
||||
- name: Start server
|
||||
run: nohup npx nx start:ci twenty-server &
|
||||
|
||||
- name: Wait for server to be ready
|
||||
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
|
||||
|
||||
- name: Authenticate with twenty-server
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
|
||||
|
||||
- name: Deploy scaffolded app
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty deploy
|
||||
|
||||
- name: Install scaffolded app
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty install
|
||||
|
||||
- name: Execute hello-world logic function
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName hello-world-logic-function)
|
||||
echo "$EXEC_OUTPUT"
|
||||
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
|
||||
|
||||
- name: Execute create-hello-world-company logic function
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName create-hello-world-company)
|
||||
echo "$EXEC_OUTPUT"
|
||||
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
|
||||
|
||||
- name: Run scaffolded app integration test
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
yarn test
|
||||
|
||||
ci-create-app-e2e-hello-world-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, create-app-e2e-hello-world]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -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" --workspace-url http://localhost:3000
|
||||
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --skip-local-instance --yes
|
||||
|
||||
- name: Install scaffolded app dependencies
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
name: CI Postcard App E2E
|
||||
|
||||
on:
|
||||
# Temporarily disabled — will be re-enabled when example apps are published.
|
||||
# push:
|
||||
# branches:
|
||||
# - main
|
||||
#
|
||||
# pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
packages/create-twenty-app/**
|
||||
packages/twenty-sdk/**
|
||||
packages/twenty-client-sdk/**
|
||||
packages/twenty-shared/**
|
||||
!packages/create-twenty-app/package.json
|
||||
!packages/twenty-sdk/package.json
|
||||
!packages/twenty-client-sdk/package.json
|
||||
!packages/twenty-shared/package.json
|
||||
create-app-e2e-postcard:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-4-cores
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
|
||||
TWENTY_API_URL: http://localhost:3000
|
||||
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 10
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Set CI version and prepare packages for publish
|
||||
run: |
|
||||
CI_VERSION="0.0.0-ci.$(date +%s)"
|
||||
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
|
||||
npx nx run-many -t set-local-version -p $PUBLISHABLE_PACKAGES --releaseVersion=$CI_VERSION
|
||||
|
||||
- name: Build packages
|
||||
run: |
|
||||
for pkg in $PUBLISHABLE_PACKAGES; do
|
||||
npx nx build $pkg
|
||||
done
|
||||
|
||||
- name: Install and start Verdaccio
|
||||
run: |
|
||||
npx verdaccio --config .github/verdaccio-config.yaml &
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if curl -s http://localhost:4873 > /dev/null 2>&1; then
|
||||
echo "Verdaccio is ready"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for Verdaccio... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Publish packages to local registry
|
||||
run: |
|
||||
yarn config set npmRegistryServer http://localhost:4873
|
||||
yarn config set unsafeHttpWhitelist --json '["localhost"]'
|
||||
yarn config set npmAuthToken ci-auth-token
|
||||
|
||||
for pkg in $PUBLISHABLE_PACKAGES; do
|
||||
cd packages/$pkg
|
||||
yarn npm publish --tag ci
|
||||
cd ../..
|
||||
done
|
||||
|
||||
- name: Scaffold app using published create-twenty-app
|
||||
run: |
|
||||
npm install -g create-twenty-app@$CI_VERSION --registry http://localhost:4873
|
||||
create-twenty-app --version
|
||||
mkdir -p /tmp/e2e-test-workspace
|
||||
cd /tmp/e2e-test-workspace
|
||||
create-twenty-app test-app --example postcard --display-name "Test postcard app" --description "E2E test postcard app" --skip-local-instance
|
||||
|
||||
- name: Install scaffolded app dependencies
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
echo 'npmRegistryServer: "http://localhost:4873"' >> .yarnrc.yml
|
||||
echo 'unsafeHttpWhitelist: ["localhost"]' >> .yarnrc.yml
|
||||
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --no-immutable
|
||||
echo "--- Installing last SDK versions ---"
|
||||
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn add twenty-sdk twenty-client-sdk
|
||||
|
||||
- name: Verify installed app versions
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
echo "--- Checking package.json references correct SDK version ---"
|
||||
node -e "
|
||||
const pkg = require('./package.json');
|
||||
const sdkVersion = pkg.dependencies['twenty-sdk'];
|
||||
if (!sdkVersion.startsWith('0.0.0-ci.')) {
|
||||
console.error('Expected twenty-sdk version to start with 0.0.0-ci., got:', sdkVersion);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('SDK version in scaffolded app:', sdkVersion);
|
||||
"
|
||||
|
||||
- name: Verify SDK CLI is available
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty --version
|
||||
|
||||
- name: Setup server environment
|
||||
run: npx nx reset:env:e2e-testing-server twenty-server
|
||||
|
||||
- name: Create databases
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
|
||||
- name: Setup database
|
||||
run: npx nx run twenty-server:database:reset
|
||||
|
||||
- name: Start server
|
||||
run: nohup npx nx start:ci twenty-server &
|
||||
|
||||
- name: Wait for server to be ready
|
||||
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
|
||||
|
||||
- name: Authenticate with twenty-server
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
|
||||
|
||||
- name: Deploy scaffolded app
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty deploy
|
||||
|
||||
- name: Install scaffolded app
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty install
|
||||
|
||||
- name: Execute postcard logic function
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName postcard-logic-function)
|
||||
echo "$EXEC_OUTPUT"
|
||||
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
|
||||
|
||||
- name: Execute create-postcard-company logic function
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName create-postcard-company)
|
||||
echo "$EXEC_OUTPUT"
|
||||
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
|
||||
|
||||
- name: Run scaffolded app integration test
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
yarn test
|
||||
|
||||
ci-create-app-e2e-postcard-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, create-app-e2e-postcard]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -23,11 +23,9 @@ jobs:
|
||||
packages/twenty-sdk/**
|
||||
packages/twenty-client-sdk/**
|
||||
packages/twenty-shared/**
|
||||
packages/twenty-server/**
|
||||
!packages/twenty-sdk/package.json
|
||||
!packages/twenty-client-sdk/package.json
|
||||
!packages/twenty-shared/package.json
|
||||
!packages/twenty-server/package.json
|
||||
|
||||
example-app-postcard:
|
||||
needs: changed-files-check
|
||||
@@ -85,31 +83,6 @@ jobs:
|
||||
working-directory: packages/twenty-apps/examples/postcard
|
||||
run: npx vitest run
|
||||
|
||||
- name: Configure remote for SDK CLI
|
||||
run: |
|
||||
mkdir -p ~/.twenty
|
||||
cat > ~/.twenty/config.json <<EOF
|
||||
{
|
||||
"version": 1,
|
||||
"remotes": {
|
||||
"target": {
|
||||
"apiUrl": "${TWENTY_API_URL}",
|
||||
"apiKey": "${TWENTY_API_KEY}",
|
||||
"accessToken": "${TWENTY_API_KEY}"
|
||||
}
|
||||
},
|
||||
"defaultRemote": "target"
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Deploy postcard app (registry install path)
|
||||
working-directory: packages/twenty-apps/examples/postcard
|
||||
run: node ${{ github.workspace }}/packages/twenty-sdk/dist/cli.cjs deploy --remote target
|
||||
|
||||
- name: Install postcard app (registry install path)
|
||||
working-directory: packages/twenty-apps/examples/postcard
|
||||
run: node ${{ github.workspace }}/packages/twenty-sdk/dist/cli.cjs install --remote target
|
||||
|
||||
ci-example-app-postcard-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
|
||||
@@ -51,7 +51,6 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
@@ -124,7 +123,6 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
|
||||
@@ -43,46 +43,6 @@
|
||||
{
|
||||
"name": "packages/twenty-e2e-testing",
|
||||
"path": "../packages/twenty-e2e-testing"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-docs",
|
||||
"path": "../packages/twenty-docs"
|
||||
},
|
||||
{
|
||||
"name": "packages/create-twenty-app",
|
||||
"path": "../packages/create-twenty-app"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-apps",
|
||||
"path": "../packages/twenty-apps"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-claude-skills",
|
||||
"path": "../packages/twenty-claude-skills"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-cli",
|
||||
"path": "../packages/twenty-cli"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-client-sdk",
|
||||
"path": "../packages/twenty-client-sdk"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-companion",
|
||||
"path": "../packages/twenty-companion"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-front-component-renderer",
|
||||
"path": "../packages/twenty-front-component-renderer"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-sdk",
|
||||
"path": "../packages/twenty-sdk"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-website-new",
|
||||
"path": "../packages/twenty-website-new"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
|
||||
@@ -4,10 +4,8 @@ enableHardenedMode: true
|
||||
|
||||
enableInlineHunks: true
|
||||
|
||||
enableScripts: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 3d
|
||||
npmMinimalAgeGate: "3d"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.13.0.cjs
|
||||
|
||||
@@ -25,18 +25,26 @@ yarn twenty dev
|
||||
The scaffolder will:
|
||||
|
||||
1. Create a new project with TypeScript, linting, tests, and a preconfigured `twenty` CLI
|
||||
2. Start a local Twenty server via Docker (pulls the latest image automatically)
|
||||
3. Authenticate with the development API key
|
||||
2. Optionally start a local Twenty server (Docker)
|
||||
3. Open the browser for OAuth authentication
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Description |
|
||||
| ---------------------------------- | --------------------------------------------------------------------- |
|
||||
| `--name <name>` | Set the app name |
|
||||
| `--display-name <displayName>` | Set the display name |
|
||||
| `--description <description>` | Set the description |
|
||||
| `--workspace-url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
|
||||
| `--authentication-method <method>` | `oauth` or `apiKey` (default: `apiKey` for local, `oauth` for remote) |
|
||||
| Flag | Description |
|
||||
| ------------------------------ | --------------------------------------- |
|
||||
| `--example <name>` | Initialize from an example |
|
||||
| `--name <name>` | Set the app name (skips the prompt) |
|
||||
| `--display-name <displayName>` | Set the display name (skips the prompt) |
|
||||
| `--description <description>` | Set the description (skips the prompt) |
|
||||
| `--skip-local-instance` | Skip the local server setup prompt |
|
||||
|
||||
By default (no flags), a minimal app is generated with core files and an integration test. Use `--example` to start from a richer example:
|
||||
|
||||
```bash
|
||||
npx create-twenty-app@latest my-twenty-app --example hello-world
|
||||
```
|
||||
|
||||
Examples are sourced from [twentyhq/twenty/packages/twenty-apps/examples](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples).
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -49,7 +57,7 @@ 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 server logs`.
|
||||
- Auth not working: run `yarn twenty remote add --local` to re-authenticate.
|
||||
- Auth not working: make sure you are logged in to Twenty in the browser, then run `yarn twenty remote add`.
|
||||
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "2.5.1",
|
||||
"version": "2.4.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
@@ -32,6 +32,7 @@
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"inquirer": "^10.0.0",
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"lodash.kebabcase": "^4.1.1",
|
||||
"lodash.startcase": "^4.4.0",
|
||||
@@ -42,6 +43,7 @@
|
||||
"@swc/core": "^1.15.11",
|
||||
"@swc/jest": "^0.2.39",
|
||||
"@types/fs-extra": "^11.0.0",
|
||||
"@types/inquirer": "^9.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/lodash.camelcase": "^4.3.7",
|
||||
"@types/lodash.kebabcase": "^4.1.7",
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import chalk from 'chalk';
|
||||
import { Command, CommanderError } from 'commander';
|
||||
import {
|
||||
type AuthenticationMethod,
|
||||
CreateAppCommand,
|
||||
} from '@/create-app.command';
|
||||
import { CreateAppCommand } from '@/create-app.command';
|
||||
import packageJson from '../package.json';
|
||||
|
||||
const program = new Command(packageJson.name)
|
||||
@@ -15,29 +12,32 @@ const program = new Command(packageJson.name)
|
||||
'Output the current version of create-twenty-app.',
|
||||
)
|
||||
.argument('[directory]')
|
||||
.option('-n, --name <name>', 'Application name')
|
||||
.option('-d, --display-name <displayName>', 'Application display name')
|
||||
.option('--description <description>', 'Application description')
|
||||
.option('--example <name>', 'Initialize from an example')
|
||||
.option('-n, --name <name>', 'Application name (skips prompt)')
|
||||
.option(
|
||||
'--workspace-url <workspaceUrl>',
|
||||
'Twenty workspace URL (default: http://localhost:2020)',
|
||||
'-d, --display-name <displayName>',
|
||||
'Application display name (skips prompt)',
|
||||
)
|
||||
.option('--api-url <apiUrl>', '[deprecated: use --workspace-url]')
|
||||
.option(
|
||||
'--authentication-method <method>',
|
||||
'Authentication method: oauth or apiKey (default: apiKey for local, oauth for remote)',
|
||||
'--description <description>',
|
||||
'Application description (skips prompt)',
|
||||
)
|
||||
.option(
|
||||
'--skip-local-instance',
|
||||
'Skip the local Twenty instance setup prompt',
|
||||
)
|
||||
.option('-y, --yes', 'Auto-confirm prompts (e.g. start existing container)')
|
||||
.helpOption('-h, --help', 'Display this help message.')
|
||||
.action(
|
||||
async (
|
||||
directory?: string,
|
||||
options?: {
|
||||
example?: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
workspaceUrl?: string;
|
||||
apiUrl?: string;
|
||||
authenticationMethod?: AuthenticationMethod;
|
||||
skipLocalInstance?: boolean;
|
||||
yes?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
|
||||
@@ -54,33 +54,14 @@ const program = new Command(packageJson.name)
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (
|
||||
options?.authenticationMethod &&
|
||||
!['oauth', 'apiKey'].includes(options.authenticationMethod)
|
||||
) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
'Error: --authentication-method must be "oauth" or "apiKey".',
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options?.apiUrl) {
|
||||
console.warn(
|
||||
chalk.yellow(
|
||||
'Warning: --api-url is deprecated. Use --workspace-url instead.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await new CreateAppCommand().execute({
|
||||
directory,
|
||||
example: options?.example,
|
||||
name: options?.name,
|
||||
displayName: options?.displayName,
|
||||
description: options?.description,
|
||||
workspaceUrl: options?.workspaceUrl ?? options?.apiUrl,
|
||||
authenticationMethod: options?.authenticationMethod,
|
||||
skipLocalInstance: options?.skipLocalInstance,
|
||||
yes: options?.yes,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,19 +2,8 @@ This is a [Twenty](https://twenty.com) application bootstrapped with [`create-tw
|
||||
|
||||
## Getting Started
|
||||
|
||||
This app was scaffolded with a local Twenty server running at [http://localhost:2020](http://localhost:2020).
|
||||
|
||||
Login with the default development credentials: `tim@apple.dev` / `tim@apple.dev`.
|
||||
|
||||
Run `yarn twenty help` to list all available commands.
|
||||
|
||||
## Useful Commands
|
||||
|
||||
- `yarn twenty dev` - Start the development server and sync your app
|
||||
- `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
|
||||
|
||||
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)
|
||||
|
||||
@@ -11,7 +11,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
TWENTY_DEPLOY_URL: http://localhost:2020
|
||||
TWENTY_DEPLOY_URL: http://localhost:3000
|
||||
|
||||
concurrency:
|
||||
group: cd-${{ github.ref }}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" fill="none">
|
||||
<rect width="80" height="80" rx="16" fill="#141414"/>
|
||||
<rect x="20" y="20" width="16" height="16" rx="4" fill="#FAFAFA"/>
|
||||
<rect x="44" y="20" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.6"/>
|
||||
<rect x="20" y="44" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.6"/>
|
||||
<rect x="44" y="44" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.3"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 454 B |
@@ -2,10 +2,3 @@ export const APP_DISPLAY_NAME = 'DISPLAY-NAME-TO-BE-GENERATED';
|
||||
export const APP_DESCRIPTION = 'DESCRIPTION-TO-BE-GENERATED';
|
||||
export const APPLICATION_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
|
||||
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
|
||||
export const MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
|
||||
'UUID-TO-BE-GENERATED';
|
||||
export const MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
|
||||
export const MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
|
||||
export const MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
|
||||
export const MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
|
||||
'UUID-TO-BE-GENERATED';
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
APP_DISPLAY_NAME,
|
||||
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
const DOCS_BASE_URL = 'https://docs.twenty.com/developers/extend/apps';
|
||||
|
||||
const CATEGORIES = [
|
||||
{
|
||||
title: 'Data model',
|
||||
color: '#73D08D',
|
||||
items: [
|
||||
{ label: 'CUSTOM OBJECT', href: `${DOCS_BASE_URL}/data/objects` },
|
||||
{
|
||||
label: 'CUSTOM FIELDS',
|
||||
href: `${DOCS_BASE_URL}/data/extending-objects`,
|
||||
},
|
||||
],
|
||||
rotation: '2.4deg',
|
||||
},
|
||||
{
|
||||
title: 'Logic',
|
||||
color: '#F4D345',
|
||||
items: [
|
||||
{
|
||||
label: 'TOOLS',
|
||||
href: `${DOCS_BASE_URL}/logic/logic-functions`,
|
||||
},
|
||||
{
|
||||
label: 'SERVERLESS FUNCT.',
|
||||
href: `${DOCS_BASE_URL}/logic/logic-functions`,
|
||||
},
|
||||
{
|
||||
label: 'SKILLS',
|
||||
href: `${DOCS_BASE_URL}/logic/skills-and-agents`,
|
||||
},
|
||||
],
|
||||
rotation: '0deg',
|
||||
},
|
||||
{
|
||||
title: 'Layout',
|
||||
color: '#C4A2E0',
|
||||
items: [
|
||||
{ label: 'VIEWS', href: `${DOCS_BASE_URL}/layout/views` },
|
||||
{ label: 'WIDGETS', href: `${DOCS_BASE_URL}/layout/page-layouts` },
|
||||
{
|
||||
label: 'LAYOUT PAGES',
|
||||
href: `${DOCS_BASE_URL}/layout/page-layouts`,
|
||||
},
|
||||
{
|
||||
label: 'COMMANDS',
|
||||
href: `${DOCS_BASE_URL}/layout/command-menu-items`,
|
||||
},
|
||||
],
|
||||
rotation: '-2.8deg',
|
||||
},
|
||||
] 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
|
||||
d="M4.5 3.5H10.5V9.5M10.5 3.5L3.5 10.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CategoryCard = ({
|
||||
title,
|
||||
color,
|
||||
items,
|
||||
rotation,
|
||||
}: {
|
||||
title: string;
|
||||
color: string;
|
||||
items: ReadonlyArray<{ label: string; href: string }>;
|
||||
rotation: string;
|
||||
}) => {
|
||||
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
border: `1px solid ${color}80`,
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
width: '240px',
|
||||
background: '#FFFFFF',
|
||||
transform: `rotate(${rotation})`,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
background: `${color}22`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
color: color,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '8px',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isHovered = hoveredItem === item.label;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onMouseEnter={() => setHoveredItem(item.label)}
|
||||
onMouseLeave={() => setHoveredItem(null)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
textDecoration: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
background: isHovered ? '#0000000A' : 'transparent',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
<ItemIcon color={color} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 300,
|
||||
color: '#333',
|
||||
letterSpacing: '0.5px',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{isHovered && <ArrowUpRight />}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MainPage = () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
gap: '8px',
|
||||
padding: '40px',
|
||||
}}
|
||||
>
|
||||
{/*<Avatar
|
||||
avatarUrl={getPublicAssetUrl('logo.svg')}
|
||||
placeholder={APP_DISPLAY_NAME}
|
||||
placeholderColorSeed={APP_DISPLAY_NAME}
|
||||
type="squared"
|
||||
size="xl"
|
||||
/>*/}
|
||||
<span
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 600,
|
||||
color: '#333',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
{APP_DISPLAY_NAME}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#888',
|
||||
textAlign: 'center',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
Was installed successfully.
|
||||
<br />
|
||||
You can now add content to your app.
|
||||
</span>
|
||||
<a
|
||||
href="/settings/applications"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
marginTop: '16px',
|
||||
fontSize: '13px',
|
||||
color: '#333',
|
||||
textDecoration: 'none',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
background: '#fafafa',
|
||||
transition: 'background 0.15s, border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f0f0f0';
|
||||
e.currentTarget.style.borderColor = '#ccc';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#fafafa';
|
||||
e.currentTarget.style.borderColor = '#e0e0e0';
|
||||
}}
|
||||
>
|
||||
Open app settings
|
||||
<ArrowUpRight color="#333" />
|
||||
</a>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
marginTop: '32px',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{CATEGORIES.map((category) => (
|
||||
<CategoryCard
|
||||
key={category.title}
|
||||
title={category.title}
|
||||
color={category.color}
|
||||
items={category.items}
|
||||
rotation={category.rotation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: APP_DISPLAY_NAME,
|
||||
description: `${APP_DISPLAY_NAME} front component displaying the app logo and name`,
|
||||
component: MainPage,
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import {
|
||||
defineNavigationMenuItem,
|
||||
NavigationMenuItemType,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
APP_DISPLAY_NAME,
|
||||
MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
|
||||
name: APP_DISPLAY_NAME,
|
||||
icon: 'IconFile',
|
||||
position: -1,
|
||||
type: NavigationMenuItemType.PAGE_LAYOUT,
|
||||
pageLayoutUniversalIdentifier: MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
APP_DISPLAY_NAME,
|
||||
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default definePageLayout({
|
||||
universalIdentifier: MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
name: APP_DISPLAY_NAME,
|
||||
type: 'STANDALONE_PAGE',
|
||||
tabs: [
|
||||
{
|
||||
universalIdentifier: MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Overview',
|
||||
position: 0,
|
||||
icon: 'IconApps',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: ' ',
|
||||
type: 'FRONT_COMPONENT',
|
||||
gridPosition: { row: 0, column: 0, rowSpan: 12, columnSpan: 12 },
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier:
|
||||
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,109 +1,96 @@
|
||||
import { copyBaseApplicationProject } from '@/utils/app-template';
|
||||
import { downloadExample } from '@/utils/download-example';
|
||||
import { convertToLabel } from '@/utils/convert-to-label';
|
||||
import { install } from '@/utils/install';
|
||||
import { tryGitInit } from '@/utils/try-git-init';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import inquirer from 'inquirer';
|
||||
import kebabCase from 'lodash.kebabcase';
|
||||
import * as path from 'path';
|
||||
import { basename } from 'path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
authLogin,
|
||||
authLoginOAuth,
|
||||
checkDockerRunning,
|
||||
ConfigService,
|
||||
DEV_API_KEY,
|
||||
DEV_API_URL,
|
||||
containerExists,
|
||||
detectLocalServer,
|
||||
serverStart,
|
||||
} from 'twenty-sdk/cli';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
getDockerInstallInstructions,
|
||||
isDockerInstalled,
|
||||
} from '@/utils/docker-install';
|
||||
|
||||
const CURRENT_EXECUTION_DIRECTORY = process.env.INIT_CWD || process.cwd();
|
||||
const IMAGE = 'twentycrm/twenty-app-dev:latest';
|
||||
|
||||
export type AuthenticationMethod = 'oauth' | 'apiKey';
|
||||
|
||||
type CreateAppOptions = {
|
||||
directory?: string;
|
||||
example?: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
workspaceUrl?: string;
|
||||
authenticationMethod?: AuthenticationMethod;
|
||||
skipLocalInstance?: boolean;
|
||||
yes?: boolean;
|
||||
};
|
||||
|
||||
export class CreateAppCommand {
|
||||
private stepCounter = 0;
|
||||
private totalSteps = 0;
|
||||
private static TOTAL_STEPS = 4;
|
||||
|
||||
async execute(options: CreateAppOptions = {}): Promise<void> {
|
||||
const { appName, appDisplayName, appDirectory, appDescription } =
|
||||
this.getAppInfos(options);
|
||||
|
||||
const workspaceUrl = options.workspaceUrl ?? DEV_API_URL;
|
||||
|
||||
const skipLocalInstance = workspaceUrl !== DEV_API_URL;
|
||||
|
||||
if (!skipLocalInstance && !isDockerInstalled()) {
|
||||
console.log(chalk.yellow('\n' + getDockerInstallInstructions() + '\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (skipLocalInstance && options.authenticationMethod === 'apiKey') {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'API key authentication is only supported on a local Docker instance. Ignoring and switching to OAuth authentication.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const authenticationMethod = skipLocalInstance
|
||||
? 'oauth'
|
||||
: (options.authenticationMethod ?? 'apiKey');
|
||||
await this.getAppInfos(options);
|
||||
|
||||
try {
|
||||
await this.validateDirectory(appDirectory);
|
||||
|
||||
this.totalSteps = this.computeTotalSteps({
|
||||
skipLocalInstance,
|
||||
});
|
||||
|
||||
this.stepCounter = 0;
|
||||
|
||||
const dockerPullPromise =
|
||||
!skipLocalInstance && checkDockerRunning()
|
||||
? this.pullImageInBackground()
|
||||
: Promise.resolve(false);
|
||||
|
||||
this.logPlan({ appName, appDisplayName, appDescription, appDirectory });
|
||||
|
||||
this.logNextStep('Creating project directory');
|
||||
|
||||
await fs.ensureDir(appDirectory);
|
||||
|
||||
this.logDetail(appDirectory);
|
||||
|
||||
this.logNextStep('Scaffolding project files');
|
||||
|
||||
await copyBaseApplicationProject({
|
||||
const confirmed = await this.promptScaffoldConfirmation({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
onProgress: (message) => this.logDetail(message),
|
||||
autoConfirm: options.yes,
|
||||
});
|
||||
|
||||
this.logNextStep('Installing dependencies');
|
||||
if (!confirmed) {
|
||||
console.log(chalk.gray('\nScaffolding cancelled.'));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
this.logStep(1, 'Creating project directory');
|
||||
await fs.ensureDir(appDirectory);
|
||||
this.logDetail(appDirectory);
|
||||
|
||||
this.logStep(2, 'Scaffolding project files');
|
||||
|
||||
if (options.example) {
|
||||
const exampleSucceeded = await this.tryDownloadExample(
|
||||
options.example,
|
||||
appDirectory,
|
||||
);
|
||||
|
||||
if (!exampleSucceeded) {
|
||||
await copyBaseApplicationProject({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
onProgress: (message) => this.logDetail(message),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await copyBaseApplicationProject({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
onProgress: (message) => this.logDetail(message),
|
||||
});
|
||||
}
|
||||
|
||||
this.logStep(3, 'Installing dependencies');
|
||||
await install(appDirectory, (message) => this.logDetail(message));
|
||||
|
||||
this.logNextStep('Initializing Git repository');
|
||||
|
||||
this.logStep(4, 'Initializing Git repository');
|
||||
const gitInitialized = await tryGitInit(appDirectory);
|
||||
|
||||
if (gitInitialized) {
|
||||
@@ -117,58 +104,38 @@ export class CreateAppCommand {
|
||||
|
||||
console.log('');
|
||||
|
||||
let hasLocalServer = false;
|
||||
let authSucceeded = false;
|
||||
let resolvedWorkspaceUrl = workspaceUrl;
|
||||
let serverReady = skipLocalInstance;
|
||||
|
||||
if (!skipLocalInstance) {
|
||||
this.logNextStep('Starting Twenty server');
|
||||
const serverResult = await this.ensureDockerServer(dockerPullPromise);
|
||||
if (!options.skipLocalInstance) {
|
||||
const existingServerUrl = await detectLocalServer();
|
||||
|
||||
if (isDefined(serverResult.url)) {
|
||||
resolvedWorkspaceUrl = serverResult.url;
|
||||
serverReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (serverReady) {
|
||||
this.logNextStep('Authenticating');
|
||||
|
||||
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(resolvedWorkspaceUrl);
|
||||
if (existingServerUrl) {
|
||||
hasLocalServer = true;
|
||||
authSucceeded = await this.promptConnectToLocal(existingServerUrl);
|
||||
} else {
|
||||
this.logDetail('Using development API key');
|
||||
authSucceeded =
|
||||
await this.authenticateWithDevKey(resolvedWorkspaceUrl);
|
||||
const shouldStart = await this.shouldStartServer(options.yes);
|
||||
|
||||
if (shouldStart) {
|
||||
const startResult = await serverStart({
|
||||
onProgress: (message: string) => console.log(chalk.gray(message)),
|
||||
});
|
||||
|
||||
if (startResult.success) {
|
||||
hasLocalServer = true;
|
||||
authSucceeded = await this.promptConnectToLocal(
|
||||
startResult.data.url,
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.yellow(`\n${startResult.error.message}`));
|
||||
}
|
||||
} else {
|
||||
this.logServerSkipped();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logNextStep('Installing application');
|
||||
|
||||
let syncSucceeded = false;
|
||||
|
||||
if (serverReady && authSucceeded) {
|
||||
syncSucceeded = await this.syncApplication(appDirectory);
|
||||
|
||||
if (!syncSucceeded) {
|
||||
this.logDetail('Sync failed. Run `yarn twenty dev --once` manually.');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.logDetail('Skipped (server or authentication not available)');
|
||||
}
|
||||
|
||||
if (syncSucceeded) {
|
||||
await this.openMainPage(appDirectory, resolvedWorkspaceUrl);
|
||||
}
|
||||
|
||||
this.logSuccess(appDirectory, resolvedWorkspaceUrl, authSucceeded);
|
||||
this.logSuccess(appDirectory, hasLocalServer, authSucceeded);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('\nCreate application failed:'),
|
||||
@@ -178,42 +145,66 @@ export class CreateAppCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private computeTotalSteps({
|
||||
skipLocalInstance,
|
||||
}: {
|
||||
skipLocalInstance: boolean;
|
||||
}): number {
|
||||
let steps = 4; // directory, scaffold, install, git
|
||||
|
||||
if (!skipLocalInstance) {
|
||||
steps += 1; // start server
|
||||
}
|
||||
|
||||
steps += 1; // authenticate (oauth or apiKey)
|
||||
steps += 1; // sync application
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
private getAppInfos(options: CreateAppOptions): {
|
||||
private async getAppInfos(options: CreateAppOptions): Promise<{
|
||||
appName: string;
|
||||
appDisplayName: string;
|
||||
appDescription: string;
|
||||
appDirectory: string;
|
||||
} {
|
||||
}> {
|
||||
const { directory } = options;
|
||||
|
||||
const hasName = isDefined(options.name) || isDefined(directory);
|
||||
const hasDisplayName = isDefined(options.displayName);
|
||||
const hasDescription = isDefined(options.description);
|
||||
const hasExample = isDefined(options.example);
|
||||
|
||||
const { name, displayName, description } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Application name:',
|
||||
when: () => !hasName && !hasExample,
|
||||
default: 'my-twenty-app',
|
||||
validate: (input) => {
|
||||
if (input.length === 0) return 'Application name is required';
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'displayName',
|
||||
message: 'Application display name:',
|
||||
when: () => !hasDisplayName && !hasExample,
|
||||
default: (answers: { name?: string }) => {
|
||||
return convertToLabel(
|
||||
answers?.name ?? options.name ?? directory ?? '',
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'description',
|
||||
message: 'Application description (optional):',
|
||||
when: () => !hasDescription && !hasExample,
|
||||
default: '',
|
||||
},
|
||||
]);
|
||||
|
||||
const appName = (
|
||||
options.name ??
|
||||
options.directory ??
|
||||
name ??
|
||||
directory ??
|
||||
options.example ??
|
||||
'my-twenty-app'
|
||||
).trim();
|
||||
|
||||
const appDisplayName =
|
||||
options.displayName?.trim() || convertToLabel(appName);
|
||||
(options.displayName ?? displayName)?.trim() || convertToLabel(appName);
|
||||
|
||||
const appDescription = (options.description ?? '').trim();
|
||||
const appDescription = (options.description ?? description ?? '').trim();
|
||||
|
||||
const appDirectory = options.directory
|
||||
? path.join(CURRENT_EXECUTION_DIRECTORY, options.directory)
|
||||
const appDirectory = directory
|
||||
? path.join(CURRENT_EXECUTION_DIRECTORY, directory)
|
||||
: path.join(CURRENT_EXECUTION_DIRECTORY, kebabCase(appName));
|
||||
|
||||
return { appName, appDisplayName, appDirectory, appDescription };
|
||||
@@ -232,17 +223,54 @@ export class CreateAppCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private logPlan({
|
||||
private async tryDownloadExample(
|
||||
example: string,
|
||||
appDirectory: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await downloadExample(example, appDirectory);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
`\n${error instanceof Error ? error.message : 'Failed to download example.'}`,
|
||||
),
|
||||
);
|
||||
|
||||
const { useTemplate } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'useTemplate',
|
||||
message: 'Would you like to create a default template app instead?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!useTemplate) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Clean up any partial files from the failed download
|
||||
await fs.emptyDir(appDirectory);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async promptScaffoldConfirmation({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
autoConfirm,
|
||||
}: {
|
||||
appName: string;
|
||||
appDisplayName: string;
|
||||
appDescription: string;
|
||||
appDirectory: string;
|
||||
}): void {
|
||||
autoConfirm?: boolean;
|
||||
}): Promise<boolean> {
|
||||
console.log(chalk.blue('\nCreating Twenty Application\n'));
|
||||
console.log(chalk.white(` Name: ${appName}`));
|
||||
console.log(chalk.white(` Display name: ${appDisplayName}`));
|
||||
@@ -252,13 +280,43 @@ export class CreateAppCommand {
|
||||
}
|
||||
|
||||
console.log(chalk.white(` Directory: ${appDirectory}`));
|
||||
|
||||
console.log(chalk.white('\nThe following steps will be performed:\n'));
|
||||
console.log(chalk.gray(' 1. Create project directory'));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
' 2. Scaffold project files from base template\n' +
|
||||
' - Copy template files\n' +
|
||||
' - Configure dotfiles (.gitignore, .github)\n' +
|
||||
' - Generate unique application identifiers\n' +
|
||||
' - Update package.json with app name and SDK versions',
|
||||
),
|
||||
);
|
||||
console.log(chalk.gray(' 3. Install dependencies (yarn)'));
|
||||
console.log(
|
||||
chalk.gray(' 4. Initialize Git repository with initial commit'),
|
||||
);
|
||||
console.log('');
|
||||
|
||||
if (autoConfirm) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { proceed } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'proceed',
|
||||
message: 'Proceed?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return proceed;
|
||||
}
|
||||
|
||||
private logNextStep(title: string): void {
|
||||
this.stepCounter++;
|
||||
private logStep(step: number, title: string): void {
|
||||
console.log(
|
||||
chalk.blue(`\n[${this.stepCounter}/${this.totalSteps}]`) +
|
||||
chalk.blue(`\n[${step}/${CreateAppCommand.TOTAL_STEPS}]`) +
|
||||
chalk.white(` ${title}...`),
|
||||
);
|
||||
}
|
||||
@@ -267,299 +325,100 @@ export class CreateAppCommand {
|
||||
console.log(chalk.gray(` → ${message}`));
|
||||
}
|
||||
|
||||
private pullImageInBackground(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn('docker', ['pull', IMAGE], { stdio: 'ignore' });
|
||||
private async shouldStartServer(autoConfirm?: boolean): Promise<boolean> {
|
||||
console.log(
|
||||
chalk.white(
|
||||
'\n A local Twenty instance is required for app development.\n' +
|
||||
' It provides the API and schema your application connects to.\n',
|
||||
),
|
||||
);
|
||||
|
||||
child.on('close', (code) => resolve(code === 0));
|
||||
child.on('error', () => resolve(false));
|
||||
});
|
||||
if (checkDockerRunning() && containerExists()) {
|
||||
if (autoConfirm) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { startExisting } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'startExisting',
|
||||
message:
|
||||
'An existing Twenty server container was found. Would you like to start it?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return startExisting;
|
||||
}
|
||||
|
||||
if (autoConfirm) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { startDocker } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'startDocker',
|
||||
message:
|
||||
'No running Twenty instance found. Would you like to start one using Docker?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return startDocker;
|
||||
}
|
||||
|
||||
private async ensureDockerServer(
|
||||
dockerPullPromise: Promise<boolean>,
|
||||
): Promise<{ url?: string }> {
|
||||
if (!checkDockerRunning()) {
|
||||
private logServerSkipped(): void {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
'\n To start a Twenty instance later:\n' +
|
||||
' yarn twenty server start\n\n' +
|
||||
' To connect to a remote instance instead:\n' +
|
||||
' yarn twenty remote add\n',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async promptConnectToLocal(serverUrl: string): Promise<boolean> {
|
||||
console.log(
|
||||
chalk.white(
|
||||
'\n Authentication links your app to a Twenty instance so you can\n' +
|
||||
' sync custom objects, fields, and roles during development.\n' +
|
||||
' This will open a browser window to complete the OAuth flow.\n',
|
||||
),
|
||||
);
|
||||
|
||||
const { shouldAuthenticate } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'shouldAuthenticate',
|
||||
message: `Authenticate to the local Twenty instance (${serverUrl})?`,
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!shouldAuthenticate) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'\n Docker is installed but not running.\n' +
|
||||
' Please start Docker and run this command again.\n',
|
||||
chalk.gray(
|
||||
'\n Authentication skipped. To authenticate later:\n' +
|
||||
` yarn twenty remote add --local\n`,
|
||||
),
|
||||
);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
this.logDetail('Ensuring latest Twenty server image...');
|
||||
|
||||
const pullSucceeded = await dockerPullPromise;
|
||||
|
||||
if (!pullSucceeded) {
|
||||
this.logDetail(
|
||||
'Image pull failed, continuing with cached image if available...',
|
||||
);
|
||||
}
|
||||
|
||||
const startResult = await serverStart({
|
||||
onProgress: (message: string) => this.logDetail(message),
|
||||
});
|
||||
|
||||
if (startResult.success) {
|
||||
return { url: startResult.data.url };
|
||||
}
|
||||
|
||||
console.log(chalk.yellow(`\n ${startResult.error.message}`));
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private async openMainPage(
|
||||
appDirectory: string,
|
||||
workspaceUrl: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const configService = new ConfigService();
|
||||
const config = await configService.getConfig();
|
||||
const token = config.twentyCLIAccessToken ?? config.apiKey;
|
||||
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [universalIdentifier, frontUrl] = await Promise.all([
|
||||
this.readMainPageLayoutUniversalIdentifier(appDirectory),
|
||||
this.resolveWorkspaceFrontUrl(workspaceUrl, token),
|
||||
]);
|
||||
|
||||
if (!universalIdentifier || !frontUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageLayoutId = await this.resolvePageLayoutId(
|
||||
workspaceUrl,
|
||||
universalIdentifier,
|
||||
token,
|
||||
);
|
||||
|
||||
if (!pageLayoutId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${frontUrl}/page/${pageLayoutId}`;
|
||||
|
||||
this.logDetail(`Opening app welcome page: ${url}`);
|
||||
this.openInBrowser(url);
|
||||
} catch {
|
||||
// Best-effort — don't fail the scaffold if browser open fails
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveWorkspaceFrontUrl(
|
||||
workspaceUrl: string,
|
||||
token: string,
|
||||
): Promise<string | null> {
|
||||
const query = `{ currentWorkspace { workspaceUrls { subdomainUrl customUrl } } }`;
|
||||
|
||||
const response = await fetch(`${workspaceUrl}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data?: {
|
||||
currentWorkspace?: {
|
||||
workspaceUrls?: { subdomainUrl?: string; customUrl?: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const urls = body.data?.currentWorkspace?.workspaceUrls;
|
||||
|
||||
if (!urls) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frontUrl = urls.customUrl ?? urls.subdomainUrl;
|
||||
|
||||
return frontUrl?.replace(/\/+$/, '') ?? null;
|
||||
}
|
||||
|
||||
private async readMainPageLayoutUniversalIdentifier(
|
||||
appDirectory: string,
|
||||
): Promise<string | null> {
|
||||
const filePath = path.join(
|
||||
appDirectory,
|
||||
'src',
|
||||
'constants',
|
||||
'universal-identifiers.ts',
|
||||
);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const match = content.match(
|
||||
/MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER\s*=\s*'([^']+)'/,
|
||||
);
|
||||
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
private async resolvePageLayoutId(
|
||||
workspaceUrl: string,
|
||||
universalIdentifier: string,
|
||||
token: string,
|
||||
): Promise<string | null> {
|
||||
const query = `{ getPageLayouts { id universalIdentifier } }`;
|
||||
|
||||
const response = await fetch(`${workspaceUrl}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data?: {
|
||||
getPageLayouts?: { id: string; universalIdentifier: string }[];
|
||||
};
|
||||
};
|
||||
|
||||
const matching = body.data?.getPageLayouts?.find(
|
||||
(layout) => layout.universalIdentifier === universalIdentifier,
|
||||
);
|
||||
|
||||
return matching?.id ?? null;
|
||||
}
|
||||
|
||||
private sanitizeBrowserUrl(url: string): string | null {
|
||||
if (/[^\u0020-\u007E]/.test(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private openInBrowser(url: string): void {
|
||||
const safeUrl = this.sanitizeBrowserUrl(url);
|
||||
|
||||
if (!safeUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const command = isWindows
|
||||
? 'rundll32'
|
||||
: process.platform === 'darwin'
|
||||
? 'open'
|
||||
: 'xdg-open';
|
||||
const args = isWindows
|
||||
? ['url.dll,FileProtocolHandler', safeUrl]
|
||||
: [safeUrl];
|
||||
|
||||
const child = spawn(command, args, {
|
||||
stdio: 'ignore',
|
||||
detached: !isWindows,
|
||||
});
|
||||
child.on('error', () => undefined);
|
||||
if (!isWindows) {
|
||||
child.unref();
|
||||
}
|
||||
}
|
||||
|
||||
private async syncApplication(appDirectory: string): Promise<boolean> {
|
||||
this.logDetail('Running `yarn twenty dev --once`...');
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn('yarn', ['twenty', 'dev', '--once'], {
|
||||
cwd: appDirectory,
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
child.stdout?.resume();
|
||||
child.stderr?.resume();
|
||||
|
||||
child.on('close', (code) => resolve(code === 0));
|
||||
child.on('error', () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
private async tryExistingAuth(workspaceUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const configService = new ConfigService();
|
||||
const remoteNames = await configService.getRemotes();
|
||||
|
||||
for (const remoteName of remoteNames) {
|
||||
const remoteConfig = await configService.getConfigForRemote(remoteName);
|
||||
|
||||
if (remoteConfig.apiUrl !== workspaceUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const token = remoteConfig.twentyCLIAccessToken ?? remoteConfig.apiKey;
|
||||
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const response = await fetch(`${workspaceUrl}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: '{ currentWorkspace { id } }',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data?: { currentWorkspace?: { id: string } };
|
||||
errors?: unknown[];
|
||||
};
|
||||
|
||||
if (isDefined(body.data?.currentWorkspace) && !body.errors) {
|
||||
ConfigService.setActiveRemote(remoteName);
|
||||
await configService.setDefaultRemote(remoteName);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async authenticateWithDevKey(workspaceUrl: string): Promise<boolean> {
|
||||
await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'confirm',
|
||||
message: 'Press Enter to open the browser for authentication...',
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
const result = await authLogin({
|
||||
apiKey: DEV_API_KEY,
|
||||
apiUrl: workspaceUrl,
|
||||
const result = await authLoginOAuth({
|
||||
apiUrl: serverUrl,
|
||||
remote: 'local',
|
||||
});
|
||||
|
||||
@@ -567,68 +426,21 @@ export class CreateAppCommand {
|
||||
const configService = new ConfigService();
|
||||
|
||||
await configService.setDefaultRemote('local');
|
||||
this.logDetail('Authenticated as tim@apple.dev (development API key)');
|
||||
|
||||
return true;
|
||||
} else {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'Authentication failed. Run `yarn twenty remote add --local` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Authentication failed. Run `yarn twenty remote add --local` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Authentication failed. Run `yarn twenty remote add --local` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private deriveRemoteName(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/\./g, '-');
|
||||
} catch {
|
||||
return 'remote';
|
||||
}
|
||||
}
|
||||
|
||||
private async authenticateWithOAuth(workspaceUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const remoteName = this.deriveRemoteName(workspaceUrl);
|
||||
|
||||
ConfigService.setActiveRemote(remoteName);
|
||||
|
||||
this.logDetail('Opening browser for OAuth...');
|
||||
|
||||
const result = await authLoginOAuth({ apiUrl: workspaceUrl });
|
||||
|
||||
if (result.success) {
|
||||
const configService = new ConfigService();
|
||||
|
||||
await configService.setDefaultRemote(remoteName);
|
||||
this.logDetail(`Authenticated via OAuth to ${workspaceUrl}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` OAuth failed: ${result.error.message}\n` +
|
||||
` Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
|
||||
),
|
||||
);
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` Authentication failed. Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
|
||||
'Authentication failed. Run `yarn twenty remote add` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -638,13 +450,12 @@ export class CreateAppCommand {
|
||||
|
||||
private logSuccess(
|
||||
appDirectory: string,
|
||||
workspaceUrl: string,
|
||||
hasLocalServer: boolean,
|
||||
authSucceeded: boolean,
|
||||
): void {
|
||||
const dirName = basename(appDirectory);
|
||||
|
||||
console.log(chalk.green('\n✔ Application created successfully!\n'));
|
||||
|
||||
console.log(chalk.white(' Next steps:\n'));
|
||||
|
||||
let stepNumber = 1;
|
||||
@@ -654,21 +465,17 @@ export class CreateAppCommand {
|
||||
stepNumber++;
|
||||
|
||||
if (!authSucceeded) {
|
||||
const remoteCommand = hasLocalServer
|
||||
? 'yarn twenty remote add --local'
|
||||
: 'yarn twenty remote add';
|
||||
|
||||
console.log(chalk.white(` ${stepNumber}. Connect to a Twenty instance`));
|
||||
console.log(
|
||||
chalk.cyan(
|
||||
' yarn twenty remote add --api-url <your-instance-url>\n',
|
||||
),
|
||||
);
|
||||
console.log(chalk.cyan(` ${remoteCommand}\n`));
|
||||
stepNumber++;
|
||||
}
|
||||
|
||||
console.log(chalk.white(` ${stepNumber}. Start developing`));
|
||||
console.log(chalk.cyan(' yarn twenty dev\n'));
|
||||
stepNumber++;
|
||||
|
||||
console.log(chalk.white(` ${stepNumber}. Open your twenty instance`));
|
||||
console.log(chalk.cyan(` ${workspaceUrl}\n`));
|
||||
|
||||
console.log(
|
||||
chalk.gray(
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const DOCKER_DOWNLOAD_URLS: Record<string, string> = {
|
||||
darwin: 'https://docs.docker.com/desktop/setup/install/mac-install/',
|
||||
win32: 'https://docs.docker.com/desktop/setup/install/windows-install/',
|
||||
linux: 'https://docs.docker.com/engine/install/',
|
||||
};
|
||||
|
||||
export const isDockerInstalled = (): boolean => {
|
||||
try {
|
||||
execSync('docker --version', { stdio: 'ignore' });
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDockerInstallInstructions = (): string => {
|
||||
const url =
|
||||
DOCKER_DOWNLOAD_URLS[process.platform] ?? DOCKER_DOWNLOAD_URLS.linux;
|
||||
|
||||
return [
|
||||
' Docker is required but not installed.',
|
||||
'',
|
||||
` Install Docker: ${url}`,
|
||||
'',
|
||||
' Then run this command again.',
|
||||
'',
|
||||
' Alternatively, connect to an existing Twenty instance:',
|
||||
' npx create-twenty-app@latest my-twenty-app --workspace-url <your-instance-url>',
|
||||
].join('\n');
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const TWENTY_REPO_OWNER = 'twentyhq';
|
||||
const TWENTY_REPO_NAME = 'twenty';
|
||||
const TWENTY_FALLBACK_REF = 'main';
|
||||
const TWENTY_EXAMPLES_PATH = 'packages/twenty-apps/examples';
|
||||
const TWENTY_EXAMPLES_URL = `https://github.com/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/tree/${TWENTY_FALLBACK_REF}/${TWENTY_EXAMPLES_PATH}`;
|
||||
|
||||
// Fetches the latest release tag from the repo, or falls back to main
|
||||
const resolveRef = async (): Promise<string> => {
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/releases/latest`,
|
||||
{ headers: { Accept: 'application/vnd.github.v3+json' } },
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const release = (await response.json()) as { tag_name: string };
|
||||
|
||||
return release.tag_name;
|
||||
}
|
||||
|
||||
return TWENTY_FALLBACK_REF;
|
||||
};
|
||||
|
||||
// Uses the GitHub Contents API to list directories — fast and doesn't download the repo
|
||||
const fetchGitHubDirectoryContents = async (
|
||||
path: string,
|
||||
ref: string,
|
||||
): Promise<{ name: string; type: string }[] | null> => {
|
||||
const apiUrl = `https://api.github.com/repos/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/contents/${path}?ref=${ref}`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: { Accept: 'application/vnd.github.v3+json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data as { name: string; type: string }[];
|
||||
};
|
||||
|
||||
const listAvailableExamples = async (ref: string): Promise<string[]> => {
|
||||
const contents = await fetchGitHubDirectoryContents(
|
||||
TWENTY_EXAMPLES_PATH,
|
||||
ref,
|
||||
);
|
||||
|
||||
if (!contents) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return contents
|
||||
.filter((entry) => entry.type === 'dir')
|
||||
.map((entry) => entry.name);
|
||||
};
|
||||
|
||||
const validateExampleExists = async (
|
||||
exampleName: string,
|
||||
ref: string,
|
||||
): Promise<void> => {
|
||||
const examplePath = `${TWENTY_EXAMPLES_PATH}/${exampleName}`;
|
||||
|
||||
const contents = await fetchGitHubDirectoryContents(examplePath, ref);
|
||||
|
||||
if (contents !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const availableExamples = await listAvailableExamples(ref);
|
||||
|
||||
throw new Error(
|
||||
`Example "${exampleName}" not found.\n\n` +
|
||||
(availableExamples.length > 0
|
||||
? `Available examples:\n${availableExamples.map((name) => ` - ${name}`).join('\n')}\n\n`
|
||||
: '') +
|
||||
`Browse all examples: ${TWENTY_EXAMPLES_URL}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const downloadExample = async (
|
||||
exampleName: string,
|
||||
targetDirectory: string,
|
||||
): Promise<void> => {
|
||||
if (
|
||||
exampleName.includes('/') ||
|
||||
exampleName.includes('\\') ||
|
||||
exampleName.includes('..')
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid example name: "${exampleName}". Example names must be simple directory names (e.g., "hello-world").`,
|
||||
);
|
||||
}
|
||||
|
||||
const ref = await resolveRef();
|
||||
const examplePath = `${TWENTY_EXAMPLES_PATH}/${exampleName}`;
|
||||
|
||||
console.log(chalk.gray(`Resolving examples from ref '${ref}'...`));
|
||||
|
||||
await validateExampleExists(exampleName, ref);
|
||||
|
||||
console.log(chalk.gray(`Example '${examplePath}' validated successfully.`));
|
||||
|
||||
const tarballUrl = `https://codeload.github.com/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/tar.gz/${ref}`;
|
||||
|
||||
const tempDir = join(
|
||||
tmpdir(),
|
||||
`create-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await fs.ensureDir(tempDir);
|
||||
|
||||
console.log(chalk.gray(`Downloading tarball from ${tarballUrl}...`));
|
||||
|
||||
const response = await fetch(tarballUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(
|
||||
`Could not find repository: ${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME} (ref: ${ref})`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to download from GitHub: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(chalk.gray('Tarball downloaded. Writing to disk...'));
|
||||
|
||||
const tarballPath = join(tempDir, 'archive.tar.gz');
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
|
||||
await fs.writeFile(tarballPath, buffer);
|
||||
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`Tarball saved (${(buffer.length / 1024 / 1024).toFixed(1)} MB). Extracting...`,
|
||||
),
|
||||
);
|
||||
|
||||
execSync(`tar xzf "${tarballPath}" -C "${tempDir}"`, {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
// GitHub tarballs extract to a directory named {repo}-{ref}/
|
||||
const extractedEntries = await fs.readdir(tempDir);
|
||||
const extractedDir = extractedEntries.find(
|
||||
(entry) => entry !== 'archive.tar.gz',
|
||||
);
|
||||
|
||||
if (!extractedDir) {
|
||||
throw new Error('Failed to extract archive: no directory found');
|
||||
}
|
||||
|
||||
const sourcePath = join(tempDir, extractedDir, examplePath);
|
||||
|
||||
if (!(await fs.pathExists(sourcePath))) {
|
||||
throw new Error(
|
||||
`Example directory not found in archive: "${examplePath}"`,
|
||||
);
|
||||
}
|
||||
|
||||
await fs.copy(sourcePath, targetDirectory);
|
||||
} finally {
|
||||
await fs.remove(tempDir);
|
||||
}
|
||||
};
|
||||
@@ -28,6 +28,12 @@ yarn install
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
Or use it as a template via create-twenty-app:
|
||||
|
||||
```bash
|
||||
npx create-twenty-app@latest my-app --example postcard
|
||||
```
|
||||
|
||||
## Learn more
|
||||
|
||||
- [Getting Started](https://docs.twenty.com/developers/extend/apps/getting-started)
|
||||
|
||||
@@ -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,173 +0,0 @@
|
||||
# Discord for Twenty
|
||||
|
||||
Connect Discord to your Twenty workflows. Post messages, edit them, react,
|
||||
delete, and browse channels — all from the workflow builder or the AI chat.
|
||||
|
||||
## What you can do
|
||||
|
||||
Once installed and the admin has provided the bot token, five tools become
|
||||
available in workflows and the AI chat:
|
||||
|
||||
- **Send Discord message** — *"post 'New deal closed 🎉' to #general"*. As a
|
||||
workflow step: provide `channelId` + `messageText`. Optional
|
||||
`replyToMessageId` turns it into a reply to a previous message.
|
||||
- **Update Discord message** — edit a message the bot already sent. Useful
|
||||
for status messages that change as a workflow progresses.
|
||||
- **Delete Discord message** — remove a bot message (for example after a
|
||||
mistake or when a workflow is cancelled).
|
||||
- **Add Discord reaction** — react with a unicode emoji (`👍`, `✅`, `🎉`)
|
||||
or a custom server emoji (`partyparrot:643452342342342342`). Great for
|
||||
signalling status at a glance.
|
||||
- **List Discord channels** — discovers the text-postable channels in a
|
||||
Discord server, useful as the first step in a workflow that picks a
|
||||
channel dynamically. **Leave `guildId` blank if your bot is in only one
|
||||
server — it auto-picks.** If the bot is in multiple servers, the error
|
||||
response lists each server's name and ID so you can copy the right one.
|
||||
|
||||
## Installing
|
||||
|
||||
1. Open **Settings → Applications** in your Twenty workspace.
|
||||
2. Find **Discord** in the available apps and click **Install**.
|
||||
3. Use any of the tools above in a workflow step or via the AI chat.
|
||||
|
||||
> **Heads up:** if you see *"Discord is not configured"* on the first run,
|
||||
> your Twenty admin needs to follow the [Self-hosting setup](#self-hosting-setup-admin-only)
|
||||
> section below — they need to create a Discord application and provide the
|
||||
> bot token before the tools work.
|
||||
|
||||
## Finding channel and message IDs
|
||||
|
||||
Discord IDs are long numeric strings (e.g. `1234567890123456789`). To copy
|
||||
them from the Discord client:
|
||||
|
||||
1. **Enable Developer Mode** once: **User Settings → Advanced → Developer
|
||||
Mode** (toggle on). Discord then shows extra "Copy ID" options on
|
||||
right-click.
|
||||
2. **Channel ID** — right-click any channel → *Copy Channel ID*.
|
||||
3. **Message ID** — right-click any message → *Copy Message ID*.
|
||||
4. **Server (guild) ID** — right-click the server icon → *Copy Server ID*.
|
||||
Only needed for `discord-list-channels` when your bot is in multiple
|
||||
servers (otherwise leave it blank for auto-pick).
|
||||
|
||||
> **Tip:** when chaining steps, pass `messageId` from a previous *Send
|
||||
> Discord message* step directly into a follow-up *Update / Delete / Add
|
||||
> Reaction* step. No copy/paste needed.
|
||||
|
||||
## Limitations
|
||||
|
||||
What this connector intentionally does **not** support in v1:
|
||||
|
||||
- **Direct messages to users.** Discord's DM API needs a different identity
|
||||
flow.
|
||||
- **Threads.** Replying inside a thread or starting a new one isn't exposed.
|
||||
- **Webhooks.** Uses the bot REST API only.
|
||||
- **Slash commands / interactions.** The bot doesn't register or respond to
|
||||
`/commands`.
|
||||
- **Per-workspace identity.** All Twenty workspaces in the same Twenty
|
||||
deployment share the same Discord bot — see
|
||||
[Why bot token instead of OAuth?](#why-bot-token-instead-of-oauth) below.
|
||||
- **2000-character message limit.** Discord rejects longer payloads with
|
||||
HTTP 400.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| `Discord is not configured` | `DISCORD_BOT_TOKEN` not set | Admin: paste the bot token in Settings → Applications → Discord → **Settings tab** |
|
||||
| `401 Unauthorized` | Token wrong, copied with whitespace, or rotated | Reset the token in the Discord Developer Portal and re-paste |
|
||||
| `Missing Access (50001)` | Bot wasn't invited to that server | Re-run the OAuth invite URL for that server (see admin setup) |
|
||||
| `Missing Permissions (50013)` | Bot is in the server but lacks Send / Manage / React permissions | In Discord: server settings → roles → grant the bot's role the missing permission |
|
||||
| `Unknown Channel (10003)` | `channelId` is wrong, or bot can't see that channel | Verify the ID; check the bot has *View Channel* permission |
|
||||
| `Unknown Message (10008)` | `messageId` is wrong, or message was deleted | Verify the ID is from a still-existing message |
|
||||
| `Cannot edit a message authored by another user (50005)` | Trying to update / delete a message the bot didn't send | The bot can only edit its own messages; it can delete others' messages only with the *Manage Messages* permission |
|
||||
| `Bot is in N Discord servers — please specify which one` (from list-channels) | Bot is in multiple servers and `guildId` was left blank | Copy the server ID from the error message and paste it into the `guildId` field |
|
||||
| `Discord API responded with 429` | Hit Discord's rate limit | Reduce workflow concurrency; this connector doesn't yet auto-retry |
|
||||
|
||||
---
|
||||
|
||||
## Self-hosting setup (admin-only)
|
||||
|
||||
This section is for Twenty server admins. If you're on Twenty Cloud, skip
|
||||
this — the bot credentials are already configured.
|
||||
|
||||
### 1. Create a Discord application
|
||||
|
||||
1. Visit https://discord.com/developers/applications and click
|
||||
**New Application**.
|
||||
2. Name it (e.g. *"Twenty Bot"*) and create. Use a dedicated app — don't
|
||||
reuse one configured for other bots.
|
||||
3. Sidebar → **Bot** tab → **Reset Token** → copy the token immediately
|
||||
(it's only shown once; if you lose it, reset again).
|
||||
4. *Recommended:* toggle **Public Bot** OFF so randoms can't invite the
|
||||
bot to their servers.
|
||||
|
||||
### 2. Generate the invite URL and add the bot to your server(s)
|
||||
|
||||
1. In the Discord Developer Portal: sidebar → **OAuth2** → **URL Generator**.
|
||||
2. **Scopes**: tick `bot`.
|
||||
3. **Bot Permissions**: tick `Send Messages`, `Manage Messages`,
|
||||
`Add Reactions`, `Read Message History`.
|
||||
4. Copy the generated URL.
|
||||
5. Open the URL in your browser, pick the Discord server, authorize.
|
||||
Repeat for each server you want workflows to post in.
|
||||
|
||||
### 3. Wire the bot token into Twenty
|
||||
|
||||
1. In Twenty: **Settings → Applications → Discord → Settings tab**
|
||||
(gear icon).
|
||||
2. Paste the bot token into the `DISCORD_BOT_TOKEN` row. Saves on blur.
|
||||
|
||||
Workspace users can now use the Discord workflow tools immediately — no
|
||||
further per-user configuration needed (which is unique vs Linear / Slack
|
||||
where each user connects their own account).
|
||||
|
||||
---
|
||||
|
||||
## Why bot token instead of OAuth?
|
||||
|
||||
Slack and Linear use OAuth-per-workspace
|
||||
(`defineConnectionProvider({ type: 'oauth' })`) so each Twenty workspace
|
||||
stores its own access token. Discord works differently:
|
||||
|
||||
- Discord's `bot` scope **does** have an OAuth flow, but the `access_token`
|
||||
it returns is a *user* bearer token — useless for bot actions like posting
|
||||
messages. To actually send messages as the bot you need the static
|
||||
**bot token** from the Developer Portal.
|
||||
- That bot token is global to the Discord application (and therefore to
|
||||
the Twenty deployment). Discord deprecated per-install bot tokens years
|
||||
ago.
|
||||
- Webhooks are a separate auth model but only support posting — no edit,
|
||||
delete, or reactions — so they don't cover Slack/Linear parity.
|
||||
|
||||
The result: this connector skips `defineConnectionProvider` entirely and
|
||||
reads `DISCORD_BOT_TOKEN` from an `applicationVariable` set once at
|
||||
deployment scope. See
|
||||
[Discord's OAuth2 docs](https://discord.com/developers/docs/topics/oauth2#bot-users)
|
||||
for the underlying reason bot users authenticate via static tokens.
|
||||
|
||||
---
|
||||
|
||||
## Developers only
|
||||
|
||||
If you're working on this app rather than installing the published version:
|
||||
|
||||
```bash
|
||||
cd packages/twenty-apps/internal/twenty-discord
|
||||
|
||||
# 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 Discord REST API (v10) is called directly via `fetch` — no `discord.js`
|
||||
or other SDK dependency. See
|
||||
`src/logic-functions/utils/discord-api-request.ts` for the auth and
|
||||
error-handling wrapper that all handlers go through.
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "twenty-discord",
|
||||
"version": "0.1.0",
|
||||
"description": "Discord 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": {
|
||||
"twenty-sdk": "2.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
"oxlint": "^0.16.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.1.1"
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 127 127"
|
||||
fill="none"
|
||||
role="img"
|
||||
>
|
||||
<title>Discord</title>
|
||||
<path
|
||||
fill="#5865F2"
|
||||
d="M107.7 26.4A104.6 104.6 0 0 0 81.6 18a0.4 0.4 0 0 0-0.4 0.2 73 73 0 0 0-3.2 6.6 96.6 96.6 0 0 0-29 0A66.8 66.8 0 0 0 45.6 18.2a0.4 0.4 0 0 0-0.4-0.2 104.2 104.2 0 0 0-26.1 8.4 0.4 0.4 0 0 0-0.2 0.2C2.3 51.4-2.2 75.7 0 99.7a0.4 0.4 0 0 0 0.2 0.3 105.2 105.2 0 0 0 31.6 16 0.4 0.4 0 0 0 0.5-0.1 75.6 75.6 0 0 0 6.5-10.6 0.4 0.4 0 0 0-0.2-0.5 69.4 69.4 0 0 1-9.9-4.7 0.4 0.4 0 0 1 0-0.7 54.6 54.6 0 0 0 2-1.6 0.4 0.4 0 0 1 0.4-0.1 75 75 0 0 0 63.9 0 0.4 0.4 0 0 1 0.4 0.1c0.6 0.6 1.3 1.1 2 1.6a0.4 0.4 0 0 1 0 0.7 65.2 65.2 0 0 1-9.9 4.7 0.4 0.4 0 0 0-0.2 0.5 84.8 84.8 0 0 0 6.5 10.6 0.4 0.4 0 0 0 0.5 0.1 104.8 104.8 0 0 0 31.6-16 0.4 0.4 0 0 0 0.2-0.3c2.7-27.8-4.5-51.9-18.9-73.1a0.4 0.4 0 0 0-0.2-0.2zM42.5 85a12.3 12.3 0 0 1-11.5-12.9c0-7.1 5-12.9 11.5-12.9 6.6 0 11.7 5.8 11.6 12.9 0 7-5.1 12.9-11.6 12.9zm42.8 0a12.3 12.3 0 0 1-11.5-12.9c0-7.1 5-12.9 11.5-12.9 6.6 0 11.6 5.8 11.5 12.9 0 7-5 12.9-11.5 12.9z"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,30 +0,0 @@
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
DISCORD_BOT_TOKEN_VARIABLE_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Twenty Discord',
|
||||
description:
|
||||
'Connect Discord to Twenty. Workflow steps post, update, and delete bot messages and add reactions using a Discord bot token shared across the deployment.',
|
||||
logoUrl: 'public/twenty-discord.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Communication',
|
||||
aboutDescription:
|
||||
'Official Discord connector for Twenty CRM. Create a Discord application at https://discord.com/developers/applications, copy its bot token into the DISCORD_BOT_TOKEN application variable, then invite the bot to each server you want workflows to post in. Use workflow actions to post, update, or delete bot messages and add reactions.',
|
||||
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: {
|
||||
DISCORD_BOT_TOKEN: {
|
||||
universalIdentifier: DISCORD_BOT_TOKEN_VARIABLE_UNIVERSAL_IDENTIFIER,
|
||||
description:
|
||||
'Bot token from your Discord application (Developer Portal → Bot tab → Reset Token). Used with the `Bot` auth prefix to call the Discord REST API. The same token authenticates the bot across every guild it has been invited to.',
|
||||
isSecret: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { defineCommandMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
SEND_DISCORD_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: SEND_DISCORD_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Send Discord message',
|
||||
shortLabel: 'Discord message',
|
||||
icon: 'IconBrandDiscord',
|
||||
isPinned: false,
|
||||
availabilityType: 'GLOBAL',
|
||||
frontComponentUniversalIdentifier:
|
||||
SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
@@ -1,670 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
closeSidePanel,
|
||||
enqueueSnackbar,
|
||||
unmountFrontComponent,
|
||||
} from 'twenty-sdk/front-component';
|
||||
|
||||
import { SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
type DiscordGuild = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type DiscordChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: number;
|
||||
parentId: string | null;
|
||||
position: number;
|
||||
};
|
||||
|
||||
const DISCORD_MESSAGE_MAX_LENGTH = 2000;
|
||||
|
||||
// The front component sandbox dispatches events with a non-standard shape.
|
||||
// Values may live on e.detail.value, e.value, or e.target.value.
|
||||
const readSerializedValue = (
|
||||
e: React.SyntheticEvent<HTMLElement>,
|
||||
): string | undefined => {
|
||||
const obj = e as {
|
||||
detail?: { value?: string };
|
||||
value?: string;
|
||||
target?: { value?: string };
|
||||
};
|
||||
|
||||
if (typeof obj.detail?.value === 'string') return obj.detail.value;
|
||||
if (typeof obj.value === 'string') return obj.value;
|
||||
if (typeof obj.target?.value === 'string') return obj.target.value;
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const onValueChange =
|
||||
(fn: (value: string) => void) => (e: React.SyntheticEvent<HTMLElement>) => {
|
||||
const v = readSerializedValue(e);
|
||||
|
||||
if (typeof v === 'string') fn(v);
|
||||
};
|
||||
|
||||
const callAppRoute = async (
|
||||
path: string,
|
||||
method: 'GET' | 'POST',
|
||||
body?: Record<string, unknown>,
|
||||
) => {
|
||||
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('API configuration missing');
|
||||
}
|
||||
|
||||
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(() => '');
|
||||
|
||||
throw new Error(
|
||||
`Request failed (${response.status}): ${text.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const COLOR = {
|
||||
bg: '#f1f1f1',
|
||||
card: '#ffffff',
|
||||
surface: '#fcfcfc',
|
||||
border: '#ebebeb',
|
||||
borderStrong: '#d6d6d6',
|
||||
text: '#333333',
|
||||
textSecondary: '#666666',
|
||||
textTertiary: '#999999',
|
||||
placeholder: '#cccccc',
|
||||
accent: '#5865f2',
|
||||
error: '#e05252',
|
||||
};
|
||||
|
||||
const INLINE_SELECT: React.CSSProperties = {
|
||||
appearance: 'none',
|
||||
WebkitAppearance: 'none' as const,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
padding: 0,
|
||||
};
|
||||
|
||||
const STYLES = {
|
||||
outer: {
|
||||
fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
|
||||
fontSize: '13px',
|
||||
backgroundColor: COLOR.bg,
|
||||
padding: '12px',
|
||||
height: '100%',
|
||||
boxSizing: 'border-box' as const,
|
||||
},
|
||||
container: {
|
||||
backgroundColor: COLOR.card,
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${COLOR.border}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
height: '100%',
|
||||
boxSizing: 'border-box' as const,
|
||||
color: COLOR.text,
|
||||
},
|
||||
topBar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
borderBottom: `1px solid ${COLOR.border}`,
|
||||
flexShrink: 0,
|
||||
},
|
||||
guildSelect: {
|
||||
appearance: 'none' as const,
|
||||
WebkitAppearance: 'none' as const,
|
||||
background: COLOR.surface,
|
||||
border: `1px solid ${COLOR.border}`,
|
||||
borderRadius: '6px',
|
||||
padding: '3px 8px',
|
||||
color: COLOR.text,
|
||||
fontSize: '12px',
|
||||
fontFamily: 'inherit',
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
},
|
||||
channelPicker: {
|
||||
flexShrink: 0,
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
borderBottom: `1px solid ${COLOR.border}`,
|
||||
},
|
||||
channelChip: {
|
||||
appearance: 'none' as const,
|
||||
WebkitAppearance: 'none' as const,
|
||||
background: 'transparent',
|
||||
border: `1px solid ${COLOR.border}`,
|
||||
borderRadius: '20px',
|
||||
padding: '3px 9px',
|
||||
color: COLOR.textSecondary,
|
||||
fontSize: '12px',
|
||||
fontFamily: 'inherit',
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
display: 'inline-flex' as const,
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
lineHeight: '16px',
|
||||
},
|
||||
chipIcon: {
|
||||
fontSize: '12px',
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
color: COLOR.textTertiary,
|
||||
},
|
||||
optionsLoadingIndicator: {
|
||||
marginLeft: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
spinner: {
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
border: `2px solid ${COLOR.border}`,
|
||||
borderTopColor: COLOR.textSecondary,
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.6s linear infinite',
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
padding: '24px',
|
||||
overflow: 'auto',
|
||||
minHeight: 0,
|
||||
gap: '6px',
|
||||
},
|
||||
messageInput: {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: COLOR.text,
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
width: '100%',
|
||||
padding: 0,
|
||||
resize: 'none' as const,
|
||||
flex: 1,
|
||||
minHeight: '120px',
|
||||
},
|
||||
charCount: {
|
||||
fontSize: '11px',
|
||||
textAlign: 'right' as const,
|
||||
color: COLOR.textTertiary,
|
||||
flexShrink: 0,
|
||||
},
|
||||
charCountOverLimit: {
|
||||
color: COLOR.error,
|
||||
},
|
||||
actionBar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '6px',
|
||||
padding: '6px 12px',
|
||||
borderTop: `1px solid ${COLOR.border}`,
|
||||
flexShrink: 0,
|
||||
},
|
||||
cancelButton: {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: COLOR.textSecondary,
|
||||
fontSize: '12px',
|
||||
fontFamily: 'inherit',
|
||||
cursor: 'pointer',
|
||||
padding: '5px 10px',
|
||||
borderRadius: '6px',
|
||||
},
|
||||
sendButton: {
|
||||
background: COLOR.accent,
|
||||
border: 'none',
|
||||
color: '#ffffff',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
fontFamily: 'inherit',
|
||||
cursor: 'pointer',
|
||||
padding: '5px 14px',
|
||||
borderRadius: '6px',
|
||||
},
|
||||
sendButtonDisabled: {
|
||||
background: COLOR.borderStrong,
|
||||
color: COLOR.textTertiary,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
successOuter: {
|
||||
fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
|
||||
backgroundColor: COLOR.bg,
|
||||
padding: '12px',
|
||||
height: '100%',
|
||||
boxSizing: 'border-box' as const,
|
||||
},
|
||||
successContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '10px 14px',
|
||||
boxSizing: 'border-box' as const,
|
||||
backgroundColor: COLOR.card,
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${COLOR.border}`,
|
||||
color: COLOR.text,
|
||||
},
|
||||
successCheck: {
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#2ea043',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '11px',
|
||||
color: '#fff',
|
||||
flexShrink: 0,
|
||||
},
|
||||
successText: {
|
||||
color: COLOR.text,
|
||||
fontWeight: 500,
|
||||
fontSize: '13px',
|
||||
flex: 1,
|
||||
overflow: 'hidden' as const,
|
||||
textOverflow: 'ellipsis' as const,
|
||||
whiteSpace: 'nowrap' as const,
|
||||
},
|
||||
closeButton: {
|
||||
background: 'transparent',
|
||||
border: `1px solid ${COLOR.border}`,
|
||||
color: COLOR.textSecondary,
|
||||
fontSize: '12px',
|
||||
fontFamily: 'inherit',
|
||||
cursor: 'pointer',
|
||||
padding: '5px 14px',
|
||||
borderRadius: '6px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
loadingOuter: {
|
||||
fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
|
||||
backgroundColor: COLOR.bg,
|
||||
padding: '12px',
|
||||
height: '100%',
|
||||
boxSizing: 'border-box' as const,
|
||||
},
|
||||
loadingContainer: {
|
||||
color: COLOR.textSecondary,
|
||||
fontSize: '13px',
|
||||
textAlign: 'center' as const,
|
||||
backgroundColor: COLOR.card,
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${COLOR.border}`,
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
errorOuter: {
|
||||
fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
|
||||
backgroundColor: COLOR.bg,
|
||||
padding: '12px',
|
||||
height: '100%',
|
||||
boxSizing: 'border-box' as const,
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: COLOR.card,
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${COLOR.border}`,
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '10px',
|
||||
},
|
||||
errorText: {
|
||||
color: COLOR.error,
|
||||
fontSize: '12px',
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
retryButton: {
|
||||
background: COLOR.surface,
|
||||
border: `1px solid ${COLOR.border}`,
|
||||
color: COLOR.text,
|
||||
fontSize: '12px',
|
||||
fontFamily: 'inherit',
|
||||
cursor: 'pointer',
|
||||
padding: '5px 12px',
|
||||
borderRadius: '6px',
|
||||
},
|
||||
};
|
||||
|
||||
const SendMessageForm = () => {
|
||||
const [guilds, setGuilds] = useState<DiscordGuild[]>([]);
|
||||
const [guildsLoading, setGuildsLoading] = useState(true);
|
||||
const [guildsError, setGuildsError] = useState<string | null>(null);
|
||||
|
||||
const [channels, setChannels] = useState<DiscordChannel[]>([]);
|
||||
const [channelsLoading, setChannelsLoading] = useState(false);
|
||||
|
||||
const [selectedGuildId, setSelectedGuildId] = useState('');
|
||||
const [selectedChannelId, setSelectedChannelId] = useState('');
|
||||
const [messageText, setMessageText] = useState('');
|
||||
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sentChannelName, setSentChannelName] = useState<string | null>(null);
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
||||
const fetchChannels = useCallback(async (guildId: string) => {
|
||||
if (!guildId) {
|
||||
setChannels([]);
|
||||
setSelectedChannelId('');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setChannelsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await callAppRoute(
|
||||
`/discord/channels?guildId=${encodeURIComponent(guildId)}`,
|
||||
'GET',
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
setChannels([]);
|
||||
await enqueueSnackbar({
|
||||
message: result.error ?? 'Failed to load channels',
|
||||
variant: 'error',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setChannels(result.channels ?? []);
|
||||
} catch (error) {
|
||||
setChannels([]);
|
||||
await enqueueSnackbar({
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Failed to load channels',
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setChannelsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleGuildChange = useCallback(
|
||||
(newGuildId: string) => {
|
||||
setSelectedGuildId(newGuildId);
|
||||
setSelectedChannelId('');
|
||||
fetchChannels(newGuildId);
|
||||
},
|
||||
[fetchChannels],
|
||||
);
|
||||
|
||||
const fetchGuilds = useCallback(async () => {
|
||||
try {
|
||||
setGuildsError(null);
|
||||
const result = await callAppRoute('/discord/guilds', 'GET');
|
||||
|
||||
if (!result.success) {
|
||||
setGuildsError(result.error ?? 'Failed to load Discord servers');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchedGuilds: DiscordGuild[] = result.guilds ?? [];
|
||||
|
||||
setGuilds(fetchedGuilds);
|
||||
|
||||
// Auto-select when the bot is in exactly one server to skip a needless step.
|
||||
if (fetchedGuilds.length === 1) {
|
||||
setSelectedGuildId(fetchedGuilds[0].id);
|
||||
fetchChannels(fetchedGuilds[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
setGuildsError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to load Discord servers',
|
||||
);
|
||||
} finally {
|
||||
setGuildsLoading(false);
|
||||
}
|
||||
}, [fetchChannels]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGuilds();
|
||||
}, [fetchGuilds]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmedMessage = (messageText ?? '').trim();
|
||||
|
||||
if (
|
||||
!selectedGuildId ||
|
||||
!selectedChannelId ||
|
||||
trimmedMessage.length === 0 ||
|
||||
trimmedMessage.length > DISCORD_MESSAGE_MAX_LENGTH
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
const result = await callAppRoute('/discord/messages', 'POST', {
|
||||
channelId: selectedChannelId,
|
||||
messageText: trimmedMessage,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
await enqueueSnackbar({
|
||||
message: result.error ?? 'Failed to send message',
|
||||
variant: 'error',
|
||||
});
|
||||
setSending(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const channelName =
|
||||
channels.find((channel) => channel.id === selectedChannelId)?.name ??
|
||||
'channel';
|
||||
|
||||
setSentChannelName(channelName);
|
||||
await enqueueSnackbar({
|
||||
message: `Message sent to #${channelName}`,
|
||||
variant: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
await enqueueSnackbar({
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Failed to send message',
|
||||
variant: 'error',
|
||||
});
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setClosing(true);
|
||||
unmountFrontComponent();
|
||||
closeSidePanel();
|
||||
};
|
||||
|
||||
if (closing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sentChannelName) {
|
||||
return (
|
||||
<div style={STYLES.successOuter}>
|
||||
<div style={STYLES.successContainer}>
|
||||
<div style={STYLES.successCheck}>{'✓'}</div>
|
||||
<span style={STYLES.successText}>
|
||||
Message sent to #{sentChannelName}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
style={STYLES.closeButton}
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (guildsLoading) {
|
||||
return (
|
||||
<div style={STYLES.loadingOuter}>
|
||||
<div style={STYLES.loadingContainer}>Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (guildsError) {
|
||||
return (
|
||||
<div style={STYLES.errorOuter}>
|
||||
<div style={STYLES.errorContainer}>
|
||||
<p style={STYLES.errorText}>{guildsError}</p>
|
||||
<button
|
||||
type="button"
|
||||
style={STYLES.retryButton}
|
||||
onClick={fetchGuilds}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedLength = (messageText ?? '').trim().length;
|
||||
const isOverLimit = (messageText ?? '').length > DISCORD_MESSAGE_MAX_LENGTH;
|
||||
const canSubmit =
|
||||
!!selectedGuildId &&
|
||||
!!selectedChannelId &&
|
||||
trimmedLength > 0 &&
|
||||
!isOverLimit &&
|
||||
!sending;
|
||||
|
||||
return (
|
||||
<div style={STYLES.outer}>
|
||||
<style>{'@keyframes spin{to{transform:rotate(360deg)}}'}</style>
|
||||
<div style={STYLES.container}>
|
||||
<div style={STYLES.topBar}>
|
||||
<select
|
||||
value={selectedGuildId}
|
||||
onChange={onValueChange(handleGuildChange)}
|
||||
style={STYLES.guildSelect}
|
||||
>
|
||||
<option value="">Select server...</option>
|
||||
{guilds.map((guild) => (
|
||||
<option key={guild.id} value={guild.id}>
|
||||
{guild.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{channelsLoading && (
|
||||
<div style={STYLES.optionsLoadingIndicator}>
|
||||
<div style={STYLES.spinner} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={STYLES.channelPicker}>
|
||||
<label style={STYLES.channelChip}>
|
||||
<span style={STYLES.chipIcon}>{'#'}</span>
|
||||
<select
|
||||
value={selectedChannelId}
|
||||
onChange={onValueChange(setSelectedChannelId)}
|
||||
style={INLINE_SELECT}
|
||||
disabled={!selectedGuildId || channelsLoading}
|
||||
>
|
||||
<option value="">
|
||||
{channels.length === 0 ? 'No channels' : 'Select channel'}
|
||||
</option>
|
||||
{channels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{channel.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={STYLES.body}>
|
||||
<textarea
|
||||
value={messageText}
|
||||
onInput={onValueChange(setMessageText)}
|
||||
onChange={onValueChange(setMessageText)}
|
||||
style={STYLES.messageInput}
|
||||
placeholder="Type a message..."
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
...STYLES.charCount,
|
||||
...(isOverLimit ? STYLES.charCountOverLimit : {}),
|
||||
}}
|
||||
>
|
||||
{(messageText ?? '').length} / {DISCORD_MESSAGE_MAX_LENGTH}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={STYLES.actionBar}>
|
||||
<button
|
||||
type="button"
|
||||
style={STYLES.cancelButton}
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
...STYLES.sendButton,
|
||||
...(!canSubmit ? STYLES.sendButtonDisabled : {}),
|
||||
}}
|
||||
disabled={!canSubmit}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{sending ? 'Sending...' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'send-discord-message-form',
|
||||
description:
|
||||
'Form to send a message to a Discord channel: pick the server, pick the channel, type the message, send.',
|
||||
component: SendMessageForm,
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
export const APPLICATION_UNIVERSAL_IDENTIFIER =
|
||||
'9b1b7628-ac98-4e44-8e1e-91b401378a5f';
|
||||
|
||||
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
|
||||
'6aa50eb2-e8c2-470f-8c4d-09debbf6e051';
|
||||
|
||||
export const DISCORD_BOT_TOKEN_VARIABLE_UNIVERSAL_IDENTIFIER =
|
||||
'ac986a3b-19a5-4cee-a87a-2f8d3885ad84';
|
||||
|
||||
export const DISCORD_POST_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'6c03d456-c1fa-4acc-9928-d97ced0925fe';
|
||||
|
||||
export const DISCORD_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'b37285d9-cb11-4fe5-963d-0434780122ba';
|
||||
|
||||
export const DISCORD_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'a2efc27c-eae0-4713-b327-bdb1aaeff95f';
|
||||
|
||||
export const DISCORD_ADD_REACTION_UNIVERSAL_IDENTIFIER =
|
||||
'32c77c8a-ebd0-4308-aa1b-d21bb94422ac';
|
||||
|
||||
export const DISCORD_LIST_CHANNELS_UNIVERSAL_IDENTIFIER =
|
||||
'ef2296f6-1b48-4edd-81ac-cafa4fe73f99';
|
||||
|
||||
export const DISCORD_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER =
|
||||
'550cd993-af6e-46ae-b697-f838f34cfe39';
|
||||
|
||||
export const DISCORD_LIST_GUILDS_UNIVERSAL_IDENTIFIER =
|
||||
'0aaaca4f-3156-4b7c-a9f5-ee6a2e17cbf1';
|
||||
|
||||
export const DISCORD_LIST_GUILDS_ROUTE_UNIVERSAL_IDENTIFIER =
|
||||
'3cfa36d9-3c04-495c-bc09-f8019ad85dce';
|
||||
|
||||
export const DISCORD_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER =
|
||||
'eda60c95-450b-4a86-b5a0-6b85f488ae81';
|
||||
|
||||
export const SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
|
||||
'ee8d8efa-2f9d-4d2c-b759-bb3cafc7797d';
|
||||
|
||||
export const SEND_DISCORD_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER =
|
||||
'8c80b9d8-2f18-4b6c-8933-30d170790685';
|
||||
@@ -1,35 +0,0 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { DISCORD_ADD_REACTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { discordAddReactionHandler } from 'src/logic-functions/handlers/discord-add-reaction-handler';
|
||||
import { discordAddReactionInputSchema } from './schemas/discord-add-reaction-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: DISCORD_ADD_REACTION_UNIVERSAL_IDENTIFIER,
|
||||
name: 'discord-add-reaction',
|
||||
description:
|
||||
'Add an emoji reaction to a Discord message (for example a checkmark ✅) so the channel can see status at a glance.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: discordAddReactionInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Add Discord Reaction',
|
||||
icon: 'IconBrandDiscord',
|
||||
inputSchema: jsonSchemaToInputSchema(discordAddReactionInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
messageId: { type: 'string' },
|
||||
channelId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: discordAddReactionHandler,
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { DISCORD_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { discordDeleteMessageHandler } from 'src/logic-functions/handlers/discord-delete-message-handler';
|
||||
import { discordDeleteMessageInputSchema } from './schemas/discord-delete-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: DISCORD_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'discord-delete-message',
|
||||
description:
|
||||
'Remove a Discord message this bot sent (for example after a mistake or when a workflow is cancelled).',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: discordDeleteMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Delete Discord Message',
|
||||
icon: 'IconBrandDiscord',
|
||||
inputSchema: jsonSchemaToInputSchema(discordDeleteMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
messageId: { type: 'string' },
|
||||
channelId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: discordDeleteMessageHandler,
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { RoutePayload } from 'twenty-sdk/define';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
|
||||
import { DISCORD_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { discordListChannelsHandler } from 'src/logic-functions/handlers/discord-list-channels-handler';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const params = event.queryStringParameters ?? {};
|
||||
const guildId =
|
||||
typeof params.guildId === 'string' && params.guildId.length > 0
|
||||
? params.guildId
|
||||
: undefined;
|
||||
|
||||
return discordListChannelsHandler({ guildId });
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: DISCORD_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'discord-list-channels-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/discord/channels',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { DISCORD_LIST_CHANNELS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { discordListChannelsHandler } from 'src/logic-functions/handlers/discord-list-channels-handler';
|
||||
import { discordListChannelsInputSchema } from './schemas/discord-list-channels-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: DISCORD_LIST_CHANNELS_UNIVERSAL_IDENTIFIER,
|
||||
name: 'discord-list-channels',
|
||||
description:
|
||||
'List the text-postable channels (types `GUILD_TEXT` and `GUILD_ANNOUNCEMENT`) in a Discord server, sorted by Discord position. Each entry exposes `id`, `name`, `type`, `parentId` (category), and `position`. Pass the server (guild) ID to scope the listing; if omitted and the bot is in exactly one server, that server is auto-selected. If the bot is in multiple servers, the error response lists each server name and ID for explicit selection. Voice channels, threads, forums, and categories are excluded.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: discordListChannelsInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'List Discord Channels',
|
||||
icon: 'IconBrandDiscord',
|
||||
inputSchema: jsonSchemaToInputSchema(discordListChannelsInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
channels: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
type: { type: 'number' },
|
||||
parentId: { type: 'string' },
|
||||
position: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
count: { type: 'number' },
|
||||
error: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: discordListChannelsHandler,
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/define';
|
||||
|
||||
import { DISCORD_LIST_GUILDS_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { discordListGuildsHandler } from 'src/logic-functions/handlers/discord-list-guilds-handler';
|
||||
|
||||
const handler = async (_event: RoutePayload) => {
|
||||
return discordListGuildsHandler();
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: DISCORD_LIST_GUILDS_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'discord-list-guilds-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/discord/guilds',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { DISCORD_LIST_GUILDS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { discordListGuildsHandler } from 'src/logic-functions/handlers/discord-list-guilds-handler';
|
||||
import { discordListGuildsInputSchema } from './schemas/discord-list-guilds-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: DISCORD_LIST_GUILDS_UNIVERSAL_IDENTIFIER,
|
||||
name: 'discord-list-guilds',
|
||||
description:
|
||||
'List the Discord servers (guilds) the bot has been invited to. Each entry exposes `id` and `name`. Use this to discover which servers the bot can post in, then pass the chosen server ID to `discord-list-channels` or other server-scoped actions.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: discordListGuildsInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'List Discord Servers',
|
||||
icon: 'IconBrandDiscord',
|
||||
inputSchema: jsonSchemaToInputSchema(discordListGuildsInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
guilds: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
count: { type: 'number' },
|
||||
error: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: discordListGuildsHandler,
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import type { RoutePayload } from 'twenty-sdk/define';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
|
||||
import { DISCORD_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { discordPostMessageHandler } from 'src/logic-functions/handlers/discord-post-message-handler';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
const rawChannelId = body?.channelId;
|
||||
const rawMessageText = body?.messageText;
|
||||
|
||||
if (typeof rawChannelId !== 'string' || typeof rawMessageText !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to post Discord message',
|
||||
error: '`channelId` and `messageText` must be strings.',
|
||||
};
|
||||
}
|
||||
|
||||
const channelId = rawChannelId.trim();
|
||||
const messageText = rawMessageText.trim();
|
||||
|
||||
if (!isNonEmptyString(channelId) || !isNonEmptyString(messageText)) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to post Discord message',
|
||||
error: '`channelId` and `messageText` are required.',
|
||||
};
|
||||
}
|
||||
|
||||
return discordPostMessageHandler({ channelId, messageText });
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: DISCORD_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'discord-post-message-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/discord/messages',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { DISCORD_POST_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { discordPostMessageHandler } from 'src/logic-functions/handlers/discord-post-message-handler';
|
||||
import { discordPostMessageInputSchema } from './schemas/discord-post-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: DISCORD_POST_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'discord-post-message',
|
||||
description:
|
||||
'Send a message to a Discord channel as the bot. Optionally reply to an existing message using its message ID from a previous step.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: discordPostMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Send Discord Message',
|
||||
icon: 'IconBrandDiscord',
|
||||
inputSchema: jsonSchemaToInputSchema(discordPostMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
messageId: { type: 'string' },
|
||||
channelId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: discordPostMessageHandler,
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { DISCORD_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { discordUpdateMessageHandler } from 'src/logic-functions/handlers/discord-update-message-handler';
|
||||
import { discordUpdateMessageInputSchema } from './schemas/discord-update-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: DISCORD_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'discord-update-message',
|
||||
description:
|
||||
'Change the text of a Discord message this bot already sent. You need the channel ID and the message ID from when it was posted.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: discordUpdateMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Update Discord Message',
|
||||
icon: 'IconBrandDiscord',
|
||||
inputSchema: jsonSchemaToInputSchema(discordUpdateMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
messageId: { type: 'string' },
|
||||
channelId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: discordUpdateMessageHandler,
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { type DiscordAddReactionInput } from 'src/logic-functions/types/discord-add-reaction-input.type';
|
||||
import { type DiscordToolResult } from 'src/logic-functions/types/discord-tool-result.type';
|
||||
import { buildDiscordFailureResult } from 'src/logic-functions/utils/build-discord-failure-result';
|
||||
import { discordApiRequest } from 'src/logic-functions/utils/discord-api-request';
|
||||
import { getDiscordBotToken } from 'src/logic-functions/utils/get-discord-bot-token';
|
||||
|
||||
export const discordAddReactionHandler = async (
|
||||
parameters: DiscordAddReactionInput,
|
||||
): Promise<DiscordToolResult> => {
|
||||
const tokenResult = getDiscordBotToken();
|
||||
|
||||
if (!tokenResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Discord is not configured',
|
||||
error: tokenResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const channelId = parameters.channelId.trim();
|
||||
const messageId = parameters.messageId.trim();
|
||||
const emoji = parameters.emoji.trim();
|
||||
|
||||
const encodedChannelId = encodeURIComponent(channelId);
|
||||
const encodedMessageId = encodeURIComponent(messageId);
|
||||
const encodedEmoji = encodeURIComponent(emoji);
|
||||
|
||||
try {
|
||||
const result = await discordApiRequest({
|
||||
botToken: tokenResult.botToken,
|
||||
method: 'PUT',
|
||||
path: `/channels/${encodedChannelId}/messages/${encodedMessageId}/reactions/${encodedEmoji}/@me`,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to add Discord reaction',
|
||||
error: result.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Reaction "${emoji}" added to the message.`,
|
||||
messageId,
|
||||
channelId,
|
||||
};
|
||||
} catch (error) {
|
||||
return buildDiscordFailureResult('Failed to add Discord reaction', error);
|
||||
}
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import { type DiscordDeleteMessageInput } from 'src/logic-functions/types/discord-delete-message-input.type';
|
||||
import { type DiscordToolResult } from 'src/logic-functions/types/discord-tool-result.type';
|
||||
import { buildDiscordFailureResult } from 'src/logic-functions/utils/build-discord-failure-result';
|
||||
import { discordApiRequest } from 'src/logic-functions/utils/discord-api-request';
|
||||
import { getDiscordBotToken } from 'src/logic-functions/utils/get-discord-bot-token';
|
||||
|
||||
export const discordDeleteMessageHandler = async (
|
||||
parameters: DiscordDeleteMessageInput,
|
||||
): Promise<DiscordToolResult> => {
|
||||
const tokenResult = getDiscordBotToken();
|
||||
|
||||
if (!tokenResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Discord is not configured',
|
||||
error: tokenResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await discordApiRequest({
|
||||
botToken: tokenResult.botToken,
|
||||
method: 'DELETE',
|
||||
path: `/channels/${encodeURIComponent(
|
||||
parameters.channelId,
|
||||
)}/messages/${encodeURIComponent(parameters.messageId)}`,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to delete Discord message',
|
||||
error: result.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Discord message deleted.',
|
||||
messageId: parameters.messageId,
|
||||
channelId: parameters.channelId,
|
||||
};
|
||||
} catch (error) {
|
||||
return buildDiscordFailureResult('Failed to delete Discord message', error);
|
||||
}
|
||||
};
|
||||
@@ -1,125 +0,0 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type DiscordListChannelsInput } from 'src/logic-functions/types/discord-list-channels-input.type';
|
||||
import {
|
||||
type DiscordListChannelsResult,
|
||||
type DiscordListChannelsResultChannel,
|
||||
} from 'src/logic-functions/types/discord-list-channels-output.type';
|
||||
import { discordApiRequest } from 'src/logic-functions/utils/discord-api-request';
|
||||
import { fetchDiscordGuilds } from 'src/logic-functions/utils/fetch-discord-guilds';
|
||||
import { getDiscordBotToken } from 'src/logic-functions/utils/get-discord-bot-token';
|
||||
|
||||
const GUILD_TEXT_CHANNEL_TYPE = 0;
|
||||
const GUILD_ANNOUNCEMENT_CHANNEL_TYPE = 5;
|
||||
|
||||
const POSTABLE_CHANNEL_TYPES = new Set<number>([
|
||||
GUILD_TEXT_CHANNEL_TYPE,
|
||||
GUILD_ANNOUNCEMENT_CHANNEL_TYPE,
|
||||
]);
|
||||
|
||||
type DiscordChannelResponse = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
type: number;
|
||||
parent_id: string | null;
|
||||
position: number;
|
||||
};
|
||||
|
||||
const buildFailureResult = (error: string): DiscordListChannelsResult => ({
|
||||
success: false,
|
||||
channels: [],
|
||||
count: 0,
|
||||
error,
|
||||
});
|
||||
|
||||
const resolveGuildId = async ({
|
||||
botToken,
|
||||
providedGuildId,
|
||||
}: {
|
||||
botToken: string;
|
||||
providedGuildId: string | undefined;
|
||||
}): Promise<{ ok: true; guildId: string } | { ok: false; error: string }> => {
|
||||
const trimmed = providedGuildId?.trim();
|
||||
|
||||
if (isDefined(trimmed) && trimmed.length > 0) {
|
||||
return { ok: true, guildId: trimmed };
|
||||
}
|
||||
|
||||
const guildsResult = await fetchDiscordGuilds(botToken);
|
||||
|
||||
if (!guildsResult.ok) {
|
||||
return { ok: false, error: guildsResult.errorMessage };
|
||||
}
|
||||
|
||||
const guilds = guildsResult.data;
|
||||
|
||||
if (guilds.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
'Bot has not been invited to any Discord server yet. Use the OAuth2 → URL Generator in the Discord Developer Portal (scope `bot`) to invite it to a server, then retry.',
|
||||
};
|
||||
}
|
||||
|
||||
if (guilds.length > 1) {
|
||||
const guildList = guilds
|
||||
.map((guild) => `"${guild.name}" (${guild.id})`)
|
||||
.join(', ');
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: `Bot is in ${guilds.length} Discord servers — please specify which one via the guildId input. Available: ${guildList}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, guildId: guilds[0].id };
|
||||
};
|
||||
|
||||
export const discordListChannelsHandler = async (
|
||||
parameters: DiscordListChannelsInput,
|
||||
): Promise<DiscordListChannelsResult> => {
|
||||
const tokenResult = getDiscordBotToken();
|
||||
|
||||
if (!tokenResult.success) {
|
||||
return buildFailureResult(tokenResult.error);
|
||||
}
|
||||
|
||||
const guildIdResult = await resolveGuildId({
|
||||
botToken: tokenResult.botToken,
|
||||
providedGuildId: parameters.guildId,
|
||||
});
|
||||
|
||||
if (!guildIdResult.ok) {
|
||||
return buildFailureResult(guildIdResult.error);
|
||||
}
|
||||
|
||||
const result = await discordApiRequest<DiscordChannelResponse[]>({
|
||||
botToken: tokenResult.botToken,
|
||||
method: 'GET',
|
||||
path: `/guilds/${encodeURIComponent(guildIdResult.guildId)}/channels`,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return buildFailureResult(result.errorMessage);
|
||||
}
|
||||
|
||||
const channels: DiscordListChannelsResultChannel[] = result.data
|
||||
.filter(
|
||||
(channel) =>
|
||||
POSTABLE_CHANNEL_TYPES.has(channel.type) && isDefined(channel.name),
|
||||
)
|
||||
.map((channel) => ({
|
||||
id: channel.id,
|
||||
name: channel.name as string,
|
||||
type: channel.type,
|
||||
parentId: channel.parent_id,
|
||||
position: channel.position,
|
||||
}))
|
||||
.sort((a, b) => a.position - b.position);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
channels,
|
||||
count: channels.length,
|
||||
};
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import { type DiscordListGuildsResult } from 'src/logic-functions/types/discord-list-guilds-output.type';
|
||||
import { fetchDiscordGuilds } from 'src/logic-functions/utils/fetch-discord-guilds';
|
||||
import { getDiscordBotToken } from 'src/logic-functions/utils/get-discord-bot-token';
|
||||
|
||||
const buildFailureResult = (error: string): DiscordListGuildsResult => ({
|
||||
success: false,
|
||||
guilds: [],
|
||||
count: 0,
|
||||
error,
|
||||
});
|
||||
|
||||
export const discordListGuildsHandler =
|
||||
async (): Promise<DiscordListGuildsResult> => {
|
||||
const tokenResult = getDiscordBotToken();
|
||||
|
||||
if (!tokenResult.success) {
|
||||
return buildFailureResult(tokenResult.error);
|
||||
}
|
||||
|
||||
const result = await fetchDiscordGuilds(tokenResult.botToken);
|
||||
|
||||
if (!result.ok) {
|
||||
return buildFailureResult(result.errorMessage);
|
||||
}
|
||||
|
||||
const guilds = result.data.map((guild) => ({
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
guilds,
|
||||
count: guilds.length,
|
||||
};
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type DiscordPostMessageInput } from 'src/logic-functions/types/discord-post-message-input.type';
|
||||
import { type DiscordToolResult } from 'src/logic-functions/types/discord-tool-result.type';
|
||||
import { buildDiscordFailureResult } from 'src/logic-functions/utils/build-discord-failure-result';
|
||||
import { discordApiRequest } from 'src/logic-functions/utils/discord-api-request';
|
||||
import { getDiscordBotToken } from 'src/logic-functions/utils/get-discord-bot-token';
|
||||
|
||||
type DiscordMessageResponse = {
|
||||
id: string;
|
||||
channel_id: string;
|
||||
};
|
||||
|
||||
export const discordPostMessageHandler = async (
|
||||
parameters: DiscordPostMessageInput,
|
||||
): Promise<DiscordToolResult> => {
|
||||
const tokenResult = getDiscordBotToken();
|
||||
|
||||
if (!tokenResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Discord is not configured',
|
||||
error: tokenResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const replyToMessageId = parameters.replyToMessageId?.trim();
|
||||
const body: Record<string, unknown> = {
|
||||
content: parameters.messageText,
|
||||
};
|
||||
|
||||
if (isDefined(replyToMessageId) && replyToMessageId.length > 0) {
|
||||
body.message_reference = { message_id: replyToMessageId };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await discordApiRequest<DiscordMessageResponse>({
|
||||
botToken: tokenResult.botToken,
|
||||
method: 'POST',
|
||||
path: `/channels/${encodeURIComponent(parameters.channelId)}/messages`,
|
||||
body,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to post Discord message',
|
||||
error: result.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Message posted to Discord (id=${result.data.id}).`,
|
||||
messageId: result.data.id,
|
||||
channelId: result.data.channel_id,
|
||||
};
|
||||
} catch (error) {
|
||||
return buildDiscordFailureResult('Failed to post Discord message', error);
|
||||
}
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
import { type DiscordToolResult } from 'src/logic-functions/types/discord-tool-result.type';
|
||||
import { type DiscordUpdateMessageInput } from 'src/logic-functions/types/discord-update-message-input.type';
|
||||
import { buildDiscordFailureResult } from 'src/logic-functions/utils/build-discord-failure-result';
|
||||
import { discordApiRequest } from 'src/logic-functions/utils/discord-api-request';
|
||||
import { getDiscordBotToken } from 'src/logic-functions/utils/get-discord-bot-token';
|
||||
|
||||
type DiscordMessageResponse = {
|
||||
id: string;
|
||||
channel_id: string;
|
||||
};
|
||||
|
||||
export const discordUpdateMessageHandler = async (
|
||||
parameters: DiscordUpdateMessageInput,
|
||||
): Promise<DiscordToolResult> => {
|
||||
const tokenResult = getDiscordBotToken();
|
||||
|
||||
if (!tokenResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Discord is not configured',
|
||||
error: tokenResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await discordApiRequest<DiscordMessageResponse>({
|
||||
botToken: tokenResult.botToken,
|
||||
method: 'PATCH',
|
||||
path: `/channels/${encodeURIComponent(
|
||||
parameters.channelId,
|
||||
)}/messages/${encodeURIComponent(parameters.messageId)}`,
|
||||
body: { content: parameters.newMessageText },
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to update Discord message',
|
||||
error: result.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Discord message updated.',
|
||||
messageId: result.data.id,
|
||||
channelId: result.data.channel_id,
|
||||
};
|
||||
} catch (error) {
|
||||
return buildDiscordFailureResult('Failed to update Discord message', error);
|
||||
}
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const discordAddReactionInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
channelId: {
|
||||
type: 'string',
|
||||
label: 'Discord channel ID',
|
||||
description: 'Channel where the message lives (Discord channel ID).',
|
||||
},
|
||||
messageId: {
|
||||
type: 'string',
|
||||
label: 'Message ID',
|
||||
description:
|
||||
'The message to react to: its **message ID** (same value as `messageId` returned when the bot posted it, or right-click the message → "Copy Message ID").',
|
||||
},
|
||||
emoji: {
|
||||
type: 'string',
|
||||
label: 'Emoji',
|
||||
description:
|
||||
'Emoji to add. For a standard unicode emoji, paste the emoji directly (e.g. `👍`, `✅`, `🎉`). For a custom server emoji, use the format `name:id` (e.g. `partyparrot:643452342342342342`). The bot must have **Add Reactions** permission in the channel.',
|
||||
},
|
||||
},
|
||||
required: ['channelId', 'messageId', 'emoji'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const discordDeleteMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
channelId: {
|
||||
type: 'string',
|
||||
label: 'Discord channel ID',
|
||||
description: 'Channel that contains the message (Discord channel ID).',
|
||||
},
|
||||
messageId: {
|
||||
type: 'string',
|
||||
label: 'Message ID',
|
||||
description:
|
||||
'Which message to remove: its **message ID** (same value as `messageId` returned when the bot posted it). The bot can delete its own messages without extra permissions; deleting others\' messages requires the **Manage Messages** permission.',
|
||||
},
|
||||
},
|
||||
required: ['channelId', 'messageId'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const discordListChannelsInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
guildId: {
|
||||
type: 'string',
|
||||
label: 'Discord server (guild) ID',
|
||||
description:
|
||||
'Optional. The Discord server ID to list channels for (a long number like `1234567890123456789`). In Discord with Developer Mode on: right-click the server icon → "Copy Server ID". If omitted and the bot is in exactly one server, that server is used automatically; if the bot is in multiple servers, the error response lists each server name and ID so you can pick one.',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const discordListGuildsInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const discordPostMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
channelId: {
|
||||
type: 'string',
|
||||
label: 'Discord channel ID',
|
||||
description:
|
||||
'Where to post: the Discord channel ID (a long number like `1234567890123456789`). In Discord with Developer Mode on: right-click the channel → "Copy Channel ID". The bot must be a member of the server and have permission to send messages in this channel.',
|
||||
},
|
||||
messageText: {
|
||||
type: 'string',
|
||||
label: 'Message',
|
||||
multiline: true,
|
||||
description:
|
||||
'The message text. Supports Discord-flavoured markdown (**bold**, *italics*, ```code blocks```, lists, links). Max 2000 characters — Discord rejects longer payloads.',
|
||||
},
|
||||
replyToMessageId: {
|
||||
type: 'string',
|
||||
label: 'Reply to message ID',
|
||||
description:
|
||||
'Optional. To reply to an existing message, paste its ID (from a previous step\'s `messageId` output, or right-click the message → "Copy Message ID"). Leave empty for a normal new message.',
|
||||
},
|
||||
},
|
||||
required: ['channelId', 'messageText'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const discordUpdateMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
channelId: {
|
||||
type: 'string',
|
||||
label: 'Discord channel ID',
|
||||
description: 'Channel that contains the message (Discord channel ID).',
|
||||
},
|
||||
messageId: {
|
||||
type: 'string',
|
||||
label: 'Message ID',
|
||||
description:
|
||||
'Which message to edit: its **message ID** (same value as `messageId` returned when the bot posted the message). You can only edit messages this bot sent.',
|
||||
},
|
||||
newMessageText: {
|
||||
type: 'string',
|
||||
label: 'New message',
|
||||
multiline: true,
|
||||
description:
|
||||
'Replacement text shown in Discord instead of the old content. Supports Discord-flavoured markdown. Max 2000 characters.',
|
||||
},
|
||||
},
|
||||
required: ['channelId', 'messageId', 'newMessageText'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export type DiscordAddReactionInput = {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
emoji: string;
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export type DiscordDeleteMessageInput = {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export type DiscordListChannelsInput = {
|
||||
guildId?: string;
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
export type DiscordListChannelsResultChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: number;
|
||||
parentId: string | null;
|
||||
position: number;
|
||||
};
|
||||
|
||||
export type DiscordListChannelsResult = {
|
||||
success: boolean;
|
||||
channels: DiscordListChannelsResultChannel[];
|
||||
count: number;
|
||||
error?: string;
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
export type DiscordListGuildsResultGuild = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type DiscordListGuildsResult = {
|
||||
success: boolean;
|
||||
guilds: DiscordListGuildsResultGuild[];
|
||||
count: number;
|
||||
error?: string;
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export type DiscordPostMessageInput = {
|
||||
channelId: string;
|
||||
messageText: string;
|
||||
replyToMessageId?: string;
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
export type DiscordToolResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
error?: string;
|
||||
messageId?: string;
|
||||
channelId?: string;
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export type DiscordUpdateMessageInput = {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
newMessageText: string;
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
import { type DiscordToolResult } from 'src/logic-functions/types/discord-tool-result.type';
|
||||
|
||||
export const buildDiscordFailureResult = (
|
||||
message: string,
|
||||
error: unknown,
|
||||
): DiscordToolResult => ({
|
||||
success: false,
|
||||
message,
|
||||
error: error instanceof Error ? error.message : 'Discord request failed',
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const DISCORD_API_BASE_URL = 'https://discord.com/api/v10';
|
||||
|
||||
type DiscordApiSuccess<TData> = {
|
||||
ok: true;
|
||||
status: number;
|
||||
data: TData;
|
||||
};
|
||||
|
||||
type DiscordApiFailure = {
|
||||
ok: false;
|
||||
status: number;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export type DiscordApiResult<TData> =
|
||||
| DiscordApiSuccess<TData>
|
||||
| DiscordApiFailure;
|
||||
|
||||
type DiscordErrorBody = {
|
||||
code?: number;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type DiscordApiRequestParams = {
|
||||
botToken: string;
|
||||
path: string;
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
export const discordApiRequest = async <TData = unknown>({
|
||||
botToken,
|
||||
path,
|
||||
method,
|
||||
body,
|
||||
}: DiscordApiRequestParams): Promise<DiscordApiResult<TData>> => {
|
||||
const url = `${DISCORD_API_BASE_URL}${path}`;
|
||||
const hasBody = isDefined(body);
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bot ${botToken}`,
|
||||
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
...(hasBody ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
errorMessage: `Discord API request failed: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return { ok: true, status: 204, data: undefined as TData };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = await extractDiscordErrorMessage(response);
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const data = (await response.json()) as TData;
|
||||
|
||||
return { ok: true, status: response.status, data };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
errorMessage: `Discord API returned a non-JSON response: ${
|
||||
(error as Error).message
|
||||
}`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const extractDiscordErrorMessage = async (
|
||||
response: Response,
|
||||
): Promise<string> => {
|
||||
const fallback = `Discord API responded with ${response.status}`;
|
||||
|
||||
try {
|
||||
const errorBody = (await response.json()) as DiscordErrorBody;
|
||||
|
||||
if (isDefined(errorBody.message)) {
|
||||
return isDefined(errorBody.code)
|
||||
? `${errorBody.message} (Discord error code ${errorBody.code})`
|
||||
: errorBody.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import {
|
||||
discordApiRequest,
|
||||
type DiscordApiResult,
|
||||
} from 'src/logic-functions/utils/discord-api-request';
|
||||
|
||||
export type DiscordGuildResponse = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const fetchDiscordGuilds = (
|
||||
botToken: string,
|
||||
): Promise<DiscordApiResult<DiscordGuildResponse[]>> =>
|
||||
discordApiRequest<DiscordGuildResponse[]>({
|
||||
botToken,
|
||||
method: 'GET',
|
||||
path: '/users/@me/guilds',
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const DISCORD_BOT_TOKEN_ENV_VAR = 'DISCORD_BOT_TOKEN';
|
||||
|
||||
export const getDiscordBotToken = ():
|
||||
| { success: true; botToken: string }
|
||||
| { success: false; error: string } => {
|
||||
const botToken = process.env[DISCORD_BOT_TOKEN_ENV_VAR];
|
||||
|
||||
if (!isDefined(botToken) || botToken.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Discord is not configured. Open the Twenty Discord app settings and set the DISCORD_BOT_TOKEN application variable (Developer Portal → Bot tab → Reset Token).',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, botToken };
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import { defineApplicationRole } from 'twenty-sdk/define';
|
||||
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineApplicationRole({
|
||||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Twenty Discord tools role',
|
||||
description:
|
||||
'No CRM data access — tools only forward requests to Discord using the configured bot token.',
|
||||
canReadAllObjectRecords: false,
|
||||
canUpdateAllObjectRecords: false,
|
||||
canSoftDeleteAllObjectRecords: false,
|
||||
canDestroyAllObjectRecords: false,
|
||||
canUpdateAllSettings: false,
|
||||
canBeAssignedToAgents: false,
|
||||
canBeAssignedToUsers: false,
|
||||
canBeAssignedToApiKeys: false,
|
||||
objectPermissions: [],
|
||||
fieldPermissions: [],
|
||||
permissionFlags: [],
|
||||
});
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 303 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
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 };
|
||||
};
|
||||