From 6ef9fc64d7cc97df809fcb4e4a0e272365431864 Mon Sep 17 00:00:00 2001 From: Shadow Date: Fri, 9 Jan 2026 09:52:04 -0600 Subject: [PATCH] Discord: fix forum thread starters --- src/discord/monitor.tool-result.test.ts | 107 ++++++++++++++++++++++++ src/discord/monitor.ts | 55 ++++++++++-- 2 files changed, 155 insertions(+), 7 deletions(-) diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts index 55317fb28..e85e8c4f0 100644 --- a/src/discord/monitor.tool-result.test.ts +++ b/src/discord/monitor.tool-result.test.ts @@ -1,6 +1,7 @@ import type { Client } from "@buape/carbon"; import { ChannelType, MessageType } from "@buape/carbon"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Routes } from "discord-api-types/v10"; const sendMock = vi.fn(); const reactMock = vi.fn(); @@ -381,6 +382,112 @@ describe("discord tool result dispatch", () => { expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general"); }); + it("treats forum threads as distinct sessions without channel payloads", async () => { + const { createDiscordMessageHandler } = await import("./monitor.js"); + let capturedCtx: + | { + SessionKey?: string; + ParentSessionKey?: string; + ThreadStarterBody?: string; + ThreadLabel?: string; + } + | undefined; + dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => { + capturedCtx = ctx; + dispatcher.sendFinalReply({ text: "hi" }); + return { queuedFinal: true, counts: { final: 1 } }; + }); + + const cfg = { + agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" }, + session: { store: "/tmp/clawdbot-sessions.json" }, + discord: { + dm: { enabled: true, policy: "open" }, + guilds: { "*": { requireMention: false } }, + }, + routing: { allowFrom: [] }, + } as ReturnType; + + const handler = createDiscordMessageHandler({ + cfg, + discordConfig: cfg.discord, + accountId: "default", + token: "token", + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: "bot-id", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2000, + replyToMode: "off", + dmEnabled: true, + groupDmEnabled: false, + guildEntries: { "*": { requireMention: false } }, + }); + + const fetchChannel = vi + .fn() + .mockResolvedValueOnce({ + type: ChannelType.PublicThread, + name: "topic-1", + parentId: "forum-1", + }) + .mockResolvedValueOnce({ + type: ChannelType.GuildForum, + name: "support", + }); + const restGet = vi.fn().mockResolvedValue({ + content: "starter message", + author: { id: "u1", username: "Alice", discriminator: "0001" }, + timestamp: new Date().toISOString(), + }); + const client = { + fetchChannel, + rest: { + get: restGet, + }, + } as unknown as Client; + + await handler( + { + message: { + id: "m6", + content: "thread reply", + channelId: "t1", + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" }, + }, + author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" }, + member: { displayName: "Bob" }, + guild: { id: "g1", name: "Guild" }, + guild_id: "g1", + }, + client, + ); + + expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1"); + expect(capturedCtx?.ParentSessionKey).toBe( + "agent:main:discord:channel:forum-1", + ); + expect(capturedCtx?.ThreadStarterBody).toContain("starter message"); + expect(capturedCtx?.ThreadLabel).toContain("Discord thread #support"); + expect(restGet).toHaveBeenCalledWith( + Routes.channelMessage("t1", "t1"), + ); + }); + it("scopes thread sessions to the routed agent", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 052fd4839..bee9b5e38 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -139,14 +139,22 @@ async function resolveDiscordThreadStarter(params: { channel: DiscordThreadChannel; client: Client; parentId?: string; + parentType?: ChannelType; }): Promise { const cacheKey = params.channel.id; const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey); if (cached) return cached; try { - if (!params.parentId) return null; + const parentType = params.parentType; + const isForumParent = + parentType === ChannelType.GuildForum || + parentType === ChannelType.GuildMedia; + const messageChannelId = isForumParent + ? params.channel.id + : params.parentId; + if (!messageChannelId) return null; const starter = (await params.client.rest.get( - Routes.channelMessage(params.parentId, params.channel.id), + Routes.channelMessage(messageChannelId, params.channel.id), )) as { content?: string | null; embeds?: Array<{ description?: string | null }>; @@ -226,6 +234,14 @@ export type DiscordMessageHandler = ( client: Client, ) => Promise; +function isDiscordThreadType(type: ChannelType | undefined): boolean { + return ( + type === ChannelType.PublicThread || + type === ChannelType.PrivateThread || + type === ChannelType.AnnouncementThread + ); +} + export function resolveDiscordReplyTarget(opts: { replyToMode: ReplyToMode; replyToId?: string; @@ -666,12 +682,32 @@ export function createDiscordMessageHandler(params: { message.channel && "isThread" in message.channel && message.channel.isThread(); + const isThreadByType = + isGuildMessage && isDiscordThreadType(channelInfo?.type); const threadChannel = isThreadChannel ? (message.channel as DiscordThreadChannel) - : null; + : isThreadByType + ? { + id: message.channelId, + name: channelInfo?.name ?? undefined, + parentId: channelInfo?.parentId ?? undefined, + } + : null; const threadParentId = - threadChannel?.parentId ?? threadChannel?.parent?.id ?? undefined; - const threadParentName = threadChannel?.parent?.name; + threadChannel?.parentId ?? + threadChannel?.parent?.id ?? + channelInfo?.parentId ?? + undefined; + let threadParentName = threadChannel?.parent?.name; + let threadParentType: ChannelType | undefined; + if (threadChannel && threadParentId) { + const parentInfo = await resolveDiscordChannelInfo( + client, + threadParentId, + ); + threadParentName = threadParentName ?? parentInfo?.name; + threadParentType = parentInfo?.type; + } const threadName = threadChannel?.name; const configChannelName = threadParentName ?? channelName; const configChannelSlug = configChannelName @@ -935,6 +971,7 @@ export function createDiscordMessageHandler(params: { channel: threadChannel, client, parentId: threadParentId, + parentType: threadParentType, }); if (starter?.text) { const starterEnvelope = formatThreadStarterEnvelope({ @@ -1684,13 +1721,17 @@ async function deliverDiscordReply(params: { async function resolveDiscordChannelInfo( client: Client, channelId: string, -): Promise<{ type: ChannelType; name?: string; topic?: string } | null> { +): Promise< + { type: ChannelType; name?: string; topic?: string; parentId?: string } | null +> { try { const channel = await client.fetchChannel(channelId); if (!channel) return null; const name = "name" in channel ? (channel.name ?? undefined) : undefined; const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined; - return { type: channel.type, name, topic }; + const parentId = + "parentId" in channel ? (channel.parentId ?? undefined) : undefined; + return { type: channel.type, name, topic, parentId }; } catch (err) { logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`); return null;