Compare commits

..
Author SHA1 Message Date
Charles Bochet 64710fb57e fix(server): backport relationTargetFieldMetadataId column-add to 2.4 fast instance
Backport of the column-add to a new 2.4 fast instance command so that
users sitting on a v2.3.x baseline can upgrade past v2.4 without later
hitting the 2.5 workspace command NormalizeCompositeFieldDefaults
failing on `column ViewFilterEntity.relationTargetFieldMetadataId does
not exist`.

Same fix as main #20721, scoped to the 2.4 file only for this release.
ADD COLUMN IF NOT EXISTS keeps it idempotent.
2026-05-19 15:34:27 +02:00
Etienne a630874c35 fix(ai-agent-node) - agent node execution error (#20534)
**Root cause:** getWorkflowRunContext(stepInfos) builds a Record<string,
unknown> from the previous steps' results. There is no workspaceId key
in it, so context.workspaceId as string silently evaluated to undefined.
That undefined was then passed all the way down to
WorkspaceCacheService.getOrRecompute, **which correctly throws** when
workspaceId is not a valid UUID.

Before : 
<img width="525" height="130" alt="Screenshot 2026-05-13 at 14 58 54"
src="https://github.com/user-attachments/assets/0549b4dc-7063-44e5-95a1-00a460a6d7f1"
/>

Introduced with billing v2 yesterday, since then, workspaceId is needed
to bill credit usage
2026-05-13 15:37:27 +02:00
Etienne 9dc8333908 Billing - Add default ff (#20480) 2026-05-12 13:28:24 +02:00
1135 changed files with 16039 additions and 45661 deletions
-72
View File
@@ -1,72 +0,0 @@
---
description: GitHub Actions security guidelines for supply chain protection
globs: **/.github/**/*.yml, **/.github/**/*.yaml
alwaysApply: false
---
# GitHub Actions Security
## Pin Third-Party Actions to Commit SHAs
Always reference external actions and reusable workflows by their full commit SHA, never by a mutable tag or branch. Tags can be force-pushed by a compromised maintainer account.
```yaml
# ❌ Mutable tag — vulnerable to supply chain attacks
uses: actions/checkout@v4
uses: actions/setup-node@v4
# ✅ Pinned to commit SHA with tag comment for readability
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
```
## Prefer `gh api` Over Third-Party Dispatch Actions
For repository dispatch calls, use `gh api` directly instead of third-party actions like `peter-evans/repository-dispatch`. This eliminates a supply-chain dependency entirely.
```yaml
# ✅ Use env vars + bracket notation to prevent injection
- name: Dispatch to target repo
env:
GH_TOKEN: ${{ secrets.DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BRANCH: ${{ github.event.workflow_run.head_branch }}
run: |
gh api repos/org/repo/dispatches \
-f event_type=my-event \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[branch]=$BRANCH"
# ✅ Simple dispatch without payload
- name: Trigger workflow
env:
GH_TOKEN: ${{ secrets.DISPATCH_TOKEN }}
run: |
gh api repos/org/repo/dispatches -f event_type=my-event
# ❌ Third-party action dependency
- uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.DISPATCH_TOKEN }}
repository: org/repo
event-type: my-event
# ❌ Inline ${{ }} in shell — vulnerable to injection
- run: |
gh api repos/org/repo/dispatches --input - <<EOF
{"event_type": "x", "client_payload": {"branch": "${{ github.head_ref }}"}}
EOF
```
## Minimal Permissions
Always declare explicit `permissions` at the job level with the least privilege required. Never rely on the default `GITHUB_TOKEN` permissions.
```yaml
# ✅ Explicit minimal permissions
permissions:
contents: read
# ❌ Overly broad or implicit permissions
permissions: write-all
```
-6
View File
@@ -1,6 +0,0 @@
/.github/ @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
/.github/CODEOWNERS @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
/.github/workflows/ @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
/.github/actions/ @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
/.github/dependabot.yml @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
/.yarnrc.yml @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
+1 -1
View File
@@ -21,7 +21,7 @@ runs:
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@v4
with:
node-version-file: '${{ inputs.app-path }}/.nvmrc'
cache: yarn
@@ -21,7 +21,7 @@ runs:
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@v4
with:
node-version-file: '${{ inputs.app-path }}/.nvmrc'
cache: yarn
+1 -1
View File
@@ -20,7 +20,7 @@ runs:
run: git fetch origin main --depth=1
- name: Get last successful commit
if: env.NX_BASE == ''
uses: nrwl/nx-set-shas@3e9ad7370203c1e93d109be57f3b72eb0eb511b1 # v4
uses: nrwl/nx-set-shas@v4
- name: Fallback to origin/main if no base found
if: env.NX_BASE == ''
shell: bash
+1 -1
View File
@@ -25,7 +25,7 @@ runs:
run: |
echo "CACHE_PRIMARY_KEY_PREFIX=v4-${CACHE_KEY}-${REF_NAME}" >> "${GITHUB_OUTPUT}"
- name: Restore cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 (restore)
uses: actions/cache/restore@v4
id: restore-cache
with:
key: ${{ steps.cache-primary-key-builder.outputs.CACHE_PRIMARY_KEY_PREFIX }}-${{ github.sha }}
+1 -4
View File
@@ -9,11 +9,8 @@ inputs:
runs:
using: "composite"
steps:
# Fork PRs on pull_request already can't write to the base repo's cache (GitHub built-in).
# The fork guard is defense-in-depth for pull_request_target, which does have write access.
- name: Save cache
if: ${{ format('{0}', github.event.pull_request.head.repo.fork) != 'true' }}
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 (save)
uses: actions/cache/save@v4
with:
key: ${{ inputs.key }}
path: |
@@ -48,7 +48,7 @@ runs:
fi
- name: Checkout docker compose files
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
repository: ${{ inputs.twenty-repository }}
ref: ${{ steps.resolve.outputs.git-ref }}
+4 -7
View File
@@ -29,12 +29,12 @@ runs:
echo "packages/*/node_modules" >> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_OUTPUT
- name: Setup Node.js and get yarn cache
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- name: Restore node_modules
id: cache-node-modules
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 (restore)
uses: actions/cache/restore@v4
with:
key: v4-${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-${{github.sha}}
restore-keys: v4-${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-
@@ -44,13 +44,10 @@ runs:
shell: ${{ steps.globals.outputs.ACTION_SHELL }}
run: |
yarn config set enableHardenedMode true
yarn config set enableScripts false
yarn --immutable --check-cache
# Fork PRs on pull_request already can't write to the base repo's cache (GitHub built-in).
# The fork guard is defense-in-depth for pull_request_target, which does have write access.
- name: Save cache
if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' && steps.cache-node-modules.outputs.cache-matched-key == '' && format('{0}', github.event.pull_request.head.repo.fork) != 'true' }}
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 (save)
if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' && steps.cache-node-modules.outputs.cache-matched-key == '' }}
uses: actions/cache/save@v4
with:
key: ${{ steps.cache-node-modules.outputs.cache-primary-key }}
path: ${{ steps.globals.outputs.PATH_TO_CACHE }}
+6 -5
View File
@@ -14,8 +14,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Repository Dispatch
env:
GH_TOKEN: ${{ secrets.TWENTY_INFRA_TOKEN }}
run: |
gh api repos/twentyhq/twenty-infra/dispatches \
-f event_type=auto-deploy-main
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: auto-deploy-main
client-payload: '{"github": ${{ toJson(github) }}}' # Passes the entire github context to the downstream workflow
+6 -7
View File
@@ -14,10 +14,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Repository Dispatch
env:
GH_TOKEN: ${{ secrets.TWENTY_INFRA_TOKEN }}
REF_NAME: ${{ github.ref_name }}
run: |
gh api repos/twentyhq/twenty-infra/dispatches \
-f event_type=auto-deploy-tag \
-f "client_payload[github][ref_name]=$REF_NAME"
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: auto-deploy-tag
client-payload: '{"github": ${{ toJson(github) }}}' # Passes the entire github context to the downstream workflow
+2 -2
View File
@@ -21,11 +21,11 @@ jobs:
any_changed: ${{ steps.changed-files.outputs.any_changed }}
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Check for changed files
id: changed-files
uses: tj-actions/changed-files@48d8f15b2aaa3d255ca5af3eba4870f807ce6b3c # v45.0.9
uses: tj-actions/changed-files@v45
with:
files: ${{ inputs.files }}
+7 -6
View File
@@ -17,7 +17,7 @@ jobs:
NODE_OPTIONS: '--max-old-space-size=4096'
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
ref: main
@@ -41,7 +41,7 @@ jobs:
- name: Create pull request
if: steps.changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.6
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore: sync AI model catalog from models.dev'
@@ -61,7 +61,8 @@ jobs:
- name: Trigger automerge
if: steps.changes.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.TWENTY_INFRA_TOKEN }}
run: |
gh api repos/twentyhq/twenty-infra/dispatches -f event_type=automated-pr-ready
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: automated-pr-ready
+14 -40
View File
@@ -66,7 +66,7 @@ jobs:
steps:
- name: Checkout current branch
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
@@ -164,17 +164,9 @@ jobs:
interval=5
elapsed=0
ADMIN_TOKEN=$(jq -r '.APPLE_JANE_ADMIN_ACCESS_TOKEN' packages/twenty-server/test/integration/constants/test-tokens.json)
while [ $elapsed -lt $timeout ]; do
GRAPHQL_RESPONSE=$(curl -s -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-d '{"query":"{ __schema { queryType { name } } }"}' 2>/dev/null || echo '{}')
if echo "$GRAPHQL_RESPONSE" | jq -e '.data.__schema' > /dev/null 2>&1 && \
curl -fsS "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" > /dev/null 2>&1; then
if curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
echo "Current branch server is ready!"
break
fi
@@ -185,9 +177,10 @@ jobs:
done
if [ $elapsed -ge $timeout ]; then
echo "::warning::Timed out waiting for current branch server to serve a valid schema. Validation will skip the API diff."
echo "Timeout waiting for current branch server to start"
echo "Current server log:"
cat /tmp/current-server.log || echo "No current server log found"
exit 1
fi
- name: Download GraphQL and REST responses from current branch
@@ -331,17 +324,9 @@ jobs:
interval=5
elapsed=0
ADMIN_TOKEN=$(jq -r '.APPLE_JANE_ADMIN_ACCESS_TOKEN' packages/twenty-server/test/integration/constants/test-tokens.json)
while [ $elapsed -lt $timeout ]; do
GRAPHQL_RESPONSE=$(curl -s -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-d '{"query":"{ __schema { queryType { name } } }"}' 2>/dev/null || echo '{}')
if echo "$GRAPHQL_RESPONSE" | jq -e '.data.__schema' > /dev/null 2>&1 && \
curl -fsS "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" > /dev/null 2>&1; then
if curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
echo "Main branch server is ready!"
break
fi
@@ -352,9 +337,10 @@ jobs:
done
if [ $elapsed -ge $timeout ]; then
echo "::warning::Timed out waiting for main branch server to serve a valid schema. Validation will skip the API diff."
echo "Timeout waiting for main branch server to start"
echo "Main server log:"
cat /tmp/main-server.log || echo "No main server log found"
exit 1
fi
- name: Download GraphQL and REST responses from main branch
@@ -421,23 +407,11 @@ jobs:
valid=true
for file in main-schema-introspection.json current-schema-introspection.json \
main-metadata-schema-introspection.json current-metadata-schema-introspection.json; do
if [ ! -f "$file" ]; then
echo "::warning::Missing GraphQL schema file: $file"
valid=false
elif ! jq -e '.data.__schema' "$file" >/dev/null 2>&1; then
echo "::warning::File $file is not a valid GraphQL introspection result. First 200 bytes: $(head -c 200 "$file")"
valid=false
fi
done
for file in main-rest-api.json current-rest-api.json \
main-metadata-schema-introspection.json current-metadata-schema-introspection.json \
main-rest-api.json current-rest-api.json \
main-rest-metadata-api.json current-rest-metadata-api.json; do
if [ ! -f "$file" ]; then
echo "::warning::Missing OpenAPI spec file: $file"
valid=false
elif ! jq -e '.openapi // .swagger' "$file" >/dev/null 2>&1; then
echo "::warning::File $file is not a valid OpenAPI spec. First 200 bytes: $(head -c 200 "$file")"
if [ ! -f "$file" ] || ! jq empty "$file" 2>/dev/null; then
echo "::warning::Invalid or missing schema file: $file"
valid=false
fi
done
@@ -634,7 +608,7 @@ jobs:
- name: Upload breaking changes report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: breaking-changes-report
path: |
@@ -57,7 +57,7 @@ jobs:
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
@@ -109,7 +109,7 @@ jobs:
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" --api-url http://localhost:3000
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: |
@@ -55,7 +55,7 @@ jobs:
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
@@ -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" --api-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: |
@@ -57,7 +57,7 @@ jobs:
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
@@ -107,7 +107,7 @@ jobs:
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" --api-url http://localhost:3000
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: |
+5 -1
View File
@@ -30,8 +30,12 @@ jobs:
matrix:
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
+6 -1
View File
@@ -28,8 +28,13 @@ jobs:
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Fetch local actions
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -54,7 +54,7 @@ jobs:
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
+1 -28
View File
@@ -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
@@ -56,7 +54,7 @@ jobs:
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
@@ -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
@@ -32,8 +32,12 @@ jobs:
matrix:
task: [build, typecheck, lint]
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -46,8 +50,12 @@ jobs:
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -55,7 +63,7 @@ jobs:
- name: Build storybook
run: npx nx storybook:build twenty-front-component-renderer
- name: Upload storybook build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: storybook-twenty-front-component-renderer
path: packages/twenty-front-component-renderer/storybook-static
@@ -68,7 +76,7 @@ jobs:
STORYBOOK_URL: http://localhost:6008
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -76,7 +84,7 @@ jobs:
- name: Build dependencies
run: npx nx build twenty-sdk
- name: Download storybook build
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@v4
with:
name: storybook-twenty-front-component-renderer
path: packages/twenty-front-component-renderer/storybook-static
+22 -10
View File
@@ -38,8 +38,12 @@ jobs:
env:
REACT_APP_SERVER_BASE_URL: http://localhost:3000
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Fetch local actions
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -51,7 +55,7 @@ jobs:
- name: Front / Build storybook
run: npx nx storybook:build twenty-front
- name: Upload storybook build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: storybook-static
path: packages/twenty-front/storybook-static
@@ -75,7 +79,7 @@ jobs:
STORYBOOK_URL: http://localhost:6006
steps:
- name: Fetch local actions
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -92,7 +96,7 @@ jobs:
npx nx build twenty-ui
npx nx build twenty-front-component-renderer
- name: Download storybook build
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@v4
with:
name: storybook-static
path: packages/twenty-front/storybook-static
@@ -117,7 +121,7 @@ jobs:
# exit 1
# fi
# - name: Upload coverage artifact
# uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
# uses: actions/upload-artifact@v4
# with:
# retention-days: 1
# name: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-${{ matrix.shard }}
@@ -132,12 +136,12 @@ jobs:
# matrix:
# storybook_scope: [modules, pages, performance]
# steps:
# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
# - uses: actions/checkout@v4
# with:
# fetch-depth: 10
# - name: Install dependencies
# uses: ./.github/actions/yarn-install
# - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
# - uses: actions/download-artifact@v4
# with:
# pattern: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-*
# merge-multiple: true
@@ -160,8 +164,12 @@ jobs:
matrix:
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -195,8 +203,12 @@ jobs:
NODE_OPTIONS: "--max-old-space-size=10240"
ANALYZE: "true"
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -206,7 +218,7 @@ jobs:
- name: Build frontend
run: npx nx build twenty-front
# - name: Upload frontend build artifact
# uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
# uses: actions/upload-artifact@v4
# with:
# name: frontend-build
# path: packages/twenty-front/build
+5 -5
View File
@@ -41,11 +41,11 @@ jobs:
ports:
- 6379:6379
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/checkout@v4
with:
fetch-depth: 10
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/setup-node@v4
with:
node-version: lts/*
@@ -53,7 +53,7 @@ jobs:
uses: ./.github/actions/yarn-install
- name: Restore Nx build cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 (restore)
uses: actions/cache/restore@v4
with:
key: v4-e2e-build-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
@@ -83,7 +83,7 @@ jobs:
- name: Save Nx build cache
if: always()
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 (save)
uses: actions/cache/save@v4
with:
key: v4-e2e-build-${{ github.ref_name }}-${{ github.sha }}
path: |
@@ -119,7 +119,7 @@ jobs:
- name: Upload Playwright results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: playwright-results
path: |
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref }}
@@ -47,7 +47,7 @@ jobs:
printf '%s\n' "$VERSION" > version.txt
- name: Create Pull Request
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
uses: peter-evans/create-pull-request@v6
with:
branch: release/${{ steps.sanitize.outputs.version }}
commit-message: "chore: release v${{ steps.sanitize.outputs.version }}"
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
fi
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
ref: main
@@ -55,7 +55,7 @@ jobs:
git tag "v${{ env.VERSION }}"
git push origin "v${{ env.VERSION }}"
- uses: release-drafter/release-drafter@09c613e259eb8d4e7c81c2cb00618eb5fc4575a7 # v5
- uses: release-drafter/release-drafter@v5
if: contains(github.event.pull_request.labels.*.name, 'create_release')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+6 -2
View File
@@ -30,8 +30,12 @@ jobs:
matrix:
task: [lint, typecheck, test:unit, test:integration]
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -70,7 +74,7 @@ jobs:
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
+7 -7
View File
@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -65,7 +65,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -83,12 +83,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Get changed upgrade-version-command files
id: changed-files
uses: tj-actions/changed-files@48d8f15b2aaa3d255ca5af3eba4870f807ce6b3c # v45.0.9
uses: tj-actions/changed-files@v45
with:
files: |
packages/twenty-server/src/database/commands/upgrade-version-command/**
@@ -178,7 +178,7 @@ jobs:
- 6379:6379
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -278,7 +278,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -341,7 +341,7 @@ jobs:
SHARD_COUNTER: 10
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
+5 -1
View File
@@ -29,8 +29,12 @@ jobs:
matrix:
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -26,9 +26,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
@@ -101,9 +101,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
+13 -5
View File
@@ -30,8 +30,12 @@ jobs:
matrix:
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -44,8 +48,12 @@ jobs:
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -53,7 +61,7 @@ jobs:
- name: Build storybook
run: npx nx storybook:build twenty-ui
- name: Upload storybook build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: storybook-twenty-ui
path: packages/twenty-ui/storybook-static
@@ -66,7 +74,7 @@ jobs:
STORYBOOK_URL: http://localhost:6007
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -74,7 +82,7 @@ jobs:
- name: Build dependencies
run: npx nx build twenty-shared
- name: Download storybook build
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@v4
with:
name: storybook-twenty-ui
path: packages/twenty-ui/storybook-static
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
if: github.event.action != 'closed'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Utils / Run Danger.js
@@ -38,7 +38,7 @@ jobs:
runs-on: ubuntu-latest
if: github.event.action == 'closed' && github.event.pull_request.merged == true
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Run congratulate-dangerfile.js
+5 -1
View File
@@ -32,8 +32,12 @@ jobs:
matrix:
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
+6 -2
View File
@@ -46,7 +46,7 @@ jobs:
- 6379:6379
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -97,8 +97,12 @@ jobs:
matrix:
task: [lint, typecheck, validate]
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
+16 -45
View File
@@ -8,12 +8,10 @@ on:
pull_request_review:
types: [submitted]
issues:
types: [opened]
types: [opened, assigned]
repository_dispatch:
types: [claude-core-team-issues]
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.client_payload.issue_number }}
cancel-in-progress: false
@@ -21,30 +19,10 @@ concurrency:
jobs:
claude:
if: |
(
github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '@claude') &&
github.event.comment.user.type != 'Bot' &&
contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
) ||
(
github.event_name == 'pull_request_review_comment' &&
contains(github.event.comment.body, '@claude') &&
github.event.comment.user.type != 'Bot' &&
contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
) ||
(
github.event_name == 'pull_request_review' &&
contains(github.event.review.body, '@claude') &&
github.event.review.user.type != 'Bot' &&
contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association)
) ||
(
github.event_name == 'issues' &&
github.event.action == 'opened' &&
(contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) &&
contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)
)
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && github.event.comment.user.type != 'Bot') ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && github.event.comment.user.type != 'Bot') ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && github.event.review.user.type != 'Bot') ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
@@ -72,14 +50,14 @@ jobs:
- 6379:6379
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Run Claude Code
id: claude-code
uses: anthropics/claude-code-action@dde2242db6af13460b916652159b6ba19a598f30 # v1
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
additional_permissions: |
@@ -144,14 +122,14 @@ jobs:
ports:
- 6379:6379
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build prompt from dispatch payload
id: prompt
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
uses: actions/github-script@v7
with:
script: |
const p = context.payload.client_payload;
@@ -166,7 +144,7 @@ jobs:
core.setOutput('issue_number', p.issue_number);
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@dde2242db6af13460b916652159b6ba19a598f30 # v1
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: ${{ steps.prompt.outputs.prompt }}
@@ -181,16 +159,9 @@ jobs:
}
- name: Dispatch response to ci-privileged
if: always()
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
REPO: ${{ steps.prompt.outputs.repo }}
ISSUE_NUMBER: ${{ steps.prompt.outputs.issue_number }}
RUN_ID: ${{ github.run_id }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=claude-cross-repo-response \
-f "client_payload[repo]=$REPO" \
-f "client_payload[issue_number]=$ISSUE_NUMBER" \
-f "client_payload[run_id]=$RUN_ID" \
-f "client_payload[run_url]=$RUN_URL"
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: claude-cross-repo-response
client-payload: '{"repo": ${{ toJSON(steps.prompt.outputs.repo) }}, "issue_number": ${{ toJSON(steps.prompt.outputs.issue_number) }}, "run_id": ${{ toJSON(github.run_id) }}, "run_url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}'
+6 -5
View File
@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
token: ${{ github.token }}
repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
@@ -153,7 +153,8 @@ jobs:
- name: Trigger i18n automerge
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
env:
GH_TOKEN: ${{ secrets.TWENTY_INFRA_TOKEN }}
run: |
gh api repos/twentyhq/twenty-infra/dispatches -f event_type=i18n-pr-ready
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: ${{ github.ref }}
@@ -36,7 +36,7 @@ jobs:
run: yarn docs:generate-navigation-template
- name: Upload docs to Crowdin
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: false
+7 -6
View File
@@ -35,7 +35,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: ${{ github.head_ref || github.ref_name }}
@@ -69,7 +69,7 @@ jobs:
- name: Pull translations from Crowdin
if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
@@ -139,7 +139,8 @@ jobs:
- name: Trigger i18n automerge
if: steps.compile_translations.outputs.changes_detected == 'true'
env:
GH_TOKEN: ${{ secrets.TWENTY_INFRA_TOKEN }}
run: |
gh api repos/twentyhq/twenty-infra/dispatches -f event_type=i18n-pr-ready
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
+7 -6
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: main
@@ -80,7 +80,7 @@ jobs:
- name: Upload missing translations
if: steps.check_extract_changes.outputs.changes_detected == 'true'
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: true
@@ -105,7 +105,8 @@ jobs:
- name: Trigger i18n automerge
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
env:
GH_TOKEN: ${{ secrets.TWENTY_INFRA_TOKEN }}
run: |
gh api repos/twentyhq/twenty-infra/dispatches -f event_type=i18n-pr-ready
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
+7 -14
View File
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Get PR number from workflow run
id: pr-info
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
uses: actions/github-script@v7
with:
script: |
const runId = context.payload.workflow_run.id;
@@ -63,16 +63,9 @@ jobs:
- name: Dispatch to ci-privileged
if: steps.pr-info.outputs.has_pr == 'true'
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ steps.pr-info.outputs.pr_number }}
RUN_ID: ${{ steps.pr-info.outputs.run_id }}
REPOSITORY: ${{ github.repository }}
BRANCH_STATE: ${{ github.event.workflow_run.head_branch }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=breaking-changes-report \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[run_id]=$RUN_ID" \
-f "client_payload[repo]=$REPOSITORY" \
-f "client_payload[branch_state]=$BRANCH_STATE"
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: breaking-changes-report
client-payload: '{"pr_number": ${{ toJSON(steps.pr-info.outputs.pr_number) }}, "run_id": ${{ toJSON(steps.pr-info.outputs.run_id) }}, "repo": ${{ toJSON(github.repository) }}, "branch_state": ${{ toJSON(github.event.workflow_run.head_branch) }}}'
+12 -22
View File
@@ -36,27 +36,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger preview environment workflow
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REPOSITORY: ${{ github.repository }}
run: |
gh api repos/"$REPOSITORY"/dispatches \
-f event_type=preview-environment \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[pr_head_sha]=$PR_HEAD_SHA" \
-f "client_payload[repo_full_name]=$REPOSITORY"
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
event-type: preview-environment
client-payload: '{"pr_number": "${{ github.event.pull_request.number }}", "pr_head_sha": "${{ github.event.pull_request.head.sha }}", "repo_full_name": "${{ github.repository }}"}'
- name: Dispatch to ci-privileged for PR comment
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
KEEPALIVE_DISPATCH_TIME: ${{ github.event.pull_request.updated_at }}
REPOSITORY: ${{ github.repository }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=preview-env-url \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[keepalive_dispatch_time]=$KEEPALIVE_DISPATCH_TIME" \
-f "client_payload[repo]=$REPOSITORY"
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: preview-env-url
client-payload: '{"pr_number": ${{ toJSON(github.event.pull_request.number) }}, "keepalive_dispatch_time": ${{ toJSON(github.event.pull_request.updated_at) }}, "repo": ${{ toJSON(github.repository) }}}'
+10 -57
View File
@@ -13,12 +13,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout PR
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
ref: ${{ github.event.client_payload.pr_head_sha }}
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
@@ -56,54 +56,10 @@ jobs:
- name: Create Tunnel
id: expose-tunnel
env:
CLOUDFLARED_VERSION: '2026.3.0'
run: |
set -euo pipefail
# Install cloudflared (pinned for reproducibility)
sudo curl -fsSL -o /usr/local/bin/cloudflared \
"https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64"
sudo chmod +x /usr/local/bin/cloudflared
cloudflared --version
# Start an account-less "quick tunnel" pointing at the server container.
# Cloudflare prints the assigned https://*.trycloudflare.com URL into the log.
log_file="$RUNNER_TEMP/cloudflared.log"
: > "$log_file"
cloudflared tunnel \
--url http://localhost:3000 \
--no-autoupdate \
--logfile "$log_file" \
--loglevel info \
> "$RUNNER_TEMP/cloudflared.stdout" 2>&1 &
pid=$!
echo "$pid" > "$RUNNER_TEMP/cloudflared.pid"
echo "cloudflared PID: $pid"
# Wait up to 2 minutes for the URL to appear; fail fast if cloudflared exits.
url=''
for _ in $(seq 1 60); do
url=$(grep -oE 'https://[a-zA-Z0-9-]+\.trycloudflare\.com' "$log_file" 2>/dev/null | head -n1 || true)
[ -n "$url" ] && break
if ! kill -0 "$pid" 2>/dev/null; then
echo "cloudflared exited before producing a URL"
cat "$log_file" || true
exit 1
fi
sleep 2
done
if [ -z "$url" ]; then
echo "Timed out waiting for tunnel URL"
cat "$log_file" || true
exit 1
fi
echo "Tunnel URL: $url"
echo "tunnel-url=$url" >> "$GITHUB_OUTPUT"
uses: codetalkio/[email protected]
with:
service: bore.pub
port: 3000
- name: Start services with correct SERVER_URL
env:
@@ -143,9 +99,9 @@ jobs:
- name: Seed Dev Workspace
run: |
cd packages/twenty-docker/
echo "Seeding light dev workspace (Apple only)..."
if ! docker compose exec -T server yarn command:prod workspace:seed:dev --light; then
echo "❌ Seeding light dev workspace failed. Dumping server logs..."
echo "Seeding full dev workspace..."
if ! docker compose exec -T server yarn command:prod -- workspace:seed:dev; then
echo "❌ Seeding full dev workspace failed. Dumping server logs..."
docker compose logs server
exit 1
fi
@@ -166,7 +122,7 @@ jobs:
echo "$TUNNEL_URL" > tunnel-url.txt
- name: Upload tunnel URL artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: tunnel-url
path: tunnel-url.txt
@@ -178,9 +134,6 @@ jobs:
- name: Cleanup
if: always()
run: |
if [ -f "$RUNNER_TEMP/cloudflared.pid" ]; then
kill "$(cat "$RUNNER_TEMP/cloudflared.pid")" 2>/dev/null || true
fi
cd packages/twenty-docker/
docker compose down -v
working-directory: ./
@@ -23,7 +23,7 @@ jobs:
steps:
- name: Determine project and artifact name
id: project
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
uses: actions/github-script@v7
with:
script: |
const workflowName = context.payload.workflow_run.name;
@@ -43,7 +43,7 @@ jobs:
- name: Check if storybook artifact exists
id: check-artifact
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
uses: actions/github-script@v7
with:
script: |
const artifactName = '${{ steps.project.outputs.artifact_name }}';
@@ -65,7 +65,7 @@ jobs:
- name: Get PR number
if: steps.check-artifact.outputs.exists == 'true'
id: pr-info
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
uses: actions/github-script@v7
with:
script: |
const headBranch = context.payload.workflow_run.head_branch;
@@ -107,7 +107,7 @@ jobs:
- name: Download storybook artifact from triggering run
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@v4
with:
name: ${{ steps.project.outputs.artifact_name }}
path: storybook-static
@@ -120,7 +120,7 @@ jobs:
- name: Upload storybook tarball
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: ${{ steps.project.outputs.tarball_name }}
path: /tmp/${{ steps.project.outputs.tarball_file }}
@@ -128,20 +128,17 @@ jobs:
- name: Dispatch to ci-privileged
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ steps.pr-info.outputs.pr_number }}
RUN_ID: ${{ github.run_id }}
REPOSITORY: ${{ github.repository }}
PROJECT: ${{ steps.project.outputs.project }}
BRANCH: ${{ github.event.workflow_run.head_branch }}
COMMIT: ${{ github.event.workflow_run.head_sha }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=visual-regression \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[run_id]=$RUN_ID" \
-f "client_payload[repo]=$REPOSITORY" \
-f "client_payload[project]=$PROJECT" \
-f "client_payload[branch]=$BRANCH" \
-f "client_payload[commit]=$COMMIT"
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: visual-regression
client-payload: >-
{
"pr_number": "${{ steps.pr-info.outputs.pr_number }}",
"run_id": "${{ github.run_id }}",
"repo": "${{ github.repository }}",
"project": "${{ steps.project.outputs.project }}",
"branch": "${{ github.event.workflow_run.head_branch }}",
"commit": "${{ github.event.workflow_run.head_sha }}"
}
+7 -6
View File
@@ -35,7 +35,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: ${{ github.head_ref || github.ref_name }}
@@ -66,7 +66,7 @@ jobs:
- name: Pull website translations from Crowdin
if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
@@ -128,7 +128,8 @@ jobs:
- name: Trigger i18n automerge
if: steps.compile_translations.outputs.changes_detected == 'true'
env:
GH_TOKEN: ${{ secrets.TWENTY_INFRA_TOKEN }}
run: |
gh api repos/twentyhq/twenty-infra/dispatches -f event_type=i18n-pr-ready
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
+7 -6
View File
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: main
@@ -78,7 +78,7 @@ jobs:
- name: Upload missing website translations
if: steps.check_extract_changes.outputs.changes_detected == 'true'
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: true
@@ -104,7 +104,8 @@ jobs:
- name: Trigger i18n automerge
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
env:
GH_TOKEN: ${{ secrets.TWENTY_INFRA_TOKEN }}
run: |
gh api repos/twentyhq/twenty-infra/dispatches -f event_type=i18n-pr-ready
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
-40
View File
@@ -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
View File
@@ -4,10 +4,6 @@ enableHardenedMode: true
enableInlineHunks: true
enableScripts: false
nodeLinker: node-modules
npmMinimalAgeGate: 3d
yarnPath: .yarn/releases/yarn-4.13.0.cjs
-1
View File
@@ -57,7 +57,6 @@
"lint:diff-with-main": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": ["twenty-oxlint-rules:build"],
"options": {
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (prettier --check $FILES || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
"pattern": "\\.(ts|tsx|js|jsx)$"
+1 -2
View File
@@ -63,8 +63,7 @@
"packages/twenty-cli",
"packages/create-twenty-app",
"packages/twenty-oxlint-rules",
"packages/twenty-companion",
"packages/twenty-claude-skills"
"packages/twenty-companion"
]
},
"prettier": {
+10 -11
View File
@@ -25,19 +25,18 @@ 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 |
| ---------------------------------- | --------------------------------------------------------------------- |
| `--example <name>` | Initialize from an example |
| `--name <name>` | Set the app name |
| `--display-name <displayName>` | Set the display name |
| `--description <description>` | Set the description |
| `--api-url <url>` | Twenty instance 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:
@@ -58,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
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.5.0",
"version": "2.3.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",
+15 -27
View File
@@ -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)
@@ -16,17 +13,20 @@ const program = new Command(packageJson.name)
)
.argument('[directory]')
.option('--example <name>', 'Initialize from an example')
.option('-n, --name <name>', 'Application name')
.option('-d, --display-name <displayName>', 'Application display name')
.option('--description <description>', 'Application description')
.option('-n, --name <name>', 'Application name (skips prompt)')
.option(
'--api-url <apiUrl>',
'Twenty instance URL (default: http://localhost:2020)',
'-d, --display-name <displayName>',
'Application display name (skips prompt)',
)
.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 (
@@ -36,8 +36,8 @@ const program = new Command(packageJson.name)
name?: string;
displayName?: string;
description?: string;
apiUrl?: string;
authenticationMethod?: AuthenticationMethod;
skipLocalInstance?: boolean;
yes?: boolean;
},
) => {
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
@@ -54,26 +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);
}
await new CreateAppCommand().execute({
directory,
example: options?.example,
name: options?.name,
displayName: options?.displayName,
description: options?.description,
apiUrl: 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: `[email protected]` / `[email protected]`.
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 }}
@@ -5,29 +5,21 @@ 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;
@@ -35,62 +27,40 @@ type CreateAppOptions = {
name?: string;
displayName?: string;
description?: string;
apiUrl?: 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 apiUrl = options.apiUrl ?? DEV_API_URL;
const skipLocalInstance = apiUrl !== 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,
const confirmed = await this.promptScaffoldConfirmation({
appName,
appDisplayName,
appDescription,
appDirectory,
autoConfirm: options.yes,
});
this.stepCounter = 0;
if (!confirmed) {
console.log(chalk.gray('\nScaffolding cancelled.'));
process.exit(0);
}
const dockerPullPromise =
!skipLocalInstance && checkDockerRunning()
? this.pullImageInBackground()
: Promise.resolve(false);
this.logPlan({ appName, appDisplayName, appDescription, appDirectory });
this.logNextStep('Creating project directory');
console.log('');
this.logStep(1, 'Creating project directory');
await fs.ensureDir(appDirectory);
this.logDetail(appDirectory);
this.logNextStep('Scaffolding project files');
this.logStep(2, 'Scaffolding project files');
if (options.example) {
const exampleSucceeded = await this.tryDownloadExample(
@@ -117,12 +87,10 @@ export class CreateAppCommand {
});
}
this.logNextStep('Installing dependencies');
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) {
@@ -136,29 +104,38 @@ export class CreateAppCommand {
console.log('');
let hasLocalServer = false;
let authSucceeded = false;
let resolvedApiUrl = apiUrl;
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)) {
resolvedApiUrl = serverResult.url;
serverReady = true;
if (existingServerUrl) {
hasLocalServer = true;
authSucceeded = await this.promptConnectToLocal(existingServerUrl);
} else {
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();
}
}
}
if (serverReady && authenticationMethod === 'oauth') {
this.logNextStep('Authenticating via OAuth');
authSucceeded = await this.authenticateWithOAuth(resolvedApiUrl);
} else if (serverReady && authenticationMethod === 'apiKey') {
this.logNextStep('Authenticating via API key');
authSucceeded = await this.authenticateWithDevKey(resolvedApiUrl);
}
this.logSuccess(appDirectory, resolvedApiUrl, authSucceeded);
this.logSuccess(appDirectory, hasLocalServer, authSucceeded);
} catch (error) {
console.error(
chalk.red('\nCreate application failed:'),
@@ -168,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)
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 };
@@ -231,30 +232,45 @@ export class CreateAppCommand {
return true;
} catch (error) {
console.log(
chalk.yellow(
console.error(
chalk.red(
`\n${error instanceof Error ? error.message : 'Failed to download example.'}`,
),
);
this.logDetail('Falling back to default template...');
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 logPlan({
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}`));
@@ -264,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}...`),
);
}
@@ -279,57 +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 {};
return false;
}
this.logDetail('Ensuring latest Twenty server image...');
await inquirer.prompt([
{
type: 'input',
name: 'confirm',
message: 'Press Enter to open the browser for authentication...',
},
]);
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 authenticateWithDevKey(apiUrl: string): Promise<boolean> {
try {
const result = await authLogin({
apiKey: DEV_API_KEY,
apiUrl,
const result = await authLoginOAuth({
apiUrl: serverUrl,
remote: 'local',
});
@@ -337,68 +426,21 @@ export class CreateAppCommand {
const configService = new ConfigService();
await configService.setDefaultRemote('local');
this.logDetail('Authenticated as [email protected] (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(apiUrl: string): Promise<boolean> {
try {
const remoteName = this.deriveRemoteName(apiUrl);
ConfigService.setActiveRemote(remoteName);
this.logDetail('Opening browser for OAuth...');
const result = await authLoginOAuth({ apiUrl });
if (result.success) {
const configService = new ConfigService();
await configService.setDefaultRemote(remoteName);
this.logDetail(`Authenticated via OAuth to ${apiUrl}`);
return true;
}
console.log(
chalk.yellow(
` OAuth failed: ${result.error.message}\n` +
` Run \`yarn twenty remote add --api-url ${apiUrl}\` manually.`,
),
);
return false;
} catch {
console.log(
chalk.yellow(
` Authentication failed. Run \`yarn twenty remote add --api-url ${apiUrl}\` manually.`,
'Authentication failed. Run `yarn twenty remote add` manually.',
),
);
@@ -408,13 +450,12 @@ export class CreateAppCommand {
private logSuccess(
appDirectory: string,
apiUrl: 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;
@@ -424,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(` ${apiUrl}\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 --api-url <your-instance-url>',
].join('\n');
};
@@ -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": "[email protected]",
"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: '[email protected]',
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"]
}

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