From 5f9e46a12ca384102ba28e57ea106be980e205fe Mon Sep 17 00:00:00 2001 From: dancer Date: Tue, 12 May 2026 04:19:18 +0100 Subject: [PATCH 1/3] wip --- packages/adapter-discord/src/index.ts | 143 ++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/packages/adapter-discord/src/index.ts b/packages/adapter-discord/src/index.ts index f8bb8c7d..16a018f7 100644 --- a/packages/adapter-discord/src/index.ts +++ b/packages/adapter-discord/src/index.ts @@ -40,10 +40,14 @@ import { Message, } from "chat"; import { + type ChatInputCommandInteraction, Client, + type Interaction as DiscordJsInteraction, type Message as DiscordJsMessage, + type User as DiscordJsUser, Events, GatewayIntentBits, + type MessageComponentInteraction, Partials, } from "discord.js"; import { MessageType } from "discord-api-types/v9"; @@ -82,6 +86,13 @@ const DISCORD_MAX_CONTENT_LENGTH = 2000; const HEX_64_PATTERN = /^[0-9a-f]{64}$/; const HEX_PATTERN = /^[0-9a-f]+$/; +interface GatewayCommandOption { + name: string; + options?: readonly GatewayCommandOption[]; + type: number; + value?: boolean | number | string; +} + export class DiscordAdapter implements Adapter { readonly name = "discord"; readonly userName: string; @@ -559,6 +570,117 @@ export class DiscordAdapter implements Adapter { }; } + protected async handleGatewayInteraction( + interaction: DiscordJsInteraction + ): Promise { + if (interaction.isChatInputCommand()) { + await interaction.deferReply(); + this.handleApplicationCommandInteraction( + this.normalizeGatewaySlashCommandInteraction(interaction) + ); + return; + } + + if (interaction.isMessageComponent()) { + await interaction.deferUpdate(); + this.handleComponentInteraction( + this.normalizeGatewayComponentInteraction(interaction) + ); + } + } + + protected normalizeGatewaySlashCommandInteraction( + interaction: ChatInputCommandInteraction + ): DiscordInteraction { + return { + application_id: interaction.applicationId, + channel: this.normalizeGatewayChannel(interaction), + channel_id: interaction.channelId ?? undefined, + data: { + name: interaction.commandName, + options: this.normalizeGatewayCommandOptions(interaction.options.data), + type: interaction.commandType, + }, + guild_id: interaction.guildId ?? undefined, + id: interaction.id, + token: interaction.token, + type: interaction.type, + user: this.normalizeGatewayUser(interaction.user), + version: interaction.version, + }; + } + + protected normalizeGatewayComponentInteraction( + interaction: MessageComponentInteraction + ): DiscordInteraction { + const values = + "values" in interaction && Array.isArray(interaction.values) + ? interaction.values + : undefined; + + return { + application_id: interaction.applicationId, + channel: this.normalizeGatewayChannel(interaction), + channel_id: interaction.channelId ?? undefined, + data: { + component_type: interaction.componentType, + custom_id: interaction.customId, + values, + }, + guild_id: interaction.guildId ?? undefined, + id: interaction.id, + message: { id: interaction.message.id } as APIMessage, + token: interaction.token, + type: interaction.type, + user: this.normalizeGatewayUser(interaction.user), + version: interaction.version, + }; + } + + protected normalizeGatewayChannel( + interaction: ChatInputCommandInteraction | MessageComponentInteraction + ): DiscordInteraction["channel"] { + if (!interaction.channel) { + return undefined; + } + + const parentId = + "parentId" in interaction.channel && + typeof interaction.channel.parentId === "string" + ? interaction.channel.parentId + : undefined; + + return { + id: interaction.channel.id, + parent_id: parentId, + type: interaction.channel.type, + }; + } + + protected normalizeGatewayCommandOptions( + options: readonly GatewayCommandOption[] + ): DiscordCommandOption[] { + return options.map((option) => ({ + name: option.name, + options: option.options + ? this.normalizeGatewayCommandOptions(option.options) + : undefined, + type: option.type, + value: option.value, + })); + } + + protected normalizeGatewayUser(user: DiscordJsUser): DiscordUser { + return { + avatar: user.avatar ?? undefined, + bot: user.bot, + discriminator: user.discriminator, + global_name: user.globalName ?? undefined, + id: user.id, + username: user.username, + }; + } + /** * Handle a forwarded Gateway event received via webhook. */ @@ -1845,6 +1967,27 @@ export class DiscordAdapter implements Adapter { await this.handleGatewayMessage(message, isMentioned); }); + client.on(Events.InteractionCreate, async (interaction) => { + if (isShuttingDown()) { + this.logger.debug("Ignoring interaction - Gateway is shutting down"); + return; + } + + this.logger.info("Discord Gateway interaction received", { + id: interaction.id, + type: interaction.type, + }); + + try { + await this.handleGatewayInteraction(interaction); + } catch (error) { + this.logger.error("Error handling Gateway interaction", { + error: String(error), + interactionId: interaction.id, + }); + } + }); + // Reaction add handler client.on(Events.MessageReactionAdd, async (reaction, user) => { if (isShuttingDown()) { From e574b91deb07557b9ab4b22a957bcd1a4f2300eb Mon Sep 17 00:00:00 2001 From: dancer Date: Tue, 12 May 2026 04:21:09 +0100 Subject: [PATCH 2/3] hmm --- packages/adapter-discord/src/index.test.ts | 149 +++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/packages/adapter-discord/src/index.test.ts b/packages/adapter-discord/src/index.test.ts index 592a5155..4f20e6f2 100644 --- a/packages/adapter-discord/src/index.test.ts +++ b/packages/adapter-discord/src/index.test.ts @@ -3,9 +3,11 @@ */ import { generateKeyPairSync, sign } from "node:crypto"; +import { EventEmitter } from "node:events"; import { ValidationError } from "@chat-adapter/shared"; import type { ChatInstance, Logger } from "chat"; import { Actions, Button, Card } from "chat"; +import { type Client, Events } from "discord.js"; import { InteractionType } from "discord-api-types/v10"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createDiscordAdapter, DiscordAdapter } from "./index"; @@ -70,6 +72,10 @@ function createWebhookRequest( }); } +function waitForGatewayHandlers() { + return new Promise((resolve) => setImmediate(resolve)); +} + // ============================================================================ // Factory Function Tests // ============================================================================ @@ -3229,6 +3235,149 @@ describe("fetchThread", () => { }); }); +describe("legacy gateway interactions", () => { + class TestGatewayDiscordAdapter extends DiscordAdapter { + listen(client: Client, isShuttingDown = () => false) { + this.setupLegacyGatewayHandlers(client, isShuttingDown); + } + } + + function createGatewayClient() { + return new EventEmitter() as Client; + } + + it("handles slash command interactions from the gateway", async () => { + const processSlashCommand = vi.fn(); + const adapter = new TestGatewayDiscordAdapter({ + botToken: "test-token", + publicKey: testPublicKey, + applicationId: "test-app-id", + logger: mockLogger, + }); + + await adapter.initialize({ + handleIncomingMessage: vi.fn(), + processSlashCommand, + processAction: vi.fn(), + processReaction: vi.fn(), + } as unknown as ChatInstance); + + const client = createGatewayClient(); + const deferReply = vi.fn().mockResolvedValue(undefined); + + adapter.listen(client); + client.emit(Events.InteractionCreate, { + id: "interaction123", + applicationId: "test-app-id", + token: "interaction-token", + type: InteractionType.ApplicationCommand, + version: 1, + guildId: "guild123", + channelId: "channel456", + channel: { + id: "channel456", + type: 0, + }, + user: { + id: "user789", + username: "testuser", + discriminator: "0001", + globalName: "Test User", + bot: false, + }, + commandName: "test", + commandType: 1, + options: { + data: [ + { name: "topic", type: 3, value: "status" }, + { name: "verbose", type: 5, value: true }, + ], + }, + isChatInputCommand: () => true, + isMessageComponent: () => false, + deferReply, + }); + await waitForGatewayHandlers(); + + expect(deferReply).toHaveBeenCalled(); + expect(processSlashCommand).toHaveBeenCalledWith( + expect.objectContaining({ + command: "/test", + text: "status true", + channelId: "discord:guild123:channel456", + user: expect.objectContaining({ + userId: "user789", + userName: "testuser", + fullName: "Test User", + }), + }), + undefined + ); + }); + + it("handles component interactions from the gateway", async () => { + const processAction = vi.fn(); + const adapter = new TestGatewayDiscordAdapter({ + botToken: "test-token", + publicKey: testPublicKey, + applicationId: "test-app-id", + logger: mockLogger, + }); + + await adapter.initialize({ + handleIncomingMessage: vi.fn(), + processSlashCommand: vi.fn(), + processAction, + processReaction: vi.fn(), + } as unknown as ChatInstance); + + const client = createGatewayClient(); + const deferUpdate = vi.fn().mockResolvedValue(undefined); + + adapter.listen(client); + client.emit(Events.InteractionCreate, { + id: "interaction123", + applicationId: "test-app-id", + token: "interaction-token", + type: InteractionType.MessageComponent, + version: 1, + guildId: "guild123", + channelId: "channel456", + channel: { + id: "channel456", + type: 0, + }, + user: { + id: "user789", + username: "testuser", + discriminator: "0001", + globalName: "Test User", + bot: false, + }, + customId: "approve_btn", + componentType: 2, + message: { + id: "message123", + }, + isChatInputCommand: () => false, + isMessageComponent: () => true, + deferUpdate, + }); + await waitForGatewayHandlers(); + + expect(deferUpdate).toHaveBeenCalled(); + expect(processAction).toHaveBeenCalledWith( + expect.objectContaining({ + actionId: "approve_btn", + value: "approve_btn", + messageId: "message123", + threadId: "discord:guild123:channel456", + }), + undefined + ); + }); +}); + // ============================================================================ // Forwarded Gateway Event Tests // ============================================================================ From 0e2ee0e3d819b71b207ebbc317004033f1faef59 Mon Sep 17 00:00:00 2001 From: dancer Date: Tue, 12 May 2026 04:23:26 +0100 Subject: [PATCH 3/3] docs --- .changeset/rare-donuts-count.md | 5 +++++ packages/adapter-discord/README.md | 3 +++ 2 files changed, 8 insertions(+) create mode 100644 .changeset/rare-donuts-count.md diff --git a/.changeset/rare-donuts-count.md b/.changeset/rare-donuts-count.md new file mode 100644 index 00000000..98c764c1 --- /dev/null +++ b/.changeset/rare-donuts-count.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/discord": patch +--- + +handle slash commands and button interactions in Discord gateway-only mode diff --git a/packages/adapter-discord/README.md b/packages/adapter-discord/README.md index 2d4046ea..bf0eb639 100644 --- a/packages/adapter-discord/README.md +++ b/packages/adapter-discord/README.md @@ -73,9 +73,12 @@ Discord has two ways to receive events: **Gateway WebSocket (required for messages):** - Receives regular messages and reactions +- Receives slash commands and button clicks when no Interactions Endpoint URL is configured - Requires a persistent connection - In serverless environments, use a cron job to maintain the connection +Discord sends interactions through either the Gateway or an Interactions Endpoint URL, not both. Use the HTTP endpoint for serverless apps. For resident gateway-only apps, leave the Interactions Endpoint URL unset and start the Gateway listener without `webhookUrl` so interactions are processed directly. + ## Gateway setup for serverless ### 1. Create Gateway route