Merge pull request #585 from clawdbot/shadow/forum
Discord: fix forum thread starters
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
|
- 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
|
- 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
|
- 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: accept /models as an alias for /model.
|
||||||
- Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp
|
- 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
|
- 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
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Client } from "@buape/carbon";
|
import type { Client } from "@buape/carbon";
|
||||||
import { ChannelType, MessageType } from "@buape/carbon";
|
import { ChannelType, MessageType } from "@buape/carbon";
|
||||||
|
import { Routes } from "discord-api-types/v10";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const sendMock = vi.fn();
|
const sendMock = vi.fn();
|
||||||
@@ -119,6 +120,79 @@ describe("discord tool result dispatch", () => {
|
|||||||
expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /);
|
expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /);
|
||||||
}, 10000);
|
}, 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 () => {
|
it("replies with pairing code and sender id when dmPolicy is pairing", async () => {
|
||||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||||
const cfg = {
|
const cfg = {
|
||||||
@@ -381,6 +455,110 @@ describe("discord tool result dispatch", () => {
|
|||||||
expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general");
|
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<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,
|
||||||
|
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 () => {
|
it("scopes thread sessions to the routed agent", async () => {
|
||||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,20 @@ type DiscordThreadStarter = {
|
|||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DiscordChannelInfo = {
|
||||||
|
type: ChannelType;
|
||||||
|
name?: string;
|
||||||
|
topic?: string;
|
||||||
|
parentId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>();
|
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;
|
const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 1000;
|
||||||
|
|
||||||
function logSlowDiscordListener(params: {
|
function logSlowDiscordListener(params: {
|
||||||
@@ -139,14 +152,22 @@ async function resolveDiscordThreadStarter(params: {
|
|||||||
channel: DiscordThreadChannel;
|
channel: DiscordThreadChannel;
|
||||||
client: Client;
|
client: Client;
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
|
parentType?: ChannelType;
|
||||||
}): Promise<DiscordThreadStarter | null> {
|
}): Promise<DiscordThreadStarter | null> {
|
||||||
const cacheKey = params.channel.id;
|
const cacheKey = params.channel.id;
|
||||||
const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey);
|
const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
try {
|
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(
|
const starter = (await params.client.rest.get(
|
||||||
Routes.channelMessage(params.parentId, params.channel.id),
|
Routes.channelMessage(messageChannelId, params.channel.id),
|
||||||
)) as {
|
)) as {
|
||||||
content?: string | null;
|
content?: string | null;
|
||||||
embeds?: Array<{ description?: string | null }>;
|
embeds?: Array<{ description?: string | null }>;
|
||||||
@@ -226,6 +247,14 @@ export type DiscordMessageHandler = (
|
|||||||
client: Client,
|
client: Client,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
|
function isDiscordThreadType(type: ChannelType | undefined): boolean {
|
||||||
|
return (
|
||||||
|
type === ChannelType.PublicThread ||
|
||||||
|
type === ChannelType.PrivateThread ||
|
||||||
|
type === ChannelType.AnnouncementThread
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveDiscordReplyTarget(opts: {
|
export function resolveDiscordReplyTarget(opts: {
|
||||||
replyToMode: ReplyToMode;
|
replyToMode: ReplyToMode;
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
@@ -666,12 +695,33 @@ export function createDiscordMessageHandler(params: {
|
|||||||
message.channel &&
|
message.channel &&
|
||||||
"isThread" in message.channel &&
|
"isThread" in message.channel &&
|
||||||
message.channel.isThread();
|
message.channel.isThread();
|
||||||
const threadChannel = isThreadChannel
|
const isThreadByType =
|
||||||
|
isGuildMessage && isDiscordThreadType(channelInfo?.type);
|
||||||
|
const threadChannel: DiscordThreadChannel | null = isThreadChannel
|
||||||
? (message.channel as DiscordThreadChannel)
|
? (message.channel as DiscordThreadChannel)
|
||||||
: null;
|
: isThreadByType
|
||||||
|
? {
|
||||||
|
id: message.channelId,
|
||||||
|
name: channelInfo?.name ?? undefined,
|
||||||
|
parentId: channelInfo?.parentId ?? undefined,
|
||||||
|
parent: undefined,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
const threadParentId =
|
const threadParentId =
|
||||||
threadChannel?.parentId ?? threadChannel?.parent?.id ?? undefined;
|
threadChannel?.parentId ??
|
||||||
const threadParentName = threadChannel?.parent?.name;
|
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 threadName = threadChannel?.name;
|
||||||
const configChannelName = threadParentName ?? channelName;
|
const configChannelName = threadParentName ?? channelName;
|
||||||
const configChannelSlug = configChannelName
|
const configChannelSlug = configChannelName
|
||||||
@@ -935,6 +985,7 @@ export function createDiscordMessageHandler(params: {
|
|||||||
channel: threadChannel,
|
channel: threadChannel,
|
||||||
client,
|
client,
|
||||||
parentId: threadParentId,
|
parentId: threadParentId,
|
||||||
|
parentType: threadParentType,
|
||||||
});
|
});
|
||||||
if (starter?.text) {
|
if (starter?.text) {
|
||||||
const starterEnvelope = formatThreadStarterEnvelope({
|
const starterEnvelope = formatThreadStarterEnvelope({
|
||||||
@@ -1684,15 +1735,42 @@ async function deliverDiscordReply(params: {
|
|||||||
async function resolveDiscordChannelInfo(
|
async function resolveDiscordChannelInfo(
|
||||||
client: Client,
|
client: Client,
|
||||||
channelId: string,
|
channelId: string,
|
||||||
): Promise<{ type: ChannelType; name?: string; topic?: 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 {
|
try {
|
||||||
const channel = await client.fetchChannel(channelId);
|
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 name = "name" in channel ? (channel.name ?? undefined) : undefined;
|
||||||
const topic = "topic" in channel ? (channel.topic ?? 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;
|
||||||
|
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) {
|
} catch (err) {
|
||||||
logVerbose(`discord: failed to fetch channel ${channelId}: ${String(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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user