Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rare-donuts-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/discord": patch
---

handle slash commands and button interactions in Discord gateway-only mode
3 changes: 3 additions & 0 deletions packages/adapter-discord/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
149 changes: 149 additions & 0 deletions packages/adapter-discord/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -70,6 +72,10 @@ function createWebhookRequest(
});
}

function waitForGatewayHandlers() {
return new Promise((resolve) => setImmediate(resolve));
}

// ============================================================================
// Factory Function Tests
// ============================================================================
Expand Down Expand Up @@ -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
// ============================================================================
Expand Down
143 changes: 143 additions & 0 deletions packages/adapter-discord/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<DiscordThreadId, unknown> {
readonly name = "discord";
readonly userName: string;
Expand Down Expand Up @@ -559,6 +570,117 @@ export class DiscordAdapter implements Adapter<DiscordThreadId, unknown> {
};
}

protected async handleGatewayInteraction(
interaction: DiscordJsInteraction
): Promise<void> {
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.
*/
Expand Down Expand Up @@ -1845,6 +1967,27 @@ export class DiscordAdapter implements Adapter<DiscordThreadId, unknown> {
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()) {
Expand Down