feat(discord): download attachments from forwarded messages (#17049)
Co-authored-by: Shadow <shadow@openclaw.ai>
This commit is contained in:
@@ -548,6 +548,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Thinking: allow xhigh for `github-copilot/gpt-5.2-codex` and `github-copilot/gpt-5.2`. (#11646) Thanks @LatencyTDH.
|
||||
- Thinking: honor `/think off` for reasoning-capable models. (#9564) Thanks @liuy.
|
||||
- Discord: support forum/media thread-create starter messages, wire `message thread create --message`, and harden routing. (#10062) Thanks @jarvis89757.
|
||||
- Discord: download attachments from forwarded messages. (#17049) Thanks @pip-nomel, @thewilloftheshadow.
|
||||
- Paths: structurally resolve `OPENCLAW_HOME`-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr.
|
||||
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
|
||||
- Memory: disable async batch embeddings by default for memory indexing (opt-in via `agents.defaults.memorySearch.remote.batch.enabled`). (#13069) Thanks @mcinteerj.
|
||||
|
||||
@@ -29,6 +29,7 @@ import { resolveTimestampMs } from "./format.js";
|
||||
import {
|
||||
buildDiscordMediaPayload,
|
||||
resolveDiscordMessageText,
|
||||
resolveForwardedMediaList,
|
||||
resolveMediaList,
|
||||
} from "./message-utils.js";
|
||||
import { buildDirectLabel, buildGuildLabel, resolveReplyContext } from "./reply-context.js";
|
||||
@@ -315,6 +316,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
} = ctx;
|
||||
|
||||
const mediaList = await resolveMediaList(message, mediaMaxBytes);
|
||||
const forwardedMediaList = await resolveForwardedMediaList(message, mediaMaxBytes);
|
||||
mediaList.push(...forwardedMediaList);
|
||||
const text = messageText;
|
||||
if (!text) {
|
||||
logVerbose(`discord: drop message ${message.id} (empty content)`);
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import type { Message } from "@buape/carbon";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDiscordMessageChannelId } from "./message-utils.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchRemoteMedia = vi.fn();
|
||||
const saveMediaBuffer = vi.fn();
|
||||
|
||||
vi.mock("../../media/fetch.js", () => ({
|
||||
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../media/store.js", () => ({
|
||||
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../globals.js", () => ({
|
||||
logVerbose: () => {},
|
||||
}));
|
||||
|
||||
const { resolveDiscordMessageChannelId, resolveForwardedMediaList } =
|
||||
await import("./message-utils.js");
|
||||
|
||||
function asMessage(payload: Record<string, unknown>): Message {
|
||||
return payload as unknown as Message;
|
||||
return payload as Message;
|
||||
}
|
||||
|
||||
describe("resolveDiscordMessageChannelId", () => {
|
||||
@@ -36,3 +53,72 @@ describe("resolveDiscordMessageChannelId", () => {
|
||||
expect(channelId).toBe("789");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveForwardedMediaList", () => {
|
||||
beforeEach(() => {
|
||||
fetchRemoteMedia.mockReset();
|
||||
saveMediaBuffer.mockReset();
|
||||
});
|
||||
|
||||
it("downloads forwarded attachments", async () => {
|
||||
const attachment = {
|
||||
id: "att-1",
|
||||
url: "https://cdn.discordapp.com/attachments/1/image.png",
|
||||
filename: "image.png",
|
||||
content_type: "image/png",
|
||||
};
|
||||
fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("image"),
|
||||
contentType: "image/png",
|
||||
});
|
||||
saveMediaBuffer.mockResolvedValueOnce({
|
||||
path: "/tmp/image.png",
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
const result = await resolveForwardedMediaList(
|
||||
asMessage({
|
||||
rawData: {
|
||||
message_snapshots: [{ message: { attachments: [attachment] } }],
|
||||
},
|
||||
}),
|
||||
512,
|
||||
);
|
||||
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledTimes(1);
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledWith({
|
||||
url: attachment.url,
|
||||
filePathHint: attachment.filename,
|
||||
});
|
||||
expect(saveMediaBuffer).toHaveBeenCalledTimes(1);
|
||||
expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
path: "/tmp/image.png",
|
||||
contentType: "image/png",
|
||||
placeholder: "<media:image>",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty when no snapshots are present", async () => {
|
||||
const result = await resolveForwardedMediaList(asMessage({}), 512);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(fetchRemoteMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips snapshots without attachments", async () => {
|
||||
const result = await resolveForwardedMediaList(
|
||||
asMessage({
|
||||
rawData: {
|
||||
message_snapshots: [{ message: { content: "hello" } }],
|
||||
},
|
||||
}),
|
||||
512,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(fetchRemoteMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,6 +156,46 @@ export async function resolveMediaList(
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function resolveForwardedMediaList(
|
||||
message: Message,
|
||||
maxBytes: number,
|
||||
): Promise<DiscordMediaInfo[]> {
|
||||
const snapshots = resolveDiscordMessageSnapshots(message);
|
||||
if (snapshots.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const out: DiscordMediaInfo[] = [];
|
||||
for (const snapshot of snapshots) {
|
||||
const attachments = snapshot.message?.attachments;
|
||||
if (!attachments || attachments.length === 0) {
|
||||
continue;
|
||||
}
|
||||
for (const attachment of attachments) {
|
||||
try {
|
||||
const fetched = await fetchRemoteMedia({
|
||||
url: attachment.url,
|
||||
filePathHint: attachment.filename ?? attachment.url,
|
||||
});
|
||||
const saved = await saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
fetched.contentType ?? attachment.content_type,
|
||||
"inbound",
|
||||
maxBytes,
|
||||
);
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: inferPlaceholder(attachment),
|
||||
});
|
||||
} catch (err) {
|
||||
const id = attachment.id ?? attachment.url;
|
||||
logVerbose(`discord: failed to download forwarded attachment ${id}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function inferPlaceholder(attachment: APIAttachment): string {
|
||||
const mime = attachment.content_type ?? "";
|
||||
if (mime.startsWith("image/")) {
|
||||
|
||||
Reference in New Issue
Block a user