Abdul Rahman
2026-05-14 16:27:53 +05:30
committed by GitHub
parent ddaba26abe
commit fc53f18a9f
45 changed files with 6358 additions and 0 deletions
@@ -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,
},
},
});
@@ -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,
});
@@ -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';
@@ -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,
});
@@ -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,
});
@@ -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,
},
});
@@ -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,
});
@@ -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,
},
});
@@ -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,
});
@@ -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,
},
});
@@ -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,
});
@@ -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,
});
@@ -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);
}
};
@@ -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);
}
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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);
}
};
@@ -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);
}
};
@@ -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,
};
@@ -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,
};
@@ -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,
};
@@ -0,0 +1,8 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const discordListGuildsInputSchema: InputJsonSchema = {
type: 'object',
properties: {},
required: [],
additionalProperties: false,
};
@@ -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,
};
@@ -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,
};
@@ -0,0 +1,5 @@
export type DiscordAddReactionInput = {
channelId: string;
messageId: string;
emoji: string;
};
@@ -0,0 +1,4 @@
export type DiscordDeleteMessageInput = {
channelId: string;
messageId: string;
};
@@ -0,0 +1,3 @@
export type DiscordListChannelsInput = {
guildId?: string;
};
@@ -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;
};
@@ -0,0 +1,11 @@
export type DiscordListGuildsResultGuild = {
id: string;
name: string;
};
export type DiscordListGuildsResult = {
success: boolean;
guilds: DiscordListGuildsResultGuild[];
count: number;
error?: string;
};
@@ -0,0 +1,5 @@
export type DiscordPostMessageInput = {
channelId: string;
messageText: string;
replyToMessageId?: string;
};
@@ -0,0 +1,7 @@
export type DiscordToolResult = {
success: boolean;
message: string;
error?: string;
messageId?: string;
channelId?: string;
};
@@ -0,0 +1,5 @@
export type DiscordUpdateMessageInput = {
channelId: string;
messageId: string;
newMessageText: string;
};
@@ -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',
});
@@ -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;
}
};
@@ -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',
});
@@ -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