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/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/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/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. - 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. - 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 { preflightDiscordMessage } from "./message-handler.preflight.js";
import type { DiscordMessagePreflightParams } from "./message-handler.preflight.types.js"; import type { DiscordMessagePreflightParams } from "./message-handler.preflight.types.js";
import { processDiscordMessage } from "./message-handler.process.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< type DiscordMessageHandlerParams = Omit<
DiscordMessagePreflightParams, DiscordMessagePreflightParams,
@@ -48,6 +52,9 @@ export function createDiscordMessageHandler(
if (message.attachments && message.attachments.length > 0) { if (message.attachments && message.attachments.length > 0) {
return false; return false;
} }
if (hasDiscordMessageStickers(message)) {
return false;
}
const baseText = resolveDiscordMessageText(message, { includeForwarded: false }); const baseText = resolveDiscordMessageText(message, { includeForwarded: false });
if (!baseText.trim()) { if (!baseText.trim()) {
return false; return false;

View File

@@ -1,4 +1,5 @@
import { ChannelType, type Client, type Message } from "@buape/carbon"; import { ChannelType, type Client, type Message } from "@buape/carbon";
import { StickerFormatType } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const fetchRemoteMedia = vi.fn(); const fetchRemoteMedia = vi.fn();
@@ -22,6 +23,7 @@ const {
resolveDiscordMessageChannelId, resolveDiscordMessageChannelId,
resolveDiscordMessageText, resolveDiscordMessageText,
resolveForwardedMediaList, resolveForwardedMediaList,
resolveMediaList,
} = await import("./message-utils.js"); } = await import("./message-utils.js");
function asMessage(payload: Record<string, unknown>): Message { 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 () => { it("returns empty when no snapshots are present", async () => {
const result = await resolveForwardedMediaList(asMessage({}), 512); 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", () => { describe("resolveDiscordMessageText", () => {
it("includes forwarded message snapshots in body text", () => { it("includes forwarded message snapshots in body text", () => {
const text = resolveDiscordMessageText( const text = resolveDiscordMessageText(
@@ -152,6 +239,23 @@ describe("resolveDiscordMessageText", () => {
expect(text).toContain("[Forwarded message from @Bob]"); expect(text).toContain("[Forwarded message from @Bob]");
expect(text).toContain("forwarded hello"); 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", () => { describe("resolveDiscordChannelInfo", () => {

View File

@@ -1,5 +1,5 @@
import type { ChannelType, Client, Message } from "@buape/carbon"; 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 { logVerbose } from "../../globals.js";
import { fetchRemoteMedia } from "../../media/fetch.js"; import { fetchRemoteMedia } from "../../media/fetch.js";
import { saveMediaBuffer } from "../../media/store.js"; import { saveMediaBuffer } from "../../media/store.js";
@@ -35,6 +35,8 @@ type DiscordSnapshotMessage = {
content?: string | null; content?: string | null;
embeds?: Array<{ description?: string | null; title?: string | null }> | null; embeds?: Array<{ description?: string | null; title?: string | null }> | null;
attachments?: APIAttachment[] | null; attachments?: APIAttachment[] | null;
stickers?: APIStickerItem[] | null;
sticker_items?: APIStickerItem[] | null;
author?: DiscordSnapshotAuthor | null; author?: DiscordSnapshotAuthor | null;
}; };
@@ -48,6 +50,7 @@ const DISCORD_CHANNEL_INFO_CACHE = new Map<
string, string,
{ value: DiscordChannelInfo | null; expiresAt: number } { value: DiscordChannelInfo | null; expiresAt: number }
>(); >();
const DISCORD_STICKER_ASSET_BASE_URL = "https://media.discordapp.net/stickers";
export function __resetDiscordChannelInfoCacheForTest() { export function __resetDiscordChannelInfoCacheForTest() {
DISCORD_CHANNEL_INFO_CACHE.clear(); 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( export async function resolveMediaList(
message: Message, message: Message,
maxBytes: number, maxBytes: number,
): Promise<DiscordMediaInfo[]> { ): Promise<DiscordMediaInfo[]> {
const attachments = message.attachments ?? [];
if (attachments.length === 0) {
return [];
}
const out: DiscordMediaInfo[] = []; const out: DiscordMediaInfo[] = [];
await appendResolvedMediaFromAttachments({ await appendResolvedMediaFromAttachments({
attachments, attachments: message.attachments ?? [],
maxBytes, maxBytes,
out, out,
errorPrefix: "discord: failed to download attachment", errorPrefix: "discord: failed to download attachment",
}); });
await appendResolvedMediaFromStickers({
stickers: resolveDiscordMessageStickers(message),
maxBytes,
out,
errorPrefix: "discord: failed to download sticker",
});
return out; return out;
} }
@@ -156,6 +193,12 @@ export async function resolveForwardedMediaList(
out, out,
errorPrefix: "discord: failed to download forwarded attachment", 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; 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 { function inferPlaceholder(attachment: APIAttachment): string {
const mime = attachment.content_type ?? ""; const mime = attachment.content_type ?? "";
if (mime.startsWith("image/")) { if (mime.startsWith("image/")) {
@@ -232,13 +369,37 @@ function buildDiscordAttachmentPlaceholder(attachments?: APIAttachment[]): strin
return `${tag} (${count} ${suffix})`; 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( export function resolveDiscordMessageText(
message: Message, message: Message,
options?: { fallbackText?: string; includeForwarded?: boolean }, options?: { fallbackText?: string; includeForwarded?: boolean },
): string { ): string {
const baseText = const baseText =
message.content?.trim() || message.content?.trim() ||
buildDiscordAttachmentPlaceholder(message.attachments) || buildDiscordMediaPlaceholder({
attachments: message.attachments ?? undefined,
stickers: resolveDiscordMessageStickers(message),
}) ||
message.embeds?.[0]?.description || message.embeds?.[0]?.description ||
options?.fallbackText?.trim() || options?.fallbackText?.trim() ||
""; "";
@@ -299,7 +460,10 @@ function resolveDiscordMessageSnapshots(message: Message): DiscordMessageSnapsho
function resolveDiscordSnapshotMessageText(snapshot: DiscordSnapshotMessage): string { function resolveDiscordSnapshotMessageText(snapshot: DiscordSnapshotMessage): string {
const content = snapshot.content?.trim() ?? ""; 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 embed = snapshot.embeds?.[0];
const embedText = embed?.description?.trim() || embed?.title?.trim() || ""; const embedText = embed?.description?.trim() || embed?.title?.trim() || "";
return content || attachmentText || embedText || ""; return content || attachmentText || embedText || "";