From 7a836c9ff0451a79931313d8f2ddae400c40130a Mon Sep 17 00:00:00 2001 From: Ruby Date: Sat, 10 Jan 2026 10:40:25 -0600 Subject: [PATCH] Discord: include forwarded message snapshots --- src/discord/monitor.tool-result.test.ts | 90 +++++++++++++++++++ src/discord/monitor.ts | 115 ++++++++++++++++++++++-- 2 files changed, 196 insertions(+), 9 deletions(-) diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts index 50b5600cb..5963a4d2d 100644 --- a/src/discord/monitor.tool-result.test.ts +++ b/src/discord/monitor.tool-result.test.ts @@ -193,6 +193,96 @@ describe("discord tool result dispatch", () => { expect(fetchChannel).toHaveBeenCalledTimes(1); }); + it("includes forwarded message snapshots in body", async () => { + const { createDiscordMessageHandler } = await import("./monitor.js"); + let capturedBody = ""; + dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => { + capturedBody = ctx.Body ?? ""; + dispatcher.sendFinalReply({ text: "ok" }); + return { queuedFinal: true, counts: { final: 1 } }; + }); + + 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 client = { + fetchChannel: vi.fn().mockResolvedValue({ + type: ChannelType.DM, + name: "dm", + }), + } as unknown as Client; + + await handler( + { + message: { + id: "m-forward-1", + content: "", + channelId: "c-forward-1", + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: { id: "u1", bot: false, username: "Ada" }, + rawData: { + message_snapshots: [ + { + message: { + content: "forwarded hello", + embeds: [], + attachments: [], + author: { + id: "u2", + username: "Bob", + discriminator: "0", + }, + }, + }, + ], + }, + }, + author: { id: "u1", bot: false, username: "Ada" }, + guild_id: null, + }, + client, + ); + + expect(capturedBody).toContain("[Forwarded message from @Bob]"); + expect(capturedBody).toContain("forwarded hello"); + }); + it("uses channel id allowlists for non-thread channels with categories", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); let capturedCtx: { SessionKey?: string } | undefined; diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 9a99dd54f..84b8e2beb 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -99,6 +99,25 @@ type DiscordMediaInfo = { placeholder: string; }; +type DiscordSnapshotAuthor = { + id?: string | null; + username?: string | null; + discriminator?: string | null; + global_name?: string | null; + name?: string | null; +}; + +type DiscordSnapshotMessage = { + content?: string | null; + embeds?: Array<{ description?: string | null; title?: string | null }> | null; + attachments?: APIAttachment[] | null; + author?: DiscordSnapshotAuthor | null; +}; + +type DiscordMessageSnapshot = { + message?: DiscordSnapshotMessage | null; +}; + type DiscordHistoryEntry = { sender: string; body: string; @@ -706,7 +725,12 @@ export function createDiscordMessageHandler(params: { } } const botId = botUserId; - const baseText = resolveDiscordMessageText(message); + const baseText = resolveDiscordMessageText(message, { + includeForwarded: false, + }); + const messageText = resolveDiscordMessageText(message, { + includeForwarded: true, + }); recordProviderActivity({ provider: "discord", accountId, @@ -732,7 +756,7 @@ export function createDiscordMessageHandler(params: { matchesMentionPatterns(baseText, mentionRegexes)); if (shouldLogVerbose()) { logVerbose( - `discord: inbound id=${message.id} guild=${message.guild?.id ?? "dm"} channel=${message.channelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${baseText ? "yes" : "no"}`, + `discord: inbound id=${message.id} guild=${message.guild?.id ?? "dm"} channel=${message.channelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${messageText ? "yes" : "no"}`, ); } @@ -860,7 +884,9 @@ export function createDiscordMessageHandler(params: { return; } - const textForHistory = resolveDiscordMessageText(message); + const textForHistory = resolveDiscordMessageText(message, { + includeForwarded: true, + }); if (isGuildMessage && historyLimit > 0 && textForHistory) { const history = guildHistories.get(message.channelId) ?? []; history.push({ @@ -957,7 +983,7 @@ export function createDiscordMessageHandler(params: { } const mediaList = await resolveMediaList(message, mediaMaxBytes); - const text = baseText; + const text = messageText; if (!text) { logVerbose(`discord: drop message ${message.id} (empty content)`); return; @@ -1938,17 +1964,86 @@ function buildDiscordAttachmentPlaceholder( function resolveDiscordMessageText( message: Message, - fallbackText?: string, + options?: { fallbackText?: string; includeForwarded?: boolean }, ): string { - return ( + const baseText = message.content?.trim() || buildDiscordAttachmentPlaceholder(message.attachments) || message.embeds?.[0]?.description || - fallbackText?.trim() || - "" + options?.fallbackText?.trim() || + ""; + if (!options?.includeForwarded) return baseText; + const forwardedText = resolveDiscordForwardedMessagesText(message); + if (!forwardedText) return baseText; + if (!baseText) return forwardedText; + return `${baseText}\n${forwardedText}`; +} + +function resolveDiscordForwardedMessagesText(message: Message): string { + const snapshots = resolveDiscordMessageSnapshots(message); + if (snapshots.length === 0) return ""; + const forwardedBlocks = snapshots + .map((snapshot) => { + const snapshotMessage = snapshot.message; + if (!snapshotMessage) return null; + const text = resolveDiscordSnapshotMessageText(snapshotMessage); + if (!text) return null; + const authorLabel = formatDiscordSnapshotAuthor(snapshotMessage.author); + const heading = authorLabel + ? `[Forwarded message from ${authorLabel}]` + : "[Forwarded message]"; + return `${heading}\n${text}`; + }) + .filter((entry): entry is string => Boolean(entry)); + if (forwardedBlocks.length === 0) return ""; + return forwardedBlocks.join("\n\n"); +} + +function resolveDiscordMessageSnapshots( + message: Message, +): DiscordMessageSnapshot[] { + const rawData = (message as { rawData?: { message_snapshots?: unknown } }) + .rawData; + const snapshots = + rawData?.message_snapshots ?? + (message as { message_snapshots?: unknown }).message_snapshots ?? + (message as { messageSnapshots?: unknown }).messageSnapshots; + if (!Array.isArray(snapshots)) return []; + return snapshots.filter( + (entry): entry is DiscordMessageSnapshot => + Boolean(entry) && typeof entry === "object", ); } +function resolveDiscordSnapshotMessageText( + snapshot: DiscordSnapshotMessage, +): string { + const content = snapshot.content?.trim() ?? ""; + const attachmentText = buildDiscordAttachmentPlaceholder( + snapshot.attachments ?? undefined, + ); + const embed = snapshot.embeds?.[0]; + const embedText = embed?.description?.trim() || embed?.title?.trim() || ""; + return content || attachmentText || embedText || ""; +} + +function formatDiscordSnapshotAuthor( + author: DiscordSnapshotAuthor | null | undefined, +): string | undefined { + if (!author) return undefined; + const globalName = author.global_name ?? undefined; + const username = author.username ?? undefined; + const name = author.name ?? undefined; + const discriminator = author.discriminator ?? undefined; + const base = globalName || username || name; + if (username && discriminator && discriminator !== "0") { + return `@${username}#${discriminator}`; + } + if (base) return `@${base}`; + if (author.id) return `@${author.id}`; + return undefined; +} + export function buildDiscordMediaPayload( mediaList: Array<{ path: string; contentType?: string }>, ): { @@ -1977,7 +2072,9 @@ export function buildDiscordMediaPayload( function resolveReplyContext(message: Message): string | null { const referenced = message.referencedMessage; if (!referenced?.author) return null; - const referencedText = resolveDiscordMessageText(referenced); + const referencedText = resolveDiscordMessageText(referenced, { + includeForwarded: true, + }); if (!referencedText) return null; const fromLabel = referenced.author ? buildDirectLabel(referenced.author)