mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 15:48:28 +00:00
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: 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.
|
- 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: 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.
|
- 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: 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.
|
- 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 {
|
import {
|
||||||
buildDiscordMediaPayload,
|
buildDiscordMediaPayload,
|
||||||
resolveDiscordMessageText,
|
resolveDiscordMessageText,
|
||||||
|
resolveForwardedMediaList,
|
||||||
resolveMediaList,
|
resolveMediaList,
|
||||||
} from "./message-utils.js";
|
} from "./message-utils.js";
|
||||||
import { buildDirectLabel, buildGuildLabel, resolveReplyContext } from "./reply-context.js";
|
import { buildDirectLabel, buildGuildLabel, resolveReplyContext } from "./reply-context.js";
|
||||||
@@ -315,6 +316,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
} = ctx;
|
} = ctx;
|
||||||
|
|
||||||
const mediaList = await resolveMediaList(message, mediaMaxBytes);
|
const mediaList = await resolveMediaList(message, mediaMaxBytes);
|
||||||
|
const forwardedMediaList = await resolveForwardedMediaList(message, mediaMaxBytes);
|
||||||
|
mediaList.push(...forwardedMediaList);
|
||||||
const text = messageText;
|
const text = messageText;
|
||||||
if (!text) {
|
if (!text) {
|
||||||
logVerbose(`discord: drop message ${message.id} (empty content)`);
|
logVerbose(`discord: drop message ${message.id} (empty content)`);
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
import type { Message } from "@buape/carbon";
|
import type { Message } from "@buape/carbon";
|
||||||
import { describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { resolveDiscordMessageChannelId } from "./message-utils.js";
|
|
||||||
|
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 {
|
function asMessage(payload: Record<string, unknown>): Message {
|
||||||
return payload as unknown as Message;
|
return payload as Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("resolveDiscordMessageChannelId", () => {
|
describe("resolveDiscordMessageChannelId", () => {
|
||||||
@@ -36,3 +53,72 @@ describe("resolveDiscordMessageChannelId", () => {
|
|||||||
expect(channelId).toBe("789");
|
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;
|
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 {
|
function inferPlaceholder(attachment: APIAttachment): string {
|
||||||
const mime = attachment.content_type ?? "";
|
const mime = attachment.content_type ?? "";
|
||||||
if (mime.startsWith("image/")) {
|
if (mime.startsWith("image/")) {
|
||||||
|
|||||||
Reference in New Issue
Block a user