diff --git a/packages/twenty-apps/internal/twenty-discord/.oxlintrc.json b/packages/twenty-apps/internal/twenty-discord/.oxlintrc.json new file mode 100644 index 0000000000..e0ce53c21f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-discord/.oxlintrc.json @@ -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" + } +} diff --git a/packages/twenty-apps/internal/twenty-discord/README.md b/packages/twenty-apps/internal/twenty-discord/README.md new file mode 100644 index 0000000000..10825950f8 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-discord/README.md @@ -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. diff --git a/packages/twenty-apps/internal/twenty-discord/package.json b/packages/twenty-apps/internal/twenty-discord/package.json new file mode 100644 index 0000000000..89b0e95ee9 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-discord/package.json @@ -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" + } +} diff --git a/packages/twenty-apps/internal/twenty-discord/public/twenty-discord.svg b/packages/twenty-apps/internal/twenty-discord/public/twenty-discord.svg new file mode 100644 index 0000000000..49b72ae3f0 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-discord/public/twenty-discord.svg @@ -0,0 +1,14 @@ + + Discord + + diff --git a/packages/twenty-apps/internal/twenty-discord/src/application.config.ts b/packages/twenty-apps/internal/twenty-discord/src/application.config.ts new file mode 100644 index 0000000000..a1e40af691 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-discord/src/application.config.ts @@ -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, + }, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-discord/src/command-menu-items/send-discord-message.command-menu-item.ts b/packages/twenty-apps/internal/twenty-discord/src/command-menu-items/send-discord-message.command-menu-item.ts new file mode 100644 index 0000000000..b5847e3f03 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-discord/src/command-menu-items/send-discord-message.command-menu-item.ts @@ -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, +}); diff --git a/packages/twenty-apps/internal/twenty-discord/src/components/send-message-form.front-component.tsx b/packages/twenty-apps/internal/twenty-discord/src/components/send-message-form.front-component.tsx new file mode 100644 index 0000000000..3eb1136c05 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-discord/src/components/send-message-form.front-component.tsx @@ -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, +): 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) => { + const v = readSerializedValue(e); + + if (typeof v === 'string') fn(v); + }; + +const callAppRoute = async ( + path: string, + method: 'GET' | 'POST', + body?: Record, +) => { + 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([]); + const [guildsLoading, setGuildsLoading] = useState(true); + const [guildsError, setGuildsError] = useState(null); + + const [channels, setChannels] = useState([]); + 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(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 ( +
+
+
{'✓'}
+ + Message sent to #{sentChannelName} + + +
+
+ ); + } + + if (guildsLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (guildsError) { + return ( +
+
+

{guildsError}

+ +
+
+ ); + } + + const trimmedLength = (messageText ?? '').trim().length; + const isOverLimit = (messageText ?? '').length > DISCORD_MESSAGE_MAX_LENGTH; + const canSubmit = + !!selectedGuildId && + !!selectedChannelId && + trimmedLength > 0 && + !isOverLimit && + !sending; + + return ( +
+ +
+
+ + {channelsLoading && ( +
+
+
+ )} +
+ +
+ +
+ +
+