Threads: add Slack/Discord thread sessions

This commit is contained in:
Shadow
2026-01-07 09:02:20 -06:00
committed by Peter Steinberger
parent 422477499c
commit 7e5cef29a0
17 changed files with 670 additions and 27 deletions

View File

@@ -167,4 +167,107 @@ describe("discord tool result dispatch", () => {
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledTimes(1);
}, 10000);
});
it("forks thread sessions and injects starter context", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const { resolveSessionKey } = await import("../config/sessions.js");
vi.mocked(resolveSessionKey).mockReturnValue("discord:parent:p1");
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" },
messages: { responsePrefix: "PFX" },
discord: {
dm: { enabled: true, policy: "open" },
guilds: { "*": { requireMention: false } },
},
routing: { allowFrom: [] },
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
cfg,
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 threadChannel = {
type: ChannelType.GuildText,
name: "thread-name",
parentId: "p1",
parent: { id: "p1", name: "general" },
isThread: () => true,
fetchStarterMessage: async () => ({
content: "starter message",
author: { tag: "Alice#1", username: "Alice" },
createdTimestamp: Date.now(),
}),
};
const client = {
fetchChannel: vi.fn().mockResolvedValue({
type: ChannelType.GuildText,
name: "thread-name",
}),
} as unknown as Client;
await handler(
{
message: {
id: "m4",
content: "thread reply",
channelId: "t1",
channel: threadChannel,
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("discord:thread:t1");
expect(capturedCtx?.ParentSessionKey).toBe("discord:parent:p1");
expect(capturedCtx?.ThreadStarterBody).toContain("starter message");
expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general");
});
});

View File

@@ -11,6 +11,11 @@ import {
MessageReactionRemoveListener,
MessageType,
type RequestClient,
type PartialMessage,
type PartialMessageReaction,
Partials,
type ThreadChannel,
type PartialUser,
type User,
} from "@buape/carbon";
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
@@ -81,6 +86,44 @@ type DiscordHistoryEntry = {
};
type DiscordReactionEvent = Parameters<MessageReactionAddListener["handle"]>[0];
type DiscordThreadStarter = {
text: string;
author: string;
timestamp?: number;
};
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>();
async function resolveDiscordThreadStarter(
channel: ThreadChannel,
): Promise<DiscordThreadStarter | null> {
const cacheKey = channel.id;
const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey);
if (cached) return cached;
try {
const starter = await channel.fetchStarterMessage();
if (!starter) return null;
const text =
starter.content?.trim() ??
starter.embeds?.[0]?.description?.trim() ??
"";
if (!text) return null;
const author =
starter.member?.displayName ??
starter.author?.tag ??
starter.author?.username ??
"Unknown";
const payload: DiscordThreadStarter = {
text,
author,
timestamp: starter.createdTimestamp ?? undefined,
};
DISCORD_THREAD_STARTER_CACHE.set(cacheKey, payload);
return payload;
} catch {
return null;
}
}
export type DiscordAllowList = {
allowAll: boolean;
@@ -509,7 +552,30 @@ export function createDiscordMessageHandler(params: {
return;
}
const channelName = channelInfo?.name;
const channelName =
channelInfo?.name ??
((isGuildMessage || isGroupDm) && "name" in message.channel
? message.channel.name
: undefined);
const isThreadChannel =
isGuildMessage &&
"isThread" in message.channel &&
message.channel.isThread();
const threadChannel = isThreadChannel
? (message.channel as ThreadChannel)
: null;
const threadParentId =
threadChannel?.parentId ?? threadChannel?.parent?.id ?? undefined;
const threadParentName = threadChannel?.parent?.name;
const threadName = threadChannel?.name;
const configChannelName = threadParentName ?? channelName;
const configChannelSlug = configChannelName
? normalizeDiscordSlug(configChannelName)
: "";
const displayChannelName = threadName ?? channelName;
const displayChannelSlug = displayChannelName
? normalizeDiscordSlug(displayChannelName)
: "";
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const guildSlug =
guildInfo?.slug ||
@@ -527,9 +593,9 @@ export function createDiscordMessageHandler(params: {
const channelConfig = isGuildMessage
? resolveDiscordChannelConfig({
guildInfo,
channelId: message.channelId,
channelName,
channelSlug,
channelId: threadParentId ?? message.channelId,
channelName: configChannelName,
channelSlug: configChannelSlug,
})
: null;
if (isGuildMessage && channelConfig?.enabled === false) {
@@ -544,8 +610,8 @@ export function createDiscordMessageHandler(params: {
resolveGroupDmAllow({
channels: groupDmChannels,
channelId: message.channelId,
channelName,
channelSlug,
channelName: displayChannelName,
channelSlug: displayChannelSlug,
});
if (isGroupDm && !groupDmAllowed) return;
@@ -715,7 +781,9 @@ export function createDiscordMessageHandler(params: {
channelId: message.channelId,
});
const groupRoom =
isGuildMessage && channelSlug ? `#${channelSlug}` : undefined;
isGuildMessage && displayChannelSlug
? `#${displayChannelSlug}`
: undefined;
const groupSubject = isDirectMessage ? undefined : groupRoom;
const channelDescription = channelInfo?.topic?.trim();
const systemPromptParts = [
@@ -761,6 +829,41 @@ export function createDiscordMessageHandler(params: {
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
}
let threadStarterBody: string | undefined;
let threadLabel: string | undefined;
let threadSessionKey: string | undefined;
let parentSessionKey: string | undefined;
if (threadChannel) {
const starter = await resolveDiscordThreadStarter(threadChannel);
if (starter?.text) {
const starterEnvelope = formatAgentEnvelope({
surface: "Discord",
from: starter.author,
timestamp: starter.timestamp,
body: starter.text,
});
threadStarterBody = starterEnvelope;
}
const parentName = threadParentName ?? "parent";
threadLabel = threadName
? `Discord thread #${normalizeDiscordSlug(parentName)} ${threadName}`
: `Discord thread #${normalizeDiscordSlug(parentName)}`;
threadSessionKey = `discord:thread:${message.channelId}`;
const sessionCfg = cfg.session;
const sessionScope = sessionCfg?.scope ?? "per-sender";
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
if (threadParentId) {
parentSessionKey = resolveSessionKey(
sessionScope,
{
From: `group:${threadParentId}`,
ChatType: "group",
Surface: "discord",
},
mainKey,
);
}
}
const mediaPayload = buildDiscordMediaPayload(mediaList);
const discordTo = `channel:${message.channelId}`;
const ctxPayload = {
@@ -769,7 +872,7 @@ export function createDiscordMessageHandler(params: {
? `discord:${author.id}`
: `group:${message.channelId}`,
To: discordTo,
SessionKey: route.sessionKey,
SessionKey: threadSessionKey ?? route.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "group",
SenderName:
@@ -787,6 +890,9 @@ export function createDiscordMessageHandler(params: {
Surface: "discord" as const,
WasMentioned: wasMentioned,
MessageSid: message.id,
ParentSessionKey: parentSessionKey,
ThreadStarterBody: threadStarterBody,
ThreadLabel: threadLabel,
Timestamp: resolveTimestampMs(message.timestamp),
...mediaPayload,
CommandAuthorized: commandAuthorized,