Twenty discord integration (#20530)
<img width="1290" height="777" alt="Screenshot 2026-05-14 at 8 55 02 AM" src="https://github.com/user-attachments/assets/37cde89b-b4c3-438a-8ccf-39621f9799b4" /> https://github.com/user-attachments/assets/cd8af6f9-f2e5-47cf-bf1f-57590bb358d1 https://github.com/user-attachments/assets/ff53c6b7-ee1a-42db-bbc5-d7df5b3fa6b0 https://github.com/user-attachments/assets/ddca2c16-774d-4672-aa61-05e878d8b2b7
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "twenty-discord",
|
||||
"version": "0.1.0",
|
||||
"description": "Discord workflow connector for Twenty",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"keywords": [
|
||||
"twenty-app"
|
||||
],
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"scripts": {
|
||||
"twenty": "twenty",
|
||||
"lint": "oxlint -c .oxlintrc.json .",
|
||||
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-sdk": "2.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
"oxlint": "^0.16.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,30 @@
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
DISCORD_BOT_TOKEN_VARIABLE_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Twenty Discord',
|
||||
description:
|
||||
'Connect Discord to Twenty. Workflow steps post, update, and delete bot messages and add reactions using a Discord bot token shared across the deployment.',
|
||||
logoUrl: 'public/twenty-discord.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Communication',
|
||||
aboutDescription:
|
||||
'Official Discord connector for Twenty CRM. Create a Discord application at https://discord.com/developers/applications, copy its bot token into the DISCORD_BOT_TOKEN application variable, then invite the bot to each server you want workflows to post in. Use workflow actions to post, update, or delete bot messages and add reactions.',
|
||||
websiteUrl: 'https://docs.twenty.com/developers/extend/apps/getting-started',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
|
||||
applicationVariables: {
|
||||
DISCORD_BOT_TOKEN: {
|
||||
universalIdentifier: DISCORD_BOT_TOKEN_VARIABLE_UNIVERSAL_IDENTIFIER,
|
||||
description:
|
||||
'Bot token from your Discord application (Developer Portal → Bot tab → Reset Token). Used with the `Bot` auth prefix to call the Discord REST API. The same token authenticates the bot across every guild it has been invited to.',
|
||||
isSecret: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
});
|
||||
+670
@@ -0,0 +1,670 @@
|
||||
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,
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
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';
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
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,
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
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,
|
||||
});
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
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,
|
||||
});
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
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,
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
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,
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
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,
|
||||
});
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
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,
|
||||
};
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
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,
|
||||
};
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const discordListGuildsInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
};
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
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,
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
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,
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type DiscordAddReactionInput = {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
emoji: string;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type DiscordDeleteMessageInput = {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export type DiscordListChannelsInput = {
|
||||
guildId?: string;
|
||||
};
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
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;
|
||||
};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
export type DiscordListGuildsResultGuild = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type DiscordListGuildsResult = {
|
||||
success: boolean;
|
||||
guilds: DiscordListGuildsResultGuild[];
|
||||
count: number;
|
||||
error?: string;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type DiscordPostMessageInput = {
|
||||
channelId: string;
|
||||
messageText: string;
|
||||
replyToMessageId?: string;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type DiscordToolResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
error?: string;
|
||||
messageId?: string;
|
||||
channelId?: string;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type DiscordUpdateMessageInput = {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
newMessageText: string;
|
||||
};
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
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',
|
||||
});
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
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',
|
||||
});
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
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 };
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
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: [],
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user