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; } }