mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-23 22:55:24 +00:00
Discord: ingest inbound stickers
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 || "";
|
||||
|
||||
Reference in New Issue
Block a user