feat(channels-discord): reply to a mention in a thread

Adds respondTo.appMentions.reply, mirroring Slack's option of the same name
and shape. "thread" opens a thread on the mentioned message and answers
inside it; "channel" answers in place, which is what Discord did before.
The default is "thread", matching Slack.

BREAKING CHANGE: a Discord bot that does not set respondTo now answers an
@mention in a new thread instead of in the channel. Set
respondTo.appMentions.reply to "channel" to keep the old behavior.

The two platforms differ in what a thread costs. On Slack it is implicit:
posting with thread_ts is the whole operation, so the mode is a pure branch.
On Discord a thread is an object that must be created first, and it becomes
its own channel with its own id, which then serves as the conversation key.
Resolving the reply target is therefore async, and the turn is dispatched
after it settles.

Every path that cannot open a thread falls back to replying in the channel:
a DM, a message already inside a thread, a non-mention, and a failed create
(a missing CREATE_PUBLIC_THREADS permission is the common one). Losing the
thread must never cost the user their turn.
This commit is contained in:
Alem Tuzlak
2026-09-02 18:01:29 +02:00
parent 01825653ba
commit 69792a782a
5 changed files with 303 additions and 16 deletions
+7
View File
@@ -27,6 +27,7 @@ import type {
EphemeralResult,
} from "@copilotkit/channels-ui";
import { toPlatformEmoji } from "@copilotkit/channels-ui";
import type { DiscordRespondToOptions } from "./types.js";
import { DiscordConversationStore } from "./conversation-store.js";
import type { DiscordHistoryMessage } from "./conversation-store.js";
import { attachDiscordListener } from "./discord-listener.js";
@@ -54,6 +55,11 @@ export interface DiscordAdapterOptions {
/** When set, slash commands register to this guild instantly (dev); else global. */
guildId?: string;
interruptEventNames?: ReadonlySet<string>;
/**
* Where the bot answers. Mirrors Slack's `respondTo`. Defaults to replying in
* a thread opened on the mentioned message.
*/
respondTo?: DiscordRespondToOptions;
}
/**
@@ -223,6 +229,7 @@ export class DiscordAdapter implements PlatformAdapter {
client: this.client as never,
botUserId: () => this.botUserId, // read lazily — only known after `ready`
commandPending: this.commandPending,
respondTo: this.opts.respondTo,
onTurn: async (turn) => {
// The conversation store reconstructs the full channel history each
// turn — including the triggering message and ALL its attachments — so
@@ -24,6 +24,13 @@ function reaction(over: Record<string, unknown> = {}) {
};
}
/**
* Let the listener's async work settle. Resolving the reply target is async
* now, because a threaded mention has to open the thread before the turn is
* dispatched.
*/
const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
const botId = "bot-1";
function message(over: Record<string, unknown>) {
@@ -40,7 +47,7 @@ function message(over: Record<string, unknown>) {
}
describe("attachDiscordListener", () => {
it("emits a turn when the bot is mentioned", () => {
it("emits a turn when the bot is mentioned", async () => {
const client = fakeClient();
const onTurn = vi.fn();
attachDiscordListener({
@@ -59,6 +66,7 @@ describe("attachDiscordListener", () => {
content: "<@bot-1> hi",
}),
);
await flush();
expect(onTurn).toHaveBeenCalledWith(
expect.objectContaining({
conversationKey: "c1",
@@ -75,6 +83,140 @@ describe("attachDiscordListener", () => {
);
});
/** A mention that can open a thread, recording the create call. */
function mentionMsg(over: Record<string, unknown> = {}) {
const started: Array<{ name: string; autoArchiveDuration?: number }> = [];
const msg = message({
mentions: {
has: () => true,
users: { has: (q: string) => q === "bot-1" },
},
content: "<@bot-1> why is checkout slow",
startThread: async (o: {
name: string;
autoArchiveDuration?: number;
}) => {
started.push(o);
return { id: "t1" };
},
...over,
});
return { msg, started };
}
function listen(over: Record<string, unknown> = {}) {
const client = fakeClient();
const onTurn = vi.fn();
attachDiscordListener({
client: client as any,
botUserId: botId,
onTurn,
onCommand: vi.fn(),
...over,
});
return { client, onTurn };
}
it("opens a thread on a mention by default and answers inside it", async () => {
const { client, onTurn } = listen();
const { msg, started } = mentionMsg();
client.emit("messageCreate", msg);
await flush();
// The thread is named from the user's text, with the mention stripped.
expect(started).toEqual([
{ name: "why is checkout slow", autoArchiveDuration: 1440 },
]);
// The reply goes to the thread, and the thread is its own conversation.
expect(onTurn).toHaveBeenCalledWith(
expect.objectContaining({
conversationKey: "t1",
replyTarget: { channelId: "t1", guildId: "g1" },
}),
);
});
it('replies in the channel when reply is "channel"', async () => {
const { client, onTurn } = listen({
respondTo: { appMentions: { reply: "channel" } },
});
const { msg, started } = mentionMsg();
client.emit("messageCreate", msg);
await flush();
expect(started).toEqual([]);
expect(onTurn).toHaveBeenCalledWith(
expect.objectContaining({
conversationKey: "c1",
replyTarget: { channelId: "c1", guildId: "g1" },
}),
);
});
it("does not open a thread inside a thread, or in a DM", async () => {
const inThread = listen();
const a = mentionMsg({
channel: { isDMBased: () => false, isThread: () => true },
});
inThread.client.emit("messageCreate", a.msg);
const dm = listen();
const b = mentionMsg({
channel: { isDMBased: () => true },
guildId: null,
});
dm.client.emit("messageCreate", b.msg);
await flush();
// Nesting is impossible on Discord, and a DM has no threads at all.
expect(a.started).toEqual([]);
expect(b.started).toEqual([]);
expect(inThread.onTurn).toHaveBeenCalledWith(
expect.objectContaining({ conversationKey: "c1" }),
);
expect(dm.onTurn).toHaveBeenCalledTimes(1);
});
it("still answers in the channel when opening a thread fails", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const { client, onTurn } = listen();
const { msg } = mentionMsg({
// What a missing CREATE_PUBLIC_THREADS permission looks like.
startThread: async () => {
throw new Error("Missing Permissions");
},
});
client.emit("messageCreate", msg);
await flush();
// Losing the thread must never cost the user their turn.
expect(onTurn).toHaveBeenCalledWith(
expect.objectContaining({
conversationKey: "c1",
replyTarget: { channelId: "c1", guildId: "g1" },
}),
);
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it("names a thread even when the mention carries no text, and caps at 100", async () => {
const empty = listen();
const a = mentionMsg({ content: "<@bot-1>" });
empty.client.emit("messageCreate", a.msg);
const long = listen();
const b = mentionMsg({ content: `<@bot-1> ${"x".repeat(200)}` });
long.client.emit("messageCreate", b.msg);
await flush();
expect(a.started[0]?.name).toBe("New conversation");
expect(b.started[0]?.name).toHaveLength(100);
});
it("does not answer a role / @everyone mention that only matches via mentions.has", () => {
const client = fakeClient();
const onTurn = vi.fn();
@@ -96,7 +238,7 @@ describe("attachDiscordListener", () => {
expect(onTurn).not.toHaveBeenCalled();
});
it("emits a turn for a DM even without a mention", () => {
it("emits a turn for a DM even without a mention", async () => {
const client = fakeClient();
const onTurn = vi.fn();
attachDiscordListener({
@@ -109,6 +251,7 @@ describe("attachDiscordListener", () => {
"messageCreate",
message({ channel: { isDMBased: () => true }, guildId: null }),
);
await flush();
expect(onTurn).toHaveBeenCalledTimes(1);
});
@@ -141,7 +284,7 @@ describe("attachDiscordListener", () => {
expect(onTurn).not.toHaveBeenCalled();
});
it("resolves a getter-form botUserId per event", () => {
it("resolves a getter-form botUserId per event", async () => {
const client = fakeClient();
const onTurn = vi.fn();
let id = "";
@@ -164,6 +307,7 @@ describe("attachDiscordListener", () => {
content: "<@bot-1> hi",
}),
);
await flush();
expect(onTurn).toHaveBeenCalledWith(
expect.objectContaining({
conversationKey: "c1",
@@ -348,8 +492,7 @@ describe("attachDiscordListener", () => {
),
).not.toThrow();
// Let the rejected promise settle so the .catch runs.
await Promise.resolve();
await Promise.resolve();
await flush();
expect(errSpy).toHaveBeenCalledWith(
"[bot-discord] onTurn handler failed:",
expect.any(Error),
@@ -1,6 +1,12 @@
import type { IncomingReaction } from "@copilotkit/channels-core";
import type { ProviderActor } from "@copilotkit/channels-ui";
import type { IncomingTurn, ReplyTarget } from "./types.js";
import type {
IncomingTurn,
ReplyTarget,
DiscordRespondToOptions,
DiscordMentionReplyMode,
} from "./types.js";
import { resolveDiscordRespondToOptions } from "./types.js";
import { decodeReaction } from "./interaction.js";
import type { PendingInteractions } from "./pending-interactions.js";
@@ -16,7 +22,15 @@ interface MessageLike {
channelId: string;
guildId?: string | null;
mentions: { has(id: string): boolean; users?: { has(id: string): boolean } };
channel: { isDMBased(): boolean };
channel: { isDMBased(): boolean; isThread?(): boolean };
/**
* discord.js `Message#startThread`. Optional so existing fakes keep working;
* when it is missing the mention simply replies in the channel.
*/
startThread?(options: {
name: string;
autoArchiveDuration?: number;
}): Promise<{ id: string }>;
}
interface ChatInputLike {
@@ -77,23 +91,31 @@ export interface ListenerConfig {
* the command path skips registration (no modal support / no ack).
*/
commandPending?: PendingInteractions;
/** Where an @mention replies. Defaults to a thread, matching Slack. */
respondTo?: DiscordRespondToOptions;
}
/** Wire Gateway events to normalized turns/commands. Mirrors attachSlackListener. */
export function attachDiscordListener(cfg: ListenerConfig): void {
const { client, botUserId, onTurn, onCommand, onReaction, commandPending } =
cfg;
const respondTo = resolveDiscordRespondToOptions(cfg.respondTo);
client.on("messageCreate", (msg: MessageLike) => {
const botId = typeof botUserId === "function" ? botUserId() : botUserId;
if (!shouldAnswer(msg, botId)) return;
const replyTarget = {
channelId: msg.channelId,
...(msg.guildId ? { guildId: msg.guildId } : {}),
};
void Promise.resolve(
onTurn({
conversationKey: msg.channelId,
// Opening a thread is an API call, so the reply target is resolved
// asynchronously before the turn is dispatched. The conversation key
// follows the target: a threaded answer is its own conversation, keyed on
// the thread's channel id.
void (async () => {
const replyTarget = await mentionReplyTarget(
msg,
botId,
respondTo.appMentions.reply,
);
await onTurn({
conversationKey: replyTarget.channelId,
messageId: msg.id,
mentioned: msg.mentions.has(botId),
replyTarget,
@@ -105,8 +127,8 @@ export function attachDiscordListener(cfg: ListenerConfig): void {
handle: msg.author.username,
},
raw: msg,
}),
).catch((e) => console.error("[bot-discord] onTurn handler failed:", e));
});
})().catch((e) => console.error("[bot-discord] onTurn handler failed:", e));
});
client.on("interactionCreate", async (i: ChatInputLike) => {
@@ -194,6 +216,64 @@ export function attachDiscordListener(cfg: ListenerConfig): void {
}
/** Answer @-mentions and DMs; skip our own messages and other bots. */
/** 24 hours. The one auto-archive value every guild can use. */
const THREAD_AUTO_ARCHIVE_MINUTES = 1440;
/** Discord's hard limit on a thread name. */
const THREAD_NAME_MAX = 100;
/**
* Resolve where an @mention is answered.
*
* Slack expresses this as a pure branch, because posting with `thread_ts` is
* the whole operation. Discord has to create the thread first, so this can
* fail — and a failure must never cost the user their turn. Every path that
* cannot open a thread falls back to replying in the channel.
*/
async function mentionReplyTarget(
msg: MessageLike,
botUserId: string,
mode: DiscordMentionReplyMode,
): Promise<ReplyTarget> {
const base: ReplyTarget = {
channelId: msg.channelId,
...(msg.guildId ? { guildId: msg.guildId } : {}),
};
if (mode !== "thread") return base;
// A DM cannot hold a thread. A message already inside one is answered there,
// mirroring Slack, where `thread_ts` falls back to the message's own `ts`.
if (msg.channel.isDMBased()) return base;
if (msg.channel.isThread?.()) return base;
// Threading is for mentions only, like Slack's `appMentions`. A DM reaching
// this point is already excluded above.
if (!(msg.mentions.users?.has?.(botUserId) ?? false)) return base;
if (typeof msg.startThread !== "function") return base;
try {
const thread = await msg.startThread({
name: threadName(stripMention(msg.content, botUserId)),
autoArchiveDuration: THREAD_AUTO_ARCHIVE_MINUTES,
});
return { ...base, channelId: thread.id };
} catch (e) {
// Usually a missing CREATE_PUBLIC_THREADS permission. Replying in the
// channel is not what was configured, but it is far better than silence.
console.error(
"[bot-discord] could not open a thread; replying in the channel instead:",
e,
);
return base;
}
}
/** A thread needs a name; Discord rejects an empty one and caps it at 100. */
function threadName(text: string): string {
const trimmed = text.trim().replace(/\s+/g, " ");
if (!trimmed) return "New conversation";
return trimmed.length > THREAD_NAME_MAX
? `${trimmed.slice(0, THREAD_NAME_MAX - 1)}`
: trimmed;
}
function shouldAnswer(msg: MessageLike, botUserId: string): boolean {
if (msg.author.id === botUserId) return false;
if (msg.author.bot) return false;
+10
View File
@@ -19,6 +19,16 @@ export { decodeInteraction } from "./interaction.js";
export { conversationKeyOf } from "./types.js";
export type { ReplyTarget, IncomingTurn } from "./types.js";
export {
DEFAULT_DISCORD_RESPOND_TO_OPTIONS,
resolveDiscordRespondToOptions,
} from "./types.js";
export type {
DiscordMentionReplyMode,
DiscordAppMentionOptions,
DiscordRespondToOptions,
ResolvedDiscordRespondToOptions,
} from "./types.js";
export {
renderComponents,
+47
View File
@@ -16,6 +16,53 @@ export interface IncomingTurn {
raw: unknown;
}
/**
* Where a reply to an @mention goes. Mirrors `SlackMentionReplyMode`.
*
* "thread" opens a Discord thread on the mentioned message and answers inside
* it; "channel" answers in the channel the mention arrived in.
*
* Slack and Discord differ in what this costs. On Slack a thread is implicit —
* posting with `thread_ts` is the whole operation. On Discord a thread is a
* real object that must be created first, and it becomes its own channel with
* its own id, which is then the conversation key.
*/
export type DiscordMentionReplyMode = "thread" | "channel";
export interface DiscordAppMentionOptions {
/**
* Where an @mention should reply. "thread" keeps channel noise down and is
* the default, matching Slack; "channel" replies in place.
*/
reply?: DiscordMentionReplyMode;
}
/** Mirrors `SlackRespondToOptions`, limited to the mention reply mode. */
export interface DiscordRespondToOptions {
appMentions?: DiscordAppMentionOptions;
}
export interface ResolvedDiscordRespondToOptions {
appMentions: { reply: DiscordMentionReplyMode };
}
export const DEFAULT_DISCORD_RESPOND_TO_OPTIONS: ResolvedDiscordRespondToOptions =
{
appMentions: { reply: "thread" },
};
export function resolveDiscordRespondToOptions(
respondTo?: DiscordRespondToOptions,
): ResolvedDiscordRespondToOptions {
return {
appMentions: {
reply:
respondTo?.appMentions?.reply ??
DEFAULT_DISCORD_RESPOND_TO_OPTIONS.appMentions.reply,
},
};
}
/** The conversation key is just the channel id (threads have their own id). */
export function conversationKeyOf(target: ReplyTarget): string {
return target.channelId;