Threads: add Slack/Discord thread sessions
This commit is contained in:
committed by
Peter Steinberger
parent
422477499c
commit
7e5cef29a0
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user