@chat-adapter/discord 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vercel, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,231 @@
1
+ # @chat-adapter/discord
2
+
3
+ Discord adapter for the [chat](https://github.com/vercel-labs/chat) SDK.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install chat @chat-adapter/discord
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```typescript
14
+ import { Chat } from "chat";
15
+ import { createDiscordAdapter } from "@chat-adapter/discord";
16
+
17
+ const chat = new Chat({
18
+ userName: "mybot",
19
+ adapters: {
20
+ discord: createDiscordAdapter({
21
+ botToken: process.env.DISCORD_BOT_TOKEN!,
22
+ publicKey: process.env.DISCORD_PUBLIC_KEY!,
23
+ applicationId: process.env.DISCORD_APPLICATION_ID!,
24
+ // Optional: trigger on role mentions too
25
+ mentionRoleIds: process.env.DISCORD_MENTION_ROLE_IDS?.split(","),
26
+ }),
27
+ },
28
+ });
29
+
30
+ // Handle @mentions
31
+ chat.onNewMention(async (thread, message) => {
32
+ await thread.post("Hello from Discord!");
33
+ });
34
+ ```
35
+
36
+ ## Configuration
37
+
38
+ | Option | Required | Description |
39
+ |--------|----------|-------------|
40
+ | `botToken` | Yes | Discord bot token |
41
+ | `publicKey` | Yes | Discord application public key (for webhook signature verification) |
42
+ | `applicationId` | Yes | Discord application ID |
43
+ | `mentionRoleIds` | No | Array of role IDs that should trigger mention handlers |
44
+
45
+ ## Discord Application Setup
46
+
47
+ ### 1. Create Application
48
+
49
+ 1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
50
+ 2. Click **New Application** and give it a name
51
+ 3. Note the **Application ID** from the General Information page
52
+ 4. Copy the **Public Key** from the General Information page
53
+
54
+ ### 2. Create Bot
55
+
56
+ 1. Go to the **Bot** section in the left sidebar
57
+ 2. Click **Reset Token** to generate a new bot token
58
+ 3. Copy and save the token (you won't see it again)
59
+ 4. Enable these **Privileged Gateway Intents**:
60
+ - Message Content Intent
61
+ - Server Members Intent (if needed)
62
+
63
+ ### 3. Configure Interactions Endpoint
64
+
65
+ 1. Go to **General Information**
66
+ 2. Set **Interactions Endpoint URL** to: `https://your-domain.com/api/webhooks/discord`
67
+ 3. Discord will send a PING request to verify the endpoint
68
+
69
+ ### 4. Add Bot to Server
70
+
71
+ 1. Go to **OAuth2 > URL Generator**
72
+ 2. Select scopes: `bot`, `applications.commands`
73
+ 3. Select bot permissions:
74
+ - Send Messages
75
+ - Send Messages in Threads
76
+ - Create Public Threads
77
+ - Read Message History
78
+ - Add Reactions
79
+ - Attach Files
80
+ 4. Copy the generated URL and open it to add the bot to your server
81
+
82
+ ## Environment Variables
83
+
84
+ ```bash
85
+ # Required
86
+ DISCORD_BOT_TOKEN=your-bot-token
87
+ DISCORD_PUBLIC_KEY=your-application-public-key
88
+ DISCORD_APPLICATION_ID=your-application-id
89
+
90
+ # Optional: trigger on role mentions (comma-separated)
91
+ DISCORD_MENTION_ROLE_IDS=1234567890,0987654321
92
+
93
+ # For Gateway mode with Vercel Cron
94
+ CRON_SECRET=your-random-secret
95
+ ```
96
+
97
+ ## Architecture: HTTP Interactions vs Gateway
98
+
99
+ Discord has two ways to receive events:
100
+
101
+ ### HTTP Interactions (Default)
102
+ - Receives button clicks, slash commands, and verification pings
103
+ - Works out of the box with serverless
104
+ - **Does NOT receive regular messages** - only interactions
105
+
106
+ ### Gateway WebSocket (Required for Messages)
107
+ - Required to receive regular messages and reactions
108
+ - Requires a persistent connection
109
+ - In serverless environments, use a cron job to maintain the connection
110
+
111
+ ## Gateway Setup for Serverless
112
+
113
+ For Vercel/serverless deployments, set up a cron job to maintain the Gateway connection:
114
+
115
+ ### 1. Create Gateway Route
116
+
117
+ ```typescript
118
+ // app/api/discord/gateway/route.ts
119
+ import { NextResponse } from "next/server";
120
+ import { after } from "next/server";
121
+ import { discord } from "@/lib/bot";
122
+
123
+ export const maxDuration = 800; // Maximum Vercel function duration
124
+
125
+ export async function GET(request: Request): Promise<Response> {
126
+ // Validate cron secret
127
+ const cronSecret = process.env.CRON_SECRET;
128
+ if (!cronSecret) {
129
+ return new Response("CRON_SECRET not configured", { status: 500 });
130
+ }
131
+ const authHeader = request.headers.get("authorization");
132
+ if (authHeader !== `Bearer ${cronSecret}`) {
133
+ return new Response("Unauthorized", { status: 401 });
134
+ }
135
+
136
+ // Start Gateway listener (runs for 10 minutes)
137
+ const durationMs = 600 * 1000;
138
+ const webhookUrl = `https://${process.env.VERCEL_URL}/api/webhooks/discord`;
139
+
140
+ return discord.startGatewayListener(
141
+ { waitUntil: (task) => after(() => task) },
142
+ durationMs,
143
+ undefined,
144
+ webhookUrl
145
+ );
146
+ }
147
+ ```
148
+
149
+ ### 2. Configure Vercel Cron
150
+
151
+ Create `vercel.json`:
152
+
153
+ ```json
154
+ {
155
+ "crons": [
156
+ {
157
+ "path": "/api/discord/gateway",
158
+ "schedule": "*/9 * * * *"
159
+ }
160
+ ]
161
+ }
162
+ ```
163
+
164
+ This runs every 9 minutes, ensuring overlap with the 10-minute listener duration.
165
+
166
+ ### 3. Add Environment Variables
167
+
168
+ Add `CRON_SECRET` to your Vercel project settings.
169
+
170
+ ## Role Mentions
171
+
172
+ By default, only direct user mentions (`@BotName`) trigger `onNewMention` handlers. To also trigger on role mentions (e.g., `@AI`):
173
+
174
+ 1. Create a role in your Discord server (e.g., "AI")
175
+ 2. Assign the role to your bot
176
+ 3. Copy the role ID (right-click role in server settings with Developer Mode enabled)
177
+ 4. Add the role ID to `DISCORD_MENTION_ROLE_IDS`
178
+
179
+ ```typescript
180
+ createDiscordAdapter({
181
+ botToken: process.env.DISCORD_BOT_TOKEN!,
182
+ publicKey: process.env.DISCORD_PUBLIC_KEY!,
183
+ applicationId: process.env.DISCORD_APPLICATION_ID!,
184
+ mentionRoleIds: ["1457473602180878604"], // Your role ID
185
+ });
186
+ ```
187
+
188
+ ## Features
189
+
190
+ - Message posting and editing
191
+ - Thread creation and management
192
+ - Reaction handling (add/remove/events)
193
+ - File attachments
194
+ - Rich embeds (cards with buttons)
195
+ - Action callbacks (button interactions)
196
+ - Direct messages
197
+ - Role mention support
198
+
199
+ ## Testing
200
+
201
+ Run a local tunnel (e.g., ngrok) to test webhooks:
202
+
203
+ ```bash
204
+ ngrok http 3000
205
+ ```
206
+
207
+ Update the Interactions Endpoint URL in the Discord Developer Portal to your ngrok URL.
208
+
209
+ ## Troubleshooting
210
+
211
+ ### Bot not responding to messages
212
+
213
+ 1. **Check Gateway connection**: Messages require the Gateway WebSocket, not just HTTP interactions
214
+ 2. **Verify Message Content Intent**: Enable this in the Bot settings
215
+ 3. **Check bot permissions**: Ensure the bot can read messages in the channel
216
+
217
+ ### Role mentions not triggering
218
+
219
+ 1. **Verify role ID**: Enable Developer Mode in Discord settings, then right-click the role
220
+ 2. **Check mentionRoleIds config**: Ensure the role ID is in the array
221
+ 3. **Confirm bot has the role**: The bot must have the role assigned to be mentioned via that role
222
+
223
+ ### Signature verification failing
224
+
225
+ 1. **Check public key format**: Should be a 64-character hex string (lowercase)
226
+ 2. **Verify endpoint URL**: Must exactly match what's configured in Discord Developer Portal
227
+ 3. **Check for body parsing**: Don't parse the request body before verification
228
+
229
+ ## License
230
+
231
+ MIT
@@ -0,0 +1,301 @@
1
+ import { CardElement, BaseFormatConverter, AdapterPostableMessage, Root, Adapter, Logger, ChatInstance, WebhookOptions, RawMessage, EmojiValue, FetchOptions, FetchResult, ThreadInfo, Message, FormattedContent } from 'chat';
2
+ import { ButtonStyle, APIEmbed } from 'discord-api-types/v10';
3
+
4
+ /**
5
+ * Discord adapter types.
6
+ */
7
+
8
+ /**
9
+ * Discord adapter configuration.
10
+ */
11
+ interface DiscordAdapterConfig {
12
+ /** Discord bot token */
13
+ botToken: string;
14
+ /** Discord application public key for webhook signature verification */
15
+ publicKey: string;
16
+ /** Discord application ID */
17
+ applicationId: string;
18
+ /** Role IDs that should trigger mention handlers (in addition to direct user mentions) */
19
+ mentionRoleIds?: string[];
20
+ }
21
+ /**
22
+ * Discord thread ID components.
23
+ * Used for encoding/decoding thread IDs.
24
+ */
25
+ interface DiscordThreadId {
26
+ /** Guild ID, or "@me" for DMs */
27
+ guildId: string;
28
+ /** Channel ID */
29
+ channelId: string;
30
+ /** Thread ID (if message is in a thread) */
31
+ threadId?: string;
32
+ }
33
+ /**
34
+ * Discord emoji.
35
+ */
36
+ interface DiscordEmoji {
37
+ id?: string;
38
+ name: string;
39
+ animated?: boolean;
40
+ }
41
+ /**
42
+ * Discord button component.
43
+ */
44
+ interface DiscordButton {
45
+ type: 2;
46
+ style: ButtonStyle;
47
+ label?: string;
48
+ emoji?: DiscordEmoji;
49
+ custom_id?: string;
50
+ url?: string;
51
+ disabled?: boolean;
52
+ }
53
+ /**
54
+ * Discord action row component.
55
+ */
56
+ interface DiscordActionRow {
57
+ type: 1;
58
+ components: DiscordButton[];
59
+ }
60
+
61
+ /**
62
+ * Discord Embed and Component converter for cross-platform cards.
63
+ *
64
+ * Converts CardElement to Discord Embeds and Action Row Components.
65
+ * @see https://discord.com/developers/docs/resources/message#embed-object
66
+ * @see https://discord.com/developers/docs/interactions/message-components
67
+ */
68
+
69
+ /**
70
+ * Convert a CardElement to Discord message payload (embeds + components).
71
+ */
72
+ declare function cardToDiscordPayload(card: CardElement): {
73
+ embeds: APIEmbed[];
74
+ components: DiscordActionRow[];
75
+ };
76
+ /**
77
+ * Generate fallback text from a card element.
78
+ * Used when embeds aren't supported or for notifications.
79
+ */
80
+ declare function cardToFallbackText(card: CardElement): string;
81
+
82
+ /**
83
+ * Discord-specific format conversion using AST-based parsing.
84
+ *
85
+ * Discord uses standard markdown with some extensions:
86
+ * - Bold: **text** (standard)
87
+ * - Italic: *text* or _text_ (standard)
88
+ * - Strikethrough: ~~text~~ (standard GFM)
89
+ * - Links: [text](url) (standard)
90
+ * - User mentions: <@userId>
91
+ * - Channel mentions: <#channelId>
92
+ * - Role mentions: <@&roleId>
93
+ * - Custom emoji: <:name:id> or <a:name:id> (animated)
94
+ * - Spoiler: ||text||
95
+ */
96
+
97
+ declare class DiscordFormatConverter extends BaseFormatConverter {
98
+ /**
99
+ * Convert @mentions to Discord format in plain text.
100
+ * @name → <@name>
101
+ */
102
+ private convertMentionsToDiscord;
103
+ /**
104
+ * Override renderPostable to convert @mentions in plain strings.
105
+ */
106
+ renderPostable(message: AdapterPostableMessage): string;
107
+ /**
108
+ * Render an AST to Discord markdown format.
109
+ */
110
+ fromAst(ast: Root): string;
111
+ /**
112
+ * Parse Discord markdown into an AST.
113
+ */
114
+ toAst(discordMarkdown: string): Root;
115
+ private nodeToDiscordMarkdown;
116
+ }
117
+
118
+ /**
119
+ * Discord adapter for chat-sdk.
120
+ *
121
+ * Uses Discord's HTTP Interactions API (not Gateway WebSocket) for
122
+ * serverless compatibility. Webhook signature verification uses Ed25519.
123
+ */
124
+
125
+ declare class DiscordAdapter implements Adapter<DiscordThreadId, unknown> {
126
+ readonly name = "discord";
127
+ readonly userName: string;
128
+ readonly botUserId?: string;
129
+ private botToken;
130
+ private publicKey;
131
+ private applicationId;
132
+ private mentionRoleIds;
133
+ private chat;
134
+ private logger;
135
+ private formatConverter;
136
+ constructor(config: DiscordAdapterConfig & {
137
+ logger: Logger;
138
+ userName?: string;
139
+ });
140
+ initialize(chat: ChatInstance): Promise<void>;
141
+ /**
142
+ * Handle incoming Discord webhook (HTTP Interactions or forwarded Gateway events).
143
+ */
144
+ handleWebhook(request: Request, options?: WebhookOptions): Promise<Response>;
145
+ /**
146
+ * Verify Discord's Ed25519 signature using official discord-interactions library.
147
+ */
148
+ private verifySignature;
149
+ /**
150
+ * Create a JSON response for Discord interactions.
151
+ */
152
+ private respondToInteraction;
153
+ /**
154
+ * Handle MESSAGE_COMPONENT interactions (button clicks).
155
+ */
156
+ private handleComponentInteraction;
157
+ /**
158
+ * Handle a forwarded Gateway event received via webhook.
159
+ */
160
+ private handleForwardedGatewayEvent;
161
+ /**
162
+ * Handle a forwarded MESSAGE_CREATE event.
163
+ */
164
+ private handleForwardedMessage;
165
+ /**
166
+ * Handle a forwarded REACTION_ADD or REACTION_REMOVE event.
167
+ */
168
+ private handleForwardedReaction;
169
+ /**
170
+ * Post a message to a Discord channel or thread.
171
+ */
172
+ postMessage(threadId: string, message: AdapterPostableMessage): Promise<RawMessage<unknown>>;
173
+ /**
174
+ * Create a Discord thread from a message.
175
+ */
176
+ private createDiscordThread;
177
+ /**
178
+ * Truncate content to Discord's maximum length.
179
+ */
180
+ private truncateContent;
181
+ /**
182
+ * Post a message with file attachments.
183
+ */
184
+ private postMessageWithFiles;
185
+ /**
186
+ * Edit an existing Discord message.
187
+ */
188
+ editMessage(threadId: string, messageId: string, message: AdapterPostableMessage): Promise<RawMessage<unknown>>;
189
+ /**
190
+ * Delete a Discord message.
191
+ */
192
+ deleteMessage(threadId: string, messageId: string): Promise<void>;
193
+ /**
194
+ * Add a reaction to a Discord message.
195
+ */
196
+ addReaction(threadId: string, messageId: string, emoji: EmojiValue | string): Promise<void>;
197
+ /**
198
+ * Remove a reaction from a Discord message.
199
+ */
200
+ removeReaction(threadId: string, messageId: string, emoji: EmojiValue | string): Promise<void>;
201
+ /**
202
+ * Encode an emoji for use in Discord API URLs.
203
+ */
204
+ private encodeEmoji;
205
+ /**
206
+ * Start typing indicator in a Discord channel or thread.
207
+ */
208
+ startTyping(threadId: string): Promise<void>;
209
+ /**
210
+ * Fetch messages from a Discord channel or thread.
211
+ * If threadId includes a Discord thread ID, fetches from that thread channel.
212
+ */
213
+ fetchMessages(threadId: string, options?: FetchOptions): Promise<FetchResult<unknown>>;
214
+ /**
215
+ * Fetch thread/channel information.
216
+ */
217
+ fetchThread(threadId: string): Promise<ThreadInfo>;
218
+ /**
219
+ * Open a DM with a user.
220
+ */
221
+ openDM(userId: string): Promise<string>;
222
+ /**
223
+ * Check if a thread is a DM.
224
+ */
225
+ isDM(threadId: string): boolean;
226
+ /**
227
+ * Encode platform data into a thread ID string.
228
+ */
229
+ encodeThreadId(platformData: DiscordThreadId): string;
230
+ /**
231
+ * Decode thread ID string back to platform data.
232
+ */
233
+ decodeThreadId(threadId: string): DiscordThreadId;
234
+ /**
235
+ * Parse a Discord message into normalized format.
236
+ */
237
+ parseMessage(raw: unknown): Message<unknown>;
238
+ /**
239
+ * Parse a Discord API message into normalized format.
240
+ */
241
+ private parseDiscordMessage;
242
+ /**
243
+ * Determine attachment type from MIME type.
244
+ */
245
+ private getAttachmentType;
246
+ /**
247
+ * Render formatted content to Discord markdown.
248
+ */
249
+ renderFormatted(content: FormattedContent): string;
250
+ /**
251
+ * Make a request to the Discord API.
252
+ */
253
+ private discordFetch;
254
+ /**
255
+ * Start Gateway WebSocket listener for receiving messages/mentions.
256
+ * Uses waitUntil to keep the connection alive for the specified duration.
257
+ *
258
+ * This is a workaround for serverless environments - the Gateway connection
259
+ * will stay alive for the duration, listening for messages.
260
+ *
261
+ * @param options - Webhook options with waitUntil function
262
+ * @param durationMs - How long to keep listening (default: 180000ms = 3 minutes)
263
+ * @param abortSignal - Optional AbortSignal to stop the listener early (e.g., when a new listener starts)
264
+ * @param webhookUrl - URL to forward Gateway events to (required for webhook forwarding mode)
265
+ * @returns Response indicating the listener was started
266
+ */
267
+ startGatewayListener(options: WebhookOptions, durationMs?: number, abortSignal?: AbortSignal, webhookUrl?: string): Promise<Response>;
268
+ /**
269
+ * Run the Gateway listener for a specified duration.
270
+ */
271
+ private runGatewayListener;
272
+ /**
273
+ * Set up legacy Gateway handlers for direct processing (when webhookUrl is not provided).
274
+ */
275
+ private setupLegacyGatewayHandlers;
276
+ /**
277
+ * Forward a Gateway event to the webhook endpoint.
278
+ */
279
+ private forwardGatewayEvent;
280
+ /**
281
+ * Handle a message received via the Gateway WebSocket.
282
+ */
283
+ private handleGatewayMessage;
284
+ /**
285
+ * Handle a reaction received via the Gateway WebSocket.
286
+ */
287
+ private handleGatewayReaction;
288
+ /**
289
+ * Normalize a Discord emoji to our standard EmojiValue format.
290
+ */
291
+ private normalizeDiscordEmoji;
292
+ }
293
+ /**
294
+ * Create a Discord adapter instance.
295
+ */
296
+ declare function createDiscordAdapter(config: DiscordAdapterConfig & {
297
+ logger: Logger;
298
+ userName?: string;
299
+ }): DiscordAdapter;
300
+
301
+ export { DiscordAdapter, type DiscordAdapterConfig, DiscordFormatConverter, DiscordFormatConverter as DiscordMarkdownConverter, type DiscordThreadId, cardToDiscordPayload, cardToFallbackText, createDiscordAdapter };