From 6ef9fc64d7cc97df809fcb4e4a0e272365431864 Mon Sep 17 00:00:00 2001 From: Shadow Date: Fri, 9 Jan 2026 09:52:04 -0600 Subject: [PATCH 1/2] 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; From abfd5719d6e61d7127cf86a0be9500da119440e4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 17:35:16 +0100 Subject: [PATCH 2/2] fix: cache discord channel lookups for thread starters (#585) (thanks @thewilloftheshadow) --- CHANGELOG.md | 1 + src/discord/monitor.tool-result.test.ts | 79 +++++++++++++++++++++++-- src/discord/monitor.ts | 49 +++++++++++++-- 3 files changed, 119 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 569a3f1ee..449555623 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott - Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc - Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro +- Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow - Commands: accept /models as an alias for /model. - Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp - Models/Auth: show per-agent auth candidates in `/model status`, and add `clawdbot models auth order {get,set,clear}` (per-agent auth rotation overrides). — thanks @steipete diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts index e85e8c4f0..952bfda15 100644 --- a/src/discord/monitor.tool-result.test.ts +++ b/src/discord/monitor.tool-result.test.ts @@ -1,7 +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"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const sendMock = vi.fn(); const reactMock = vi.fn(); @@ -120,6 +120,79 @@ describe("discord tool result dispatch", () => { expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /); }, 10000); + it("caches channel info lookups between messages", async () => { + const { createDiscordMessageHandler } = await import("./monitor.js"); + const cfg = { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: "/tmp/clawd", + }, + }, + session: { store: "/tmp/clawdbot-sessions.json" }, + discord: { dm: { enabled: true, policy: "open" } }, + } 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, + }); + + const fetchChannel = vi.fn().mockResolvedValue({ + type: ChannelType.DM, + name: "dm", + }); + const client = { fetchChannel } as unknown as Client; + const baseMessage = { + content: "hello", + channelId: "cache-channel-1", + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: { id: "u-cache", bot: false, username: "Ada" }, + }; + + await handler( + { + message: { ...baseMessage, id: "m-cache-1" }, + author: baseMessage.author, + guild_id: null, + }, + client, + ); + await handler( + { + message: { ...baseMessage, id: "m-cache-2" }, + author: baseMessage.author, + guild_id: null, + }, + client, + ); + + expect(fetchChannel).toHaveBeenCalledTimes(1); + }); + it("replies with pairing code and sender id when dmPolicy is pairing", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); const cfg = { @@ -483,9 +556,7 @@ describe("discord tool result dispatch", () => { ); expect(capturedCtx?.ThreadStarterBody).toContain("starter message"); expect(capturedCtx?.ThreadLabel).toContain("Discord thread #support"); - expect(restGet).toHaveBeenCalledWith( - Routes.channelMessage("t1", "t1"), - ); + expect(restGet).toHaveBeenCalledWith(Routes.channelMessage("t1", "t1")); }); it("scopes thread sessions to the routed agent", async () => { diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index bee9b5e38..59ef8e910 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -113,7 +113,20 @@ type DiscordThreadStarter = { timestamp?: number; }; +type DiscordChannelInfo = { + type: ChannelType; + name?: string; + topic?: string; + parentId?: string; +}; + const DISCORD_THREAD_STARTER_CACHE = new Map(); +const DISCORD_CHANNEL_INFO_CACHE_TTL_MS = 5 * 60 * 1000; +const DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS = 30 * 1000; +const DISCORD_CHANNEL_INFO_CACHE = new Map< + string, + { value: DiscordChannelInfo | null; expiresAt: number } +>(); const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 1000; function logSlowDiscordListener(params: { @@ -684,13 +697,14 @@ export function createDiscordMessageHandler(params: { message.channel.isThread(); const isThreadByType = isGuildMessage && isDiscordThreadType(channelInfo?.type); - const threadChannel = isThreadChannel + const threadChannel: DiscordThreadChannel | null = isThreadChannel ? (message.channel as DiscordThreadChannel) : isThreadByType ? { id: message.channelId, name: channelInfo?.name ?? undefined, parentId: channelInfo?.parentId ?? undefined, + parent: undefined, } : null; const threadParentId = @@ -1721,19 +1735,42 @@ async function deliverDiscordReply(params: { async function resolveDiscordChannelInfo( client: Client, channelId: string, -): Promise< - { type: ChannelType; name?: string; topic?: string; parentId?: string } | null -> { +): Promise { + const cached = DISCORD_CHANNEL_INFO_CACHE.get(channelId); + if (cached) { + if (cached.expiresAt > Date.now()) return cached.value; + DISCORD_CHANNEL_INFO_CACHE.delete(channelId); + } try { const channel = await client.fetchChannel(channelId); - if (!channel) return null; + if (!channel) { + DISCORD_CHANNEL_INFO_CACHE.set(channelId, { + value: null, + expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, + }); + return null; + } const name = "name" in channel ? (channel.name ?? undefined) : undefined; const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined; const parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined; - return { type: channel.type, name, topic, parentId }; + const payload: DiscordChannelInfo = { + type: channel.type, + name, + topic, + parentId, + }; + DISCORD_CHANNEL_INFO_CACHE.set(channelId, { + value: payload, + expiresAt: Date.now() + DISCORD_CHANNEL_INFO_CACHE_TTL_MS, + }); + return payload; } catch (err) { logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`); + DISCORD_CHANNEL_INFO_CACHE.set(channelId, { + value: null, + expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, + }); return null; } }