Discord: ingest inbound stickers

This commit is contained in:
Shadow
2026-02-20 16:40:27 -06:00
parent 64c29c3755
commit 1eec2aee4f
4 changed files with 285 additions and 9 deletions

View File

@@ -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.

View File

@@ -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;

View File

@@ -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<string, unknown>): 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: "<media:sticker>",
},
]);
});
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: "<media:sticker>",
},
]);
});
});
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("<media:sticker> (1 sticker)");
});
});
describe("resolveDiscordChannelInfo", () => {

View File

@@ -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<DiscordMediaInfo[]> {
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: "<media:sticker>",
});
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 `<media:sticker> (${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 || "";