fix: cache discord channel lookups for thread starters (#585) (thanks @thewilloftheshadow)

This commit is contained in:
Peter Steinberger
2026-01-09 17:35:16 +01:00
parent 6ef9fc64d7
commit abfd5719d6
3 changed files with 119 additions and 10 deletions

View File

@@ -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

View File

@@ -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<typeof import("../config/config.js").loadConfig>;
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 () => {

View File

@@ -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<string, DiscordThreadStarter>();
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<DiscordChannelInfo | null> {
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;
}
}