From 1eec2aee4f490de400f921b966fc4a0494261d61 Mon Sep 17 00:00:00 2001 From: Shadow Date: Fri, 20 Feb 2026 16:40:27 -0600 Subject: [PATCH] Discord: ingest inbound stickers --- CHANGELOG.md | 1 + src/discord/monitor/message-handler.ts | 9 +- src/discord/monitor/message-utils.test.ts | 104 +++++++++++++ src/discord/monitor/message-utils.ts | 180 +++++++++++++++++++++- 4 files changed, 285 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c652cf7dfcc..e6c6267a61f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai - Discord/Streaming: apply `replyToMode: first` only to the first Discord chunk so block-streamed replies do not spam mention pings. (#20726) Thanks @thewilloftheshadow for the report. - Discord/Gateway: handle close code 4014 (missing privileged gateway intents) without crashing the gateway. Thanks @thewilloftheshadow. +- Discord: ingest inbound stickers as media so sticker-only messages and forwarded stickers are visible to agents. Thanks @thewilloftheshadow. - Security/Net: strip sensitive headers (`Authorization`, `Proxy-Authorization`, `Cookie`, `Cookie2`) on cross-origin redirects in `fetchWithSsrFGuard` to prevent credential forwarding across origin boundaries. (#20313) Thanks @afurm. - Security/Systemd: reject CR/LF in systemd unit environment values and fix argument escaping so generated units cannot be injected with extra directives. Thanks @thewilloftheshadow. - Skills/Security: sanitize skill env overrides to block unsafe runtime injection variables and only allow sensitive keys when declared in skill metadata, with warnings for suspicious values. Thanks @thewilloftheshadow. diff --git a/src/discord/monitor/message-handler.ts b/src/discord/monitor/message-handler.ts index a22b7fd3447..aceae950d70 100644 --- a/src/discord/monitor/message-handler.ts +++ b/src/discord/monitor/message-handler.ts @@ -9,7 +9,11 @@ import type { DiscordMessageEvent, DiscordMessageHandler } from "./listeners.js" import { preflightDiscordMessage } from "./message-handler.preflight.js"; import type { DiscordMessagePreflightParams } from "./message-handler.preflight.types.js"; import { processDiscordMessage } from "./message-handler.process.js"; -import { resolveDiscordMessageChannelId, resolveDiscordMessageText } from "./message-utils.js"; +import { + hasDiscordMessageStickers, + resolveDiscordMessageChannelId, + resolveDiscordMessageText, +} from "./message-utils.js"; type DiscordMessageHandlerParams = Omit< DiscordMessagePreflightParams, @@ -48,6 +52,9 @@ export function createDiscordMessageHandler( if (message.attachments && message.attachments.length > 0) { return false; } + if (hasDiscordMessageStickers(message)) { + return false; + } const baseText = resolveDiscordMessageText(message, { includeForwarded: false }); if (!baseText.trim()) { return false; diff --git a/src/discord/monitor/message-utils.test.ts b/src/discord/monitor/message-utils.test.ts index 69ccc14c572..d04edcaf629 100644 --- a/src/discord/monitor/message-utils.test.ts +++ b/src/discord/monitor/message-utils.test.ts @@ -1,4 +1,5 @@ import { ChannelType, type Client, type Message } from "@buape/carbon"; +import { StickerFormatType } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; const fetchRemoteMedia = vi.fn(); @@ -22,6 +23,7 @@ const { resolveDiscordMessageChannelId, resolveDiscordMessageText, resolveForwardedMediaList, + resolveMediaList, } = await import("./message-utils.js"); function asMessage(payload: Record): Message { @@ -102,6 +104,46 @@ describe("resolveForwardedMediaList", () => { ]); }); + it("downloads forwarded stickers", async () => { + const sticker = { + id: "sticker-1", + name: "wave", + format_type: StickerFormatType.PNG, + }; + fetchRemoteMedia.mockResolvedValueOnce({ + buffer: Buffer.from("sticker"), + contentType: "image/png", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/sticker.png", + contentType: "image/png", + }); + + const result = await resolveForwardedMediaList( + asMessage({ + rawData: { + message_snapshots: [{ message: { sticker_items: [sticker] } }], + }, + }), + 512, + ); + + expect(fetchRemoteMedia).toHaveBeenCalledTimes(1); + expect(fetchRemoteMedia).toHaveBeenCalledWith({ + url: "https://media.discordapp.net/stickers/sticker-1.png", + filePathHint: "wave.png", + }); + expect(saveMediaBuffer).toHaveBeenCalledTimes(1); + expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512); + expect(result).toEqual([ + { + path: "/tmp/sticker.png", + contentType: "image/png", + placeholder: "", + }, + ]); + }); + it("returns empty when no snapshots are present", async () => { const result = await resolveForwardedMediaList(asMessage({}), 512); @@ -124,6 +166,51 @@ describe("resolveForwardedMediaList", () => { }); }); +describe("resolveMediaList", () => { + beforeEach(() => { + fetchRemoteMedia.mockReset(); + saveMediaBuffer.mockReset(); + }); + + it("downloads stickers", async () => { + const sticker = { + id: "sticker-2", + name: "hello", + format_type: StickerFormatType.PNG, + }; + fetchRemoteMedia.mockResolvedValueOnce({ + buffer: Buffer.from("sticker"), + contentType: "image/png", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/sticker-2.png", + contentType: "image/png", + }); + + const result = await resolveMediaList( + asMessage({ + stickers: [sticker], + }), + 512, + ); + + expect(fetchRemoteMedia).toHaveBeenCalledTimes(1); + expect(fetchRemoteMedia).toHaveBeenCalledWith({ + url: "https://media.discordapp.net/stickers/sticker-2.png", + filePathHint: "hello.png", + }); + expect(saveMediaBuffer).toHaveBeenCalledTimes(1); + expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512); + expect(result).toEqual([ + { + path: "/tmp/sticker-2.png", + contentType: "image/png", + placeholder: "", + }, + ]); + }); +}); + describe("resolveDiscordMessageText", () => { it("includes forwarded message snapshots in body text", () => { const text = resolveDiscordMessageText( @@ -152,6 +239,23 @@ describe("resolveDiscordMessageText", () => { expect(text).toContain("[Forwarded message from @Bob]"); expect(text).toContain("forwarded hello"); }); + + it("uses sticker placeholders when content is empty", () => { + const text = resolveDiscordMessageText( + asMessage({ + content: "", + stickers: [ + { + id: "sticker-3", + name: "party", + format_type: StickerFormatType.PNG, + }, + ], + }), + ); + + expect(text).toBe(" (1 sticker)"); + }); }); describe("resolveDiscordChannelInfo", () => { diff --git a/src/discord/monitor/message-utils.ts b/src/discord/monitor/message-utils.ts index f0abf545def..532e04696ef 100644 --- a/src/discord/monitor/message-utils.ts +++ b/src/discord/monitor/message-utils.ts @@ -1,5 +1,5 @@ import type { ChannelType, Client, Message } from "@buape/carbon"; -import type { APIAttachment } from "discord-api-types/v10"; +import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10"; import { logVerbose } from "../../globals.js"; import { fetchRemoteMedia } from "../../media/fetch.js"; import { saveMediaBuffer } from "../../media/store.js"; @@ -35,6 +35,8 @@ type DiscordSnapshotMessage = { content?: string | null; embeds?: Array<{ description?: string | null; title?: string | null }> | null; attachments?: APIAttachment[] | null; + stickers?: APIStickerItem[] | null; + sticker_items?: APIStickerItem[] | null; author?: DiscordSnapshotAuthor | null; }; @@ -48,6 +50,7 @@ const DISCORD_CHANNEL_INFO_CACHE = new Map< string, { value: DiscordChannelInfo | null; expiresAt: number } >(); +const DISCORD_STICKER_ASSET_BASE_URL = "https://media.discordapp.net/stickers"; export function __resetDiscordChannelInfoCacheForTest() { DISCORD_CHANNEL_INFO_CACHE.clear(); @@ -122,21 +125,55 @@ export async function resolveDiscordChannelInfo( } } +function normalizeStickerItems(value: unknown): APIStickerItem[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter( + (entry): entry is APIStickerItem => + Boolean(entry) && + typeof entry === "object" && + typeof (entry as { id?: unknown }).id === "string" && + typeof (entry as { name?: unknown }).name === "string", + ); +} + +export function resolveDiscordMessageStickers(message: Message): APIStickerItem[] { + const stickers = (message as { stickers?: unknown }).stickers; + const normalized = normalizeStickerItems(stickers); + if (normalized.length > 0) { + return normalized; + } + const rawData = (message as { rawData?: { sticker_items?: unknown; stickers?: unknown } }) + .rawData; + return normalizeStickerItems(rawData?.sticker_items ?? rawData?.stickers); +} + +function resolveDiscordSnapshotStickers(snapshot: DiscordSnapshotMessage): APIStickerItem[] { + return normalizeStickerItems(snapshot.stickers ?? snapshot.sticker_items); +} + +export function hasDiscordMessageStickers(message: Message): boolean { + return resolveDiscordMessageStickers(message).length > 0; +} + export async function resolveMediaList( message: Message, maxBytes: number, ): Promise { - const attachments = message.attachments ?? []; - if (attachments.length === 0) { - return []; - } const out: DiscordMediaInfo[] = []; await appendResolvedMediaFromAttachments({ - attachments, + attachments: message.attachments ?? [], maxBytes, out, errorPrefix: "discord: failed to download attachment", }); + await appendResolvedMediaFromStickers({ + stickers: resolveDiscordMessageStickers(message), + maxBytes, + out, + errorPrefix: "discord: failed to download sticker", + }); return out; } @@ -156,6 +193,12 @@ export async function resolveForwardedMediaList( out, errorPrefix: "discord: failed to download forwarded attachment", }); + await appendResolvedMediaFromStickers({ + stickers: snapshot.message ? resolveDiscordSnapshotStickers(snapshot.message) : [], + maxBytes, + out, + errorPrefix: "discord: failed to download forwarded sticker", + }); } return out; } @@ -194,6 +237,100 @@ async function appendResolvedMediaFromAttachments(params: { } } +type DiscordStickerAssetCandidate = { + url: string; + fileName: string; +}; + +function resolveStickerAssetCandidates(sticker: APIStickerItem): DiscordStickerAssetCandidate[] { + const baseName = sticker.name?.trim() || `sticker-${sticker.id}`; + switch (sticker.format_type) { + case StickerFormatType.GIF: + return [ + { + url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.gif`, + fileName: `${baseName}.gif`, + }, + ]; + case StickerFormatType.Lottie: + return [ + { + url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.png?size=160`, + fileName: `${baseName}.png`, + }, + { + url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.json`, + fileName: `${baseName}.json`, + }, + ]; + case StickerFormatType.APNG: + case StickerFormatType.PNG: + default: + return [ + { + url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.png`, + fileName: `${baseName}.png`, + }, + ]; + } +} + +function formatStickerError(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + if (typeof err === "string") { + return err; + } + try { + return JSON.stringify(err) ?? "unknown error"; + } catch { + return "unknown error"; + } +} + +async function appendResolvedMediaFromStickers(params: { + stickers?: APIStickerItem[] | null; + maxBytes: number; + out: DiscordMediaInfo[]; + errorPrefix: string; +}) { + const stickers = params.stickers; + if (!stickers || stickers.length === 0) { + return; + } + for (const sticker of stickers) { + const candidates = resolveStickerAssetCandidates(sticker); + let lastError: unknown; + for (const candidate of candidates) { + try { + const fetched = await fetchRemoteMedia({ + url: candidate.url, + filePathHint: candidate.fileName, + }); + const saved = await saveMediaBuffer( + fetched.buffer, + fetched.contentType, + "inbound", + params.maxBytes, + ); + params.out.push({ + path: saved.path, + contentType: saved.contentType, + placeholder: "", + }); + lastError = null; + break; + } catch (err) { + lastError = err; + } + } + if (lastError) { + logVerbose(`${params.errorPrefix} ${sticker.id}: ${formatStickerError(lastError)}`); + } + } +} + function inferPlaceholder(attachment: APIAttachment): string { const mime = attachment.content_type ?? ""; if (mime.startsWith("image/")) { @@ -232,13 +369,37 @@ function buildDiscordAttachmentPlaceholder(attachments?: APIAttachment[]): strin return `${tag} (${count} ${suffix})`; } +function buildDiscordStickerPlaceholder(stickers?: APIStickerItem[]): string { + if (!stickers || stickers.length === 0) { + return ""; + } + const count = stickers.length; + const label = count === 1 ? "sticker" : "stickers"; + return ` (${count} ${label})`; +} + +function buildDiscordMediaPlaceholder(params: { + attachments?: APIAttachment[]; + stickers?: APIStickerItem[]; +}): string { + const attachmentText = buildDiscordAttachmentPlaceholder(params.attachments); + const stickerText = buildDiscordStickerPlaceholder(params.stickers); + if (attachmentText && stickerText) { + return `${attachmentText}\n${stickerText}`; + } + return attachmentText || stickerText || ""; +} + export function resolveDiscordMessageText( message: Message, options?: { fallbackText?: string; includeForwarded?: boolean }, ): string { const baseText = message.content?.trim() || - buildDiscordAttachmentPlaceholder(message.attachments) || + buildDiscordMediaPlaceholder({ + attachments: message.attachments ?? undefined, + stickers: resolveDiscordMessageStickers(message), + }) || message.embeds?.[0]?.description || options?.fallbackText?.trim() || ""; @@ -299,7 +460,10 @@ function resolveDiscordMessageSnapshots(message: Message): DiscordMessageSnapsho function resolveDiscordSnapshotMessageText(snapshot: DiscordSnapshotMessage): string { const content = snapshot.content?.trim() ?? ""; - const attachmentText = buildDiscordAttachmentPlaceholder(snapshot.attachments ?? undefined); + const attachmentText = buildDiscordMediaPlaceholder({ + attachments: snapshot.attachments ?? undefined, + stickers: resolveDiscordSnapshotStickers(snapshot), + }); const embed = snapshot.embeds?.[0]; const embedText = embed?.description?.trim() || embed?.title?.trim() || ""; return content || attachmentText || embedText || "";