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/discord-channel-allowlist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/discord": minor
---

Add an opt-in channel allowlist for treating non-bot Discord messages as directed to the bot without requiring a mention. Configure via `respondToChannelIds` or the `DISCORD_RESPOND_TO_CHANNEL_IDS` env var (comma-separated).
5 changes: 5 additions & 0 deletions apps/docs/content/adapters/official/discord.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ bot.onNewMention(async (thread, message) => {
description:
"Role IDs that should trigger mention handlers. Auto-detected from `DISCORD_MENTION_ROLE_IDS` (comma-separated).",
},
respondToChannelIds: {
type: "string[]",
description:
"Parent channel IDs whose non-bot messages, including messages in child threads, trigger mention handlers without an @mention. Top-level messages use the adapter's normal per-message Discord thread. Auto-detected from `DISCORD_RESPOND_TO_CHANNEL_IDS` (comma-separated). Defaults to `[]`.",
},
respondToGlobalMentions: {
type: "boolean",
description:
Expand Down
1 change: 1 addition & 0 deletions packages/adapter-discord/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ All options are auto-detected from environment variables when not provided.
| `applicationId` | No* | Discord application ID. Auto-detected from `DISCORD_APPLICATION_ID` |
| `contentFormat` | No | Render Chat SDK cards as `DiscordContentFormat.Embeds` or `DiscordContentFormat.ComponentsV2`. Defaults to `DiscordContentFormat.Embeds` |
| `mentionRoleIds` | No | Array of role IDs that trigger mention handlers. Auto-detected from `DISCORD_MENTION_ROLE_IDS` (comma-separated) |
| `respondToChannelIds` | No | Parent channel IDs whose non-bot messages, including messages in child threads, trigger mention handlers without an @mention. Top-level messages use the adapter's normal per-message Discord thread. Auto-detected from `DISCORD_RESPOND_TO_CHANNEL_IDS` (comma-separated). Defaults to `[]` |
| `respondToGlobalMentions` | No | Treat `@everyone`/`@here` pings as mentions of the bot. Defaults to `false` |
| `interactionFlags` | No | Function returning Discord interaction flags for the initial deferred slash command response |
| `apiUrl` | No | Override the Discord API base URL. Auto-detected from `DISCORD_API_URL` |
Expand Down
78 changes: 77 additions & 1 deletion packages/adapter-discord/src/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@

import { createMockChatInstance, mockLogger } from "@chat-adapter/tests";
import { GatewayIntentBits, Partials } from "discord.js";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDiscordAdapter } from "./index";

const { MockClient, mockClientInstance } = vi.hoisted(() => {
const mockClientInstance = {
channels: { fetch: vi.fn() },
on: vi.fn(),
login: vi.fn().mockResolvedValue("token"),
destroy: vi.fn(),
Expand All @@ -34,6 +35,10 @@ vi.mock("discord.js", async () => {
};
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe("Gateway client configuration", () => {
it("includes Partials.Channel for DM support", async () => {
MockClient.mockClear();
Expand Down Expand Up @@ -80,4 +85,75 @@ describe("Gateway client configuration", () => {
expect(clientOptions.partials).toContain(Partials.Channel);
expect(clientOptions.intents).toContain(GatewayIntentBits.DirectMessages);
});

it("forwards the parent channel only for allowlisted thread messages", async () => {
mockClientInstance.on.mockClear();
mockClientInstance.channels.fetch.mockResolvedValue({
id: "thread789",
parentId: "channel456",
isThread: () => true,
});
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(null, { status: 200 }));
vi.stubGlobal("fetch", fetchMock);

const adapter = createDiscordAdapter({
botToken: "test-token",
publicKey: "a".repeat(64),
applicationId: "test-app-id",
logger: mockLogger,
respondToChannelIds: ["channel456"],
});
await adapter.initialize(createMockChatInstance());

const controller = new AbortController();
let listenerPromise: Promise<unknown> | undefined;
await adapter.startGatewayListener(
{
waitUntil: (promise) => {
listenerPromise = promise as Promise<unknown>;
},
},
1000,
controller.signal,
"https://example.com/webhook"
);

const rawHandler = mockClientInstance.on.mock.calls.find(
([event]) => event === "raw"
)?.[1] as (packet: { t: string; d: unknown }) => Promise<void>;
await rawHandler({
t: "MESSAGE_CREATE",
d: { channel_id: "thread789", author: { bot: false } },
});

expect(mockClientInstance.channels.fetch).toHaveBeenCalledWith("thread789");
const request = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect(JSON.parse(request.body as string)).toMatchObject({
data: {
channel_id: "thread789",
author: { bot: false },
thread: { id: "thread789", parent_id: "channel456" },
},
});

mockClientInstance.channels.fetch.mockResolvedValue({
id: "thread000",
parentId: "other-channel",
isThread: () => true,
});
await rawHandler({
t: "MESSAGE_CREATE",
d: { channel_id: "thread000", author: { bot: false } },
});
const otherRequest = fetchMock.mock.calls[1]?.[1] as RequestInit;
expect(JSON.parse(otherRequest.body as string).data).toEqual({
channel_id: "thread000",
author: { bot: false },
});

controller.abort();
await listenerPromise;
});
});
225 changes: 225 additions & 0 deletions packages/adapter-discord/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,18 @@ describe("constructor env var resolution", () => {
expect(adapter).toBeInstanceOf(DiscordAdapter);
});

it("should resolve respondToChannelIds from DISCORD_RESPOND_TO_CHANNEL_IDS env var", () => {
process.env.DISCORD_BOT_TOKEN = "env-token";
process.env.DISCORD_PUBLIC_KEY = testPublicKey;
process.env.DISCORD_APPLICATION_ID = "env-app-id";
process.env.DISCORD_RESPOND_TO_CHANNEL_IDS = "channel1, channel2";
const adapter = new DiscordAdapter();
expect(
(adapter as unknown as { respondToChannelIds: string[] })
.respondToChannelIds
).toEqual(["channel1", "channel2"]);
});

it("should default logger when not provided", () => {
process.env.DISCORD_BOT_TOKEN = "env-token";
process.env.DISCORD_PUBLIC_KEY = testPublicKey;
Expand Down Expand Up @@ -3634,6 +3646,142 @@ describe("legacy gateway interactions", () => {
);
});

it("creates a thread for messages in an allowlisted channel", async () => {
const adapter = new TestGatewayDiscordAdapter({
botToken: "test-token",
publicKey: testPublicKey,
applicationId: "test-app-id",
logger: mockLogger,
respondToChannelIds: ["channel456"],
});
const fetchSpy = vi.spyOn(adapter as any, "discordFetch").mockResolvedValue(
new Response(JSON.stringify({ id: "thread789", name: "Thread" }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
const chat = createMockChatInstance();
await adapter.initialize(chat);

const client = createGatewayClient();
adapter.listen(client);
client.emit(Events.MessageCreate, {
id: "msg123",
channelId: "channel456",
guildId: "guild1",
content: "No mention needed",
author: {
id: "user789",
username: "testuser",
displayName: "Test User",
bot: false,
},
mentions: { everyone: false, roles: [], has: () => false },
channel: { isThread: () => false },
createdAt: new Date("2021-01-01T00:00:00.000Z"),
editedAt: null,
attachments: [],
});
await waitForGatewayHandlers();

expect(fetchSpy).toHaveBeenCalledWith(
"/channels/channel456/messages/msg123/threads",
"POST",
expect.anything()
);
expect(chat.handleIncomingMessage).toHaveBeenCalledWith(
adapter,
"discord:guild1:channel456:thread789",
expect.objectContaining({ isMention: true })
);
});

it("reuses the existing thread for messages in a thread of an allowlisted channel", async () => {
const adapter = new TestGatewayDiscordAdapter({
botToken: "test-token",
publicKey: testPublicKey,
applicationId: "test-app-id",
logger: mockLogger,
respondToChannelIds: ["channel456"],
});
const fetchSpy = vi
.spyOn(adapter as any, "discordFetch")
.mockResolvedValue(new Response(null, { status: 200 }));
const chat = createMockChatInstance();
await adapter.initialize(chat);

const client = createGatewayClient();
adapter.listen(client);
client.emit(Events.MessageCreate, {
id: "msg456",
channelId: "thread789",
guildId: "guild1",
content: "Thread reply without mention",
author: {
id: "user789",
username: "testuser",
displayName: "Test User",
bot: false,
},
mentions: { everyone: false, roles: [], has: () => false },
channel: { isThread: () => true, parentId: "channel456" },
createdAt: new Date("2021-01-01T00:00:00.000Z"),
editedAt: null,
attachments: [],
});
await waitForGatewayHandlers();

expect(fetchSpy).not.toHaveBeenCalled();
expect(chat.handleIncomingMessage).toHaveBeenCalledWith(
adapter,
"discord:guild1:channel456:thread789",
expect.objectContaining({ isMention: true })
);
});

it("does not treat a parentless thread message as allowlisted", async () => {
const adapter = new TestGatewayDiscordAdapter({
botToken: "test-token",
publicKey: testPublicKey,
applicationId: "test-app-id",
logger: mockLogger,
respondToChannelIds: ["channel456"],
});
const fetchSpy = vi
.spyOn(adapter as any, "discordFetch")
.mockResolvedValue(new Response(null, { status: 200 }));
const chat = createMockChatInstance();
await adapter.initialize(chat);

const client = createGatewayClient();
adapter.listen(client);
client.emit(Events.MessageCreate, {
id: "msg000",
channelId: "thread000",
guildId: "guild1",
content: "Orphan thread message",
author: {
id: "user789",
username: "testuser",
displayName: "Test User",
bot: false,
},
mentions: { everyone: false, roles: [], has: () => false },
channel: { isThread: () => true, parentId: null },
createdAt: new Date("2021-01-01T00:00:00.000Z"),
editedAt: null,
attachments: [],
});
await waitForGatewayHandlers();

expect(fetchSpy).not.toHaveBeenCalled();
expect(chat.handleIncomingMessage).toHaveBeenCalledWith(
adapter,
"discord:guild1:thread000",
expect.objectContaining({ isMention: false })
);
});

it("handles component interactions from the gateway", async () => {
const adapter = new TestGatewayDiscordAdapter({
botToken: "test-token",
Expand Down Expand Up @@ -4055,6 +4203,83 @@ describe("handleForwardedMessage - thread handling", () => {

fetchSpy.mockRestore();
});

it("keeps allowlisted forwarded messages in their Discord thread", async () => {
const adapter = createDiscordAdapter({
botToken: "test-token",
publicKey: testPublicKey,
applicationId: "test-app-id",
logger: mockLogger,
respondToChannelIds: ["channel456"],
});
const fetchSpy = vi.spyOn(adapter as any, "discordFetch").mockResolvedValue(
new Response(JSON.stringify({ id: "thread789", name: "Thread" }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
const chat = createMockChatInstance();
await adapter.initialize(chat);

const forward = (data: Record<string, unknown>) =>
adapter.handleWebhook(
new Request("https://example.com/webhook", {
method: "POST",
headers: {
"x-discord-gateway-token": "test-token",
"content-type": "application/json",
},
body: JSON.stringify({
type: "GATEWAY_MESSAGE_CREATE",
timestamp: Date.now(),
data,
}),
})
);
const message = {
guild_id: "guild1",
content: "No mention needed",
timestamp: "2021-01-01T00:00:00.000Z",
author: { id: "user789", username: "testuser", bot: false },
mentions: [],
attachments: [],
};

await forward({ ...message, id: "msg123", channel_id: "channel456" });
await forward({
...message,
id: "msg456",
channel_id: "thread789",
thread: { id: "thread789", parent_id: "channel456" },
});
await forward({
...message,
id: "msg789",
channel_id: "thread789",
author: { id: "other-bot", username: "other-bot", bot: true },
thread: { id: "thread789", parent_id: "channel456" },
});

expect(fetchSpy).toHaveBeenCalledOnce();
expect(chat.handleIncomingMessage).toHaveBeenNthCalledWith(
1,
adapter,
"discord:guild1:channel456:thread789",
expect.objectContaining({ isMention: true })
);
expect(chat.handleIncomingMessage).toHaveBeenNthCalledWith(
2,
adapter,
"discord:guild1:channel456:thread789",
expect.objectContaining({ isMention: true })
);
expect(chat.handleIncomingMessage).toHaveBeenNthCalledWith(
3,
adapter,
"discord:guild1:channel456:thread789",
expect.objectContaining({ isMention: false })
);
});
});

// ============================================================================
Expand Down
Loading