refactor(media): centralize bounded remote downloads

Co-authored-by: samzong <samzong.lu@gmail.com>
This commit is contained in:
Peter Steinberger
2026-05-13 15:02:53 +01:00
parent 218156447c
commit 53d007bc87
81 changed files with 2321 additions and 818 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Plugins/Feishu/WhatsApp/Line: enforce inbound media size caps while reading download streams, avoiding full buffering of oversized attachments. (#81044, #81050) Thanks @samzong.
- Config: serialize and retry semantic config mutations centrally, so concurrent commands can rebase safe changes instead of clobbering or hand-rolling command-local retry loops. (#76601)
- Require approval for setup-code device pairing [AI]. (#81292) Thanks @pgondhi987.
- Docker: pin setup-time container paths so stale host `.env` OpenClaw paths cannot leak into Linux containers. Fixes #80381. (#81105) Thanks @brokemac79.

View File

@@ -542,6 +542,19 @@ two-party event loops that do not go through the shared channel-turn kernel.
<Accordion title="api.runtime.channel">
Channel-specific runtime helpers (available when a channel plugin is loaded).
`api.runtime.channel.media` is the preferred surface for channel media downloads and storage:
```typescript
const saved = await api.runtime.channel.media.saveRemoteMedia({
url,
subdir: "inbound",
maxBytes,
filePathHint: fileName,
});
```
Use `saveRemoteMedia(...)` when a remote URL should become OpenClaw media. Use `saveResponseMedia(...)` when the plugin already fetched a `Response` with plugin-owned auth, redirect, or allowlist handling. Use `readRemoteMediaBuffer(...)` only when the plugin needs raw bytes for inspection, transforms, decryption, or reupload. `fetchRemoteMedia(...)` remains a deprecated compatibility alias for `readRemoteMediaBuffer(...)`.
`api.runtime.channel.mentions` is the shared inbound mention-policy surface for bundled channel plugins that use runtime injection:
```typescript

View File

@@ -302,9 +302,9 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
<Accordion title="Capability and testing subpaths">
| Subpath | Key exports |
| --- | --- |
| `plugin-sdk/media-runtime` | Shared media fetch/transform/store helpers, ffprobe-backed video dimension probing, and media payload builders |
| `plugin-sdk/media-runtime` | Shared media fetch/transform/store helpers including `saveRemoteMedia`, `saveResponseMedia`, `readRemoteMediaBuffer`, and deprecated `fetchRemoteMedia`; prefer store helpers before buffer reads when a URL should become OpenClaw media |
| `plugin-sdk/media-mime` | Narrow MIME normalization, file-extension mapping, MIME detection, and media-kind helpers |
| `plugin-sdk/media-store` | Narrow media store helpers such as `saveMediaBuffer` |
| `plugin-sdk/media-store` | Narrow media store helpers such as `saveMediaBuffer` and `saveMediaStream` |
| `plugin-sdk/media-generation-runtime` | Shared media-generation failover helpers, candidate selection, and missing-model messaging |
| `plugin-sdk/media-understanding` | Media understanding provider types plus provider-facing image/audio/structured-extraction helper exports |
| `plugin-sdk/text-chunking` | Text and markdown chunking/render helpers, markdown table conversion, directive-tag stripping, and safe-text utilities |

View File

@@ -1,10 +1,6 @@
import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10";
import { getFileExtension } from "openclaw/plugin-sdk/media-mime";
import {
fetchRemoteMedia,
saveMediaBuffer,
type FetchLike,
} from "openclaw/plugin-sdk/media-runtime";
import { saveRemoteMedia, type FetchLike } from "openclaw/plugin-sdk/media-runtime";
import { buildMediaPayload } from "openclaw/plugin-sdk/reply-payload";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -215,19 +211,23 @@ async function fetchDiscordMedia(params: {
readIdleTimeoutMs?: number;
totalTimeoutMs?: number;
abortSignal?: AbortSignal;
fallbackContentType?: string;
originalFilename?: string;
}) {
const timeoutAbortController = params.totalTimeoutMs ? new AbortController() : undefined;
const signal = mergeAbortSignals([params.abortSignal, timeoutAbortController?.signal]);
let timedOut = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const fetchPromise = fetchRemoteMedia({
const savePromise = saveRemoteMedia({
url: params.url,
filePathHint: params.filePathHint,
maxBytes: params.maxBytes,
fetchImpl: params.fetchImpl,
ssrfPolicy: params.ssrfPolicy,
readIdleTimeoutMs: params.readIdleTimeoutMs,
fallbackContentType: params.fallbackContentType,
originalFilename: params.originalFilename,
...(signal ? { requestInit: { signal } } : {}),
}).catch((error) => {
if (timedOut) {
@@ -238,7 +238,7 @@ async function fetchDiscordMedia(params: {
try {
if (!params.totalTimeoutMs) {
return await fetchPromise;
return await savePromise;
}
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
@@ -248,7 +248,7 @@ async function fetchDiscordMedia(params: {
}, params.totalTimeoutMs);
timeoutHandle.unref?.();
});
return await Promise.race([fetchPromise, timeoutPromise]);
return await Promise.race([savePromise, timeoutPromise]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
@@ -280,7 +280,7 @@ async function appendResolvedMediaFromAttachments(params: {
continue;
}
try {
const fetched = await fetchDiscordMedia({
const saved = await fetchDiscordMedia({
url: attachmentUrl,
filePathHint: attachment.filename ?? attachmentUrl,
maxBytes: params.maxBytes,
@@ -289,14 +289,9 @@ async function appendResolvedMediaFromAttachments(params: {
readIdleTimeoutMs: params.readIdleTimeoutMs,
totalTimeoutMs: params.totalTimeoutMs,
abortSignal: params.abortSignal,
fallbackContentType: attachment.content_type,
originalFilename: attachment.filename,
});
const saved = await saveMediaBuffer(
fetched.buffer,
fetched.contentType ?? attachment.content_type,
"inbound",
params.maxBytes,
attachment.filename,
);
params.out.push({
path: saved.path,
contentType: saved.contentType,
@@ -388,7 +383,7 @@ async function appendResolvedMediaFromStickers(params: {
let lastError: unknown;
for (const candidate of candidates) {
try {
const fetched = await fetchDiscordMedia({
const saved = await fetchDiscordMedia({
url: candidate.url,
filePathHint: candidate.fileName,
maxBytes: params.maxBytes,
@@ -397,14 +392,9 @@ async function appendResolvedMediaFromStickers(params: {
readIdleTimeoutMs: params.readIdleTimeoutMs,
totalTimeoutMs: params.totalTimeoutMs,
abortSignal: params.abortSignal,
fallbackContentType: inferStickerContentType(sticker),
originalFilename: candidate.fileName,
});
const saved = await saveMediaBuffer(
fetched.buffer,
fetched.contentType,
"inbound",
params.maxBytes,
candidate.fileName,
);
params.out.push({
path: saved.path,
contentType: saved.contentType,

View File

@@ -7,7 +7,7 @@ import {
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ChannelType, type Client, type Message } from "../internal/discord.js";
const fetchRemoteMedia = vi.fn();
const readRemoteMediaBuffer = vi.fn();
const saveMediaBuffer = vi.fn();
vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
@@ -16,7 +16,21 @@ vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
);
return {
...actual,
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args),
readRemoteMediaBuffer: (...args: unknown[]) => readRemoteMediaBuffer(...args),
saveRemoteMedia: async (...args: unknown[]) => {
const fetched = await readRemoteMediaBuffer(...args);
if (fetched && typeof fetched === "object" && "path" in fetched) {
return fetched;
}
const options = (args[0] ?? {}) as { maxBytes?: number; originalFilename?: string };
return await saveMediaBuffer(
Buffer.from((fetched as { buffer?: Uint8Array }).buffer ?? new Uint8Array()),
(fetched as { contentType?: string }).contentType,
"inbound",
options.maxBytes,
options.originalFilename,
);
},
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
};
});
@@ -82,7 +96,10 @@ function callArg(mock: unknown, callIndex: number, argIndex: number, label: stri
}
function fetchParams(): Record<string, unknown> {
return requireRecord(callArg(fetchRemoteMedia, 0, 0, "fetch media params"), "fetch media params");
return requireRecord(
callArg(readRemoteMediaBuffer, 0, 0, "fetch media params"),
"fetch media params",
);
}
function expectDiscordCdnSsrFPolicy(policy: unknown) {
@@ -101,7 +118,7 @@ function expectSinglePngDownload(params: {
expectedPath: string;
placeholder: "<media:image>" | "<media:sticker>";
}) {
expect(fetchRemoteMedia).toHaveBeenCalledTimes(1);
expect(readRemoteMediaBuffer).toHaveBeenCalledTimes(1);
const call = fetchParams();
expect(call.url).toBe(params.expectedUrl);
expect(call.filePathHint).toBe(params.filePathHint);
@@ -220,7 +237,7 @@ describe("resolveDiscordMessageChannelId", () => {
describe("resolveForwardedMediaList", () => {
beforeEach(() => {
fetchRemoteMedia.mockClear();
readRemoteMediaBuffer.mockClear();
saveMediaBuffer.mockClear();
});
@@ -231,7 +248,7 @@ describe("resolveForwardedMediaList", () => {
filename: "image.png",
content_type: "image/png",
};
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("image"),
contentType: "image/png",
});
@@ -266,7 +283,7 @@ describe("resolveForwardedMediaList", () => {
filename: "proxy.png",
content_type: "image/png",
};
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("image"),
contentType: "image/png",
});
@@ -295,7 +312,7 @@ describe("resolveForwardedMediaList", () => {
filename: "fallback.png",
content_type: "image/png",
};
fetchRemoteMedia.mockRejectedValueOnce(new Error("blocked by ssrf guard"));
readRemoteMediaBuffer.mockRejectedValueOnce(new Error("blocked by ssrf guard"));
const result = await resolveForwardedMediaList(
asMessage({
@@ -315,7 +332,7 @@ describe("resolveForwardedMediaList", () => {
name: "wave",
format_type: StickerFormatType.PNG,
};
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("sticker"),
contentType: "image/png",
});
@@ -346,7 +363,7 @@ describe("resolveForwardedMediaList", () => {
const result = await resolveForwardedMediaList(asMessage({}), 512);
expect(result).toStrictEqual([]);
expect(fetchRemoteMedia).not.toHaveBeenCalled();
expect(readRemoteMediaBuffer).not.toHaveBeenCalled();
});
it("downloads forwarded referenced attachments when snapshots are absent", async () => {
@@ -356,7 +373,7 @@ describe("resolveForwardedMediaList", () => {
filename: "ref-image.png",
content_type: "image/png",
};
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("image"),
contentType: "image/png",
});
@@ -392,7 +409,7 @@ describe("resolveForwardedMediaList", () => {
);
expect(result).toStrictEqual([]);
expect(fetchRemoteMedia).not.toHaveBeenCalled();
expect(readRemoteMediaBuffer).not.toHaveBeenCalled();
});
it("passes readIdleTimeoutMs to forwarded attachment downloads", async () => {
@@ -402,7 +419,7 @@ describe("resolveForwardedMediaList", () => {
filename: "forwarded-timeout.png",
content_type: "image/png",
};
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("image"),
contentType: "image/png",
});
@@ -430,7 +447,7 @@ describe("resolveForwardedMediaList", () => {
name: "timeout-forwarded",
format_type: StickerFormatType.PNG,
};
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("sticker"),
contentType: "image/png",
});
@@ -455,7 +472,7 @@ describe("resolveForwardedMediaList", () => {
describe("resolveMediaList", () => {
beforeEach(() => {
fetchRemoteMedia.mockClear();
readRemoteMediaBuffer.mockClear();
saveMediaBuffer.mockClear();
});
@@ -465,7 +482,7 @@ describe("resolveMediaList", () => {
name: "hello",
format_type: StickerFormatType.PNG,
};
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("sticker"),
contentType: "image/png",
});
@@ -497,7 +514,7 @@ describe("resolveMediaList", () => {
name: "proxy-sticker",
format_type: StickerFormatType.PNG,
};
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("sticker"),
contentType: "image/png",
});
@@ -524,7 +541,7 @@ describe("resolveMediaList", () => {
filename: "main-fallback.png",
content_type: "image/png",
};
fetchRemoteMedia.mockRejectedValueOnce(new Error("blocked by ssrf guard"));
readRemoteMediaBuffer.mockRejectedValueOnce(new Error("blocked by ssrf guard"));
const result = await resolveMediaList(
asMessage({
@@ -550,7 +567,7 @@ describe("resolveMediaList", () => {
512,
);
expect(fetchRemoteMedia).not.toHaveBeenCalled();
expect(readRemoteMediaBuffer).not.toHaveBeenCalled();
expect(saveMediaBuffer).not.toHaveBeenCalled();
expect(result).toStrictEqual([]);
});
@@ -561,7 +578,7 @@ describe("resolveMediaList", () => {
url: "https://cdn.discordapp.com/attachments/1/voice.ogg",
filename: "voice.ogg",
};
fetchRemoteMedia.mockRejectedValueOnce(new Error("blocked by ssrf guard"));
readRemoteMediaBuffer.mockRejectedValueOnce(new Error("blocked by ssrf guard"));
const result = await resolveMediaList(
asMessage({
@@ -587,7 +604,7 @@ describe("resolveMediaList", () => {
duration_secs: 1.5,
waveform: "AAAA",
};
fetchRemoteMedia.mockRejectedValueOnce(new Error("blocked by ssrf guard"));
readRemoteMediaBuffer.mockRejectedValueOnce(new Error("blocked by ssrf guard"));
const result = await resolveMediaList(
asMessage({
@@ -612,7 +629,7 @@ describe("resolveMediaList", () => {
filename: "photo.png",
content_type: "image/png",
};
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("image"),
contentType: "image/png",
});
@@ -625,7 +642,7 @@ describe("resolveMediaList", () => {
512,
);
expect(fetchRemoteMedia).toHaveBeenCalledTimes(1);
expect(readRemoteMediaBuffer).toHaveBeenCalledTimes(1);
expect(saveMediaBuffer).toHaveBeenCalledTimes(1);
expect(result).toEqual([
{
@@ -650,7 +667,7 @@ describe("resolveMediaList", () => {
content_type: "application/pdf",
};
fetchRemoteMedia
readRemoteMediaBuffer
.mockResolvedValueOnce({
buffer: Buffer.from("image"),
contentType: "image/png",
@@ -688,7 +705,7 @@ describe("resolveMediaList", () => {
name: "fallback",
format_type: StickerFormatType.PNG,
};
fetchRemoteMedia.mockRejectedValueOnce(new Error("blocked by ssrf guard"));
readRemoteMediaBuffer.mockRejectedValueOnce(new Error("blocked by ssrf guard"));
const result = await resolveMediaList(
asMessage({
@@ -707,14 +724,14 @@ describe("resolveMediaList", () => {
]);
});
it("passes readIdleTimeoutMs to fetchRemoteMedia for attachments", async () => {
it("passes readIdleTimeoutMs to readRemoteMediaBuffer for attachments", async () => {
const attachment = {
id: "att-timeout",
url: "https://cdn.discordapp.com/attachments/1/timeout.png",
filename: "timeout.png",
content_type: "image/png",
};
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("image"),
contentType: "image/png",
});
@@ -734,13 +751,13 @@ describe("resolveMediaList", () => {
expect(fetchParams().readIdleTimeoutMs).toBe(60_000);
});
it("passes readIdleTimeoutMs to fetchRemoteMedia for stickers", async () => {
it("passes readIdleTimeoutMs to readRemoteMediaBuffer for stickers", async () => {
const sticker = {
id: "sticker-timeout",
name: "timeout",
format_type: StickerFormatType.PNG,
};
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("sticker"),
contentType: "image/png",
});
@@ -768,7 +785,7 @@ describe("resolveMediaList", () => {
content_type: "image/png",
};
vi.useFakeTimers();
fetchRemoteMedia.mockImplementation(
readRemoteMediaBuffer.mockImplementation(
() =>
new Promise(() => {
// never resolves
@@ -798,7 +815,7 @@ describe("resolveMediaList", () => {
}
});
it("passes abortSignal to fetchRemoteMedia and falls back when aborted", async () => {
it("passes abortSignal to readRemoteMediaBuffer and falls back when aborted", async () => {
const attachment = {
id: "att-abort",
url: "https://cdn.discordapp.com/attachments/1/abort.png",
@@ -806,7 +823,7 @@ describe("resolveMediaList", () => {
content_type: "image/png",
};
const abortController = new AbortController();
fetchRemoteMedia.mockImplementationOnce(
readRemoteMediaBuffer.mockImplementationOnce(
(params: { requestInit?: { signal?: AbortSignal } }) =>
new Promise((_, reject) => {
const signal = params.requestInit?.signal;
@@ -842,12 +859,12 @@ describe("resolveMediaList", () => {
describe("Discord media SSRF policy", () => {
beforeEach(() => {
fetchRemoteMedia.mockClear();
readRemoteMediaBuffer.mockClear();
saveMediaBuffer.mockClear();
});
it("passes Discord CDN hostname allowlist with RFC2544 enabled", async () => {
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("img"),
contentType: "image/png",
});
@@ -867,7 +884,7 @@ describe("Discord media SSRF policy", () => {
});
it("merges provided ssrfPolicy with Discord CDN defaults", async () => {
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("img"),
contentType: "image/png",
});

View File

@@ -1,7 +1,7 @@
import type { ClawdbotConfig } from "../runtime-api.js";
import { buildFeishuConversationId } from "./conversation-id.js";
import { normalizeFeishuExternalKey } from "./external-keys.js";
import { downloadMessageResourceFeishu } from "./media.js";
import { saveMessageResourceFeishu } from "./media.js";
import { isFeishuBroadcastMention } from "./mention.js";
import { parsePostContent } from "./post.js";
import { getFeishuRuntime } from "./runtime.js";
@@ -329,6 +329,28 @@ export function toMessageResourceType(messageType: string): "image" | "file" {
return messageType === "image" ? "image" : "file";
}
async function resolveSavedFeishuMedia(params: {
result:
| Awaited<ReturnType<typeof saveMessageResourceFeishu>>
| { buffer: Buffer; contentType?: string; fileName?: string };
maxBytes: number;
originalFilename?: string;
}) {
if ("saved" in params.result) {
return params.result.saved;
}
const core = getFeishuRuntime();
const contentType =
params.result.contentType ?? (await core.media.detectMime({ buffer: params.result.buffer }));
return await core.channel.media.saveMediaBuffer(
params.result.buffer,
contentType,
"inbound",
params.maxBytes,
params.result.fileName ?? params.originalFilename,
);
}
function inferPlaceholder(messageType: string): string {
switch (messageType) {
case "image":
@@ -363,8 +385,6 @@ export async function resolveFeishuMediaList(params: {
}
const out: FeishuMediaInfo[] = [];
const core = getFeishuRuntime();
if (messageType === "post") {
const { imageKeys, mediaKeys } = parsePostContent(content);
if (imageKeys.length === 0 && mediaKeys.length === 0) {
@@ -379,21 +399,15 @@ export async function resolveFeishuMediaList(params: {
for (const imageKey of imageKeys) {
try {
const result = await downloadMessageResourceFeishu({
const result = await saveMessageResourceFeishu({
cfg,
messageId,
fileKey: imageKey,
type: "image",
accountId,
});
const contentType =
result.contentType ?? (await core.media.detectMime({ buffer: result.buffer }));
const saved = await core.channel.media.saveMediaBuffer(
result.buffer,
contentType,
"inbound",
maxBytes,
);
});
const saved = await resolveSavedFeishuMedia({ result, maxBytes });
out.push({
path: saved.path,
contentType: saved.contentType,
@@ -407,21 +421,20 @@ export async function resolveFeishuMediaList(params: {
for (const media of mediaKeys) {
try {
const result = await downloadMessageResourceFeishu({
const result = await saveMessageResourceFeishu({
cfg,
messageId,
fileKey: media.fileKey,
type: "file",
accountId,
});
const contentType =
result.contentType ?? (await core.media.detectMime({ buffer: result.buffer }));
const saved = await core.channel.media.saveMediaBuffer(
result.buffer,
contentType,
"inbound",
maxBytes,
);
originalFilename: media.fileName,
});
const saved = await resolveSavedFeishuMedia({
result,
maxBytes,
originalFilename: media.fileName,
});
out.push({
path: saved.path,
contentType: saved.contentType,
@@ -445,22 +458,20 @@ export async function resolveFeishuMediaList(params: {
if (!fileKey) {
return [];
}
const result = await downloadMessageResourceFeishu({
const result = await saveMessageResourceFeishu({
cfg,
messageId,
fileKey,
type: toMessageResourceType(messageType),
accountId,
});
const contentType =
result.contentType ?? (await core.media.detectMime({ buffer: result.buffer }));
const saved = await core.channel.media.saveMediaBuffer(
result.buffer,
contentType,
"inbound",
maxBytes,
result.fileName || mediaKeys.fileName,
);
originalFilename: mediaKeys.fileName,
});
const saved = await resolveSavedFeishuMedia({
result,
maxBytes,
originalFilename: mediaKeys.fileName,
});
out.push({
path: saved.path,
contentType: saved.contentType,

View File

@@ -335,6 +335,7 @@ vi.mock("./send.js", () => ({
vi.mock("./media.js", () => ({
downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu,
saveMessageResourceFeishu: mockDownloadMessageResourceFeishu,
}));
vi.mock("./audio-preflight.runtime.js", () => ({

View File

@@ -5,7 +5,7 @@ import { createToolFactoryHarness, type ToolLike } from "./tool-factory-test-har
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const resolveFeishuToolAccountMock = vi.hoisted(() => vi.fn());
const fetchRemoteMediaMock = vi.hoisted(() => vi.fn());
const readRemoteMediaBufferMock = vi.hoisted(() => vi.fn());
const loadWebMediaMock = vi.hoisted(() => vi.fn());
const convertMock = vi.hoisted(() => vi.fn());
const documentCreateMock = vi.hoisted(() => vi.fn());
@@ -40,7 +40,7 @@ vi.spyOn(runtimeModule, "getFeishuRuntime").mockImplementation(
({
channel: {
media: {
fetchRemoteMedia: fetchRemoteMediaMock,
readRemoteMediaBuffer: readRemoteMediaBufferMock,
saveMediaBuffer: vi.fn(),
},
},
@@ -389,7 +389,7 @@ describe("feishu_doc image fetch hardening", () => {
it("skips image upload when markdown image URL is blocked", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
fetchRemoteMediaMock.mockRejectedValueOnce(
readRemoteMediaBufferMock.mockRejectedValueOnce(
new Error("Blocked: resolves to private/internal IP address"),
);
@@ -401,7 +401,7 @@ describe("feishu_doc image fetch hardening", () => {
content: "![x](https://x.test/image.png)",
});
expect(fetchRemoteMediaMock).toHaveBeenCalled();
expect(readRemoteMediaBufferMock).toHaveBeenCalled();
expect(driveUploadAllMock).not.toHaveBeenCalled();
expect(blockPatchMock).not.toHaveBeenCalled();
expect(result.details.images_processed).toBe(0);

View File

@@ -510,7 +510,7 @@ async function uploadImageToDocx(
}
async function downloadImage(url: string, maxBytes: number): Promise<Buffer> {
const fetched = await getFeishuRuntime().channel.media.fetchRemoteMedia({ url, maxBytes });
const fetched = await getFeishuRuntime().channel.media.readRemoteMediaBuffer({ url, maxBytes });
return fetched.buffer;
}
@@ -635,7 +635,7 @@ async function resolveUploadInput(
}
if (url) {
const fetched = await getFeishuRuntime().channel.media.fetchRemoteMedia({ url, maxBytes });
const fetched = await getFeishuRuntime().channel.media.readRemoteMediaBuffer({ url, maxBytes });
const urlPath = new URL(url).pathname;
const guessed = urlPath.split("/").pop() || "upload.bin";
return {

View File

@@ -1,6 +1,8 @@
import { realpathSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../runtime-api.js";
@@ -54,6 +56,7 @@ vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
let downloadImageFeishu: typeof import("./media.js").downloadImageFeishu;
let downloadMessageResourceFeishu: typeof import("./media.js").downloadMessageResourceFeishu;
let saveMessageResourceFeishu: typeof import("./media.js").saveMessageResourceFeishu;
let sanitizeFileNameForUpload: typeof import("./media.js").sanitizeFileNameForUpload;
let sendMediaFeishu: typeof import("./media.js").sendMediaFeishu;
let shouldSuppressFeishuTextForVoiceMedia: typeof import("./media.js").shouldSuppressFeishuTextForVoiceMedia;
@@ -114,6 +117,7 @@ describe("sendMediaFeishu msg_type routing", () => {
({
downloadImageFeishu,
downloadMessageResourceFeishu,
saveMessageResourceFeishu,
sanitizeFileNameForUpload,
sendMediaFeishu,
shouldSuppressFeishuTextForVoiceMedia,
@@ -545,6 +549,40 @@ describe("sendMediaFeishu msg_type routing", () => {
expectPathIsolatedToTmpRoot(capturedPath, fileKey);
});
it("rejects oversized message resource streams before buffering the rest", async () => {
messageResourceGetMock.mockResolvedValueOnce({
getReadableStream: () => Readable.from([Buffer.alloc(4), Buffer.alloc(4)]),
});
await expect(
downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_123",
fileKey: "file_v3_01abc123",
type: "file",
maxBytes: 7,
}),
).rejects.toThrow(/Media exceeds/i);
});
it("rejects oversized writeFile downloads before reading the temp file", async () => {
messageResourceGetMock.mockResolvedValueOnce({
writeFile: async (tmpPath: string) => {
await fs.writeFile(tmpPath, Buffer.alloc(8));
},
});
await expect(
downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_123",
fileKey: "file_v3_01abc123",
type: "file",
maxBytes: 7,
}),
).rejects.toThrow(/Media exceeds/i);
});
it("rejects invalid image keys before calling feishu api", async () => {
await expect(
downloadImageFeishu({
@@ -876,4 +914,41 @@ describe("downloadMessageResourceFeishu", () => {
expect(result.fileName).toBe(latin1LookingFileName);
});
it("saves message resource streams directly to the media store", async () => {
const originalHome = process.env.HOME;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-media-"));
try {
process.env.HOME = tempHome;
messageResourceGetMock.mockResolvedValueOnce({
getReadableStream: () => Readable.from([Buffer.from([0xff, 0xd8, 0xff, 0x00])]),
headers: {
"content-type": "image/jpeg",
"content-disposition": `attachment; filename="photo.jpg"`,
},
});
const result = await saveMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_stream_msg",
fileKey: "img_key_stream",
type: "image",
maxBytes: 1024,
});
expect(result.saved.path).toContain(`${path.sep}.openclaw${path.sep}media${path.sep}inbound`);
expect(result.saved.id).toMatch(/^photo---[a-f0-9-]{36}\.jpg$/);
expect(result.saved.size).toBe(4);
await expect(fs.readFile(result.saved.path)).resolves.toEqual(
Buffer.from([0xff, 0xd8, 0xff, 0x00]),
);
} finally {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
await fs.rm(tempHome, { recursive: true, force: true });
}
});
});

View File

@@ -5,6 +5,8 @@ import type * as Lark from "@larksuiteoapi/node-sdk";
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message";
import { mediaKindFromMime } from "openclaw/plugin-sdk/media-mime";
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
import { saveMediaBuffer, saveMediaStream, type SavedMedia } from "openclaw/plugin-sdk/media-store";
import { readByteStreamWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { readRegularFile, writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
@@ -56,6 +58,12 @@ export type DownloadMessageResourceResult = {
fileName?: string;
};
export type SaveMessageResourceResult = {
saved: SavedMedia;
contentType?: string;
fileName?: string;
};
function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accountId?: string }): {
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
client: ReturnType<typeof createFeishuClient>;
@@ -242,25 +250,29 @@ function extractFeishuDownloadMetadata(response: FeishuDownloadResponse): {
return { contentType, fileName };
}
async function readReadableBuffer(stream: Readable): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
function mediaLimitError(maxBytes: number): Error {
return new Error(`Media exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`);
}
function assertBufferWithinLimit(buffer: Buffer, maxBytes: number): Buffer {
if (buffer.byteLength > maxBytes) {
throw mediaLimitError(maxBytes);
}
return Buffer.concat(chunks);
return buffer;
}
async function readFeishuResponseBuffer(params: {
response: FeishuDownloadResponse;
tmpDirPrefix: string;
errorPrefix: string;
maxBytes: number;
}): Promise<Buffer> {
const { response } = params;
const { response, maxBytes } = params;
if (Buffer.isBuffer(response)) {
return response;
return assertBufferWithinLimit(response, maxBytes);
}
if (response instanceof ArrayBuffer) {
return Buffer.from(response);
return assertBufferWithinLimit(Buffer.from(response), maxBytes);
}
const responseWithOptionalFields = response as FeishuDownloadResponse & {
code?: number;
@@ -275,30 +287,121 @@ async function readFeishuResponseBuffer(params: {
}
if (responseWithOptionalFields.data && Buffer.isBuffer(responseWithOptionalFields.data)) {
return responseWithOptionalFields.data;
return assertBufferWithinLimit(responseWithOptionalFields.data, maxBytes);
}
if (responseWithOptionalFields.data instanceof ArrayBuffer) {
return Buffer.from(responseWithOptionalFields.data);
return assertBufferWithinLimit(Buffer.from(responseWithOptionalFields.data), maxBytes);
}
if (typeof response.getReadableStream === "function") {
return readReadableBuffer(response.getReadableStream());
return readByteStreamWithLimit(response.getReadableStream(), {
maxBytes,
onOverflow: () => mediaLimitError(maxBytes),
});
}
if (typeof response.writeFile === "function") {
return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
await response.writeFile(tmpPath);
const stat = await fs.promises.stat(tmpPath);
if (stat.size > maxBytes) {
throw mediaLimitError(maxBytes);
}
return await fs.promises.readFile(tmpPath);
});
}
if (responseWithOptionalFields[Symbol.asyncIterator]) {
const asyncIterable = responseWithOptionalFields as AsyncIterable<Buffer | Uint8Array | string>;
const chunks: Buffer[] = [];
for await (const chunk of asyncIterable) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
return readByteStreamWithLimit(asyncIterable, {
maxBytes,
onOverflow: () => mediaLimitError(maxBytes),
});
}
if (response instanceof Readable) {
return readReadableBuffer(response);
return readByteStreamWithLimit(response, {
maxBytes,
onOverflow: () => mediaLimitError(maxBytes),
});
}
const keys = Object.keys(response as object);
throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${keys.join(", ")}]`);
}
async function saveFeishuResponseMedia(params: {
response: FeishuDownloadResponse;
tmpDirPrefix: string;
errorPrefix: string;
maxBytes: number;
contentType?: string;
fileName?: string;
}): Promise<SavedMedia> {
const { response, maxBytes, contentType, fileName } = params;
if (Buffer.isBuffer(response)) {
return saveMediaBuffer(response, contentType, "inbound", maxBytes, fileName);
}
if (response instanceof ArrayBuffer) {
return saveMediaBuffer(Buffer.from(response), contentType, "inbound", maxBytes, fileName);
}
const responseWithOptionalFields = response as FeishuDownloadResponse & {
code?: number;
msg?: string;
data?: Buffer | ArrayBuffer;
[Symbol.asyncIterator]?: () => AsyncIterator<Buffer | Uint8Array | string>;
};
if (responseWithOptionalFields.code !== undefined && responseWithOptionalFields.code !== 0) {
throw new Error(
`${params.errorPrefix}: ${responseWithOptionalFields.msg || `code ${responseWithOptionalFields.code}`}`,
);
}
if (responseWithOptionalFields.data && Buffer.isBuffer(responseWithOptionalFields.data)) {
return saveMediaBuffer(
responseWithOptionalFields.data,
contentType,
"inbound",
maxBytes,
fileName,
);
}
if (responseWithOptionalFields.data instanceof ArrayBuffer) {
return saveMediaBuffer(
Buffer.from(responseWithOptionalFields.data),
contentType,
"inbound",
maxBytes,
fileName,
);
}
if (typeof response.getReadableStream === "function") {
return saveMediaStream(
response.getReadableStream(),
contentType,
"inbound",
maxBytes,
fileName,
);
}
if (typeof response.writeFile === "function") {
return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
await response.writeFile(tmpPath);
const stat = await fs.promises.stat(tmpPath);
if (stat.size > maxBytes) {
throw mediaLimitError(maxBytes);
}
return await saveMediaStream(
fs.createReadStream(tmpPath),
contentType,
"inbound",
maxBytes,
fileName,
);
});
}
if (responseWithOptionalFields[Symbol.asyncIterator]) {
const asyncIterable = responseWithOptionalFields as AsyncIterable<Buffer | Uint8Array | string>;
return saveMediaStream(asyncIterable, contentType, "inbound", maxBytes, fileName);
}
if (response instanceof Readable) {
return saveMediaStream(response, contentType, "inbound", maxBytes, fileName);
}
const keys = Object.keys(response as object);
@@ -313,8 +416,9 @@ export async function downloadImageFeishu(params: {
cfg: ClawdbotConfig;
imageKey: string;
accountId?: string;
maxBytes?: number;
}): Promise<DownloadImageResult> {
const { cfg, imageKey, accountId } = params;
const { cfg, imageKey, accountId, maxBytes = 30 * 1024 * 1024 } = params;
const normalizedImageKey = normalizeFeishuExternalKey(imageKey);
if (!normalizedImageKey) {
throw new Error("Feishu image download failed: invalid image_key");
@@ -329,6 +433,7 @@ export async function downloadImageFeishu(params: {
response,
tmpDirPrefix: "openclaw-feishu-img-",
errorPrefix: "Feishu image download failed",
maxBytes,
});
const meta = extractFeishuDownloadMetadata(response);
return { buffer, contentType: meta.contentType };
@@ -339,6 +444,7 @@ async function downloadMessageResourceWithType(params: {
messageId: string;
fileKey: string;
type: FeishuMessageResourceDownloadType;
maxBytes: number;
}): Promise<DownloadMessageResourceResult> {
const response = await params.client.im.messageResource.get({
path: { message_id: params.messageId, file_key: params.fileKey },
@@ -349,10 +455,35 @@ async function downloadMessageResourceWithType(params: {
response,
tmpDirPrefix: "openclaw-feishu-resource-",
errorPrefix: "Feishu message resource download failed",
maxBytes: params.maxBytes,
});
return { buffer, ...extractFeishuDownloadMetadata(response) };
}
async function saveMessageResourceWithType(params: {
client: ReturnType<typeof createFeishuClient>;
messageId: string;
fileKey: string;
type: FeishuMessageResourceDownloadType;
maxBytes: number;
originalFilename?: string;
}): Promise<SaveMessageResourceResult> {
const response = await params.client.im.messageResource.get({
path: { message_id: params.messageId, file_key: params.fileKey },
params: { type: params.type },
});
const meta = extractFeishuDownloadMetadata(response);
const saved = await saveFeishuResponseMedia({
response,
tmpDirPrefix: "openclaw-feishu-resource-",
errorPrefix: "Feishu message resource download failed",
maxBytes: params.maxBytes,
contentType: meta.contentType,
fileName: meta.fileName ?? params.originalFilename,
});
return { saved, ...meta };
}
/**
* Download a message resource (file/image/audio/video) from Feishu.
* Used for downloading files, audio, and video from messages.
@@ -363,8 +494,9 @@ export async function downloadMessageResourceFeishu(params: {
fileKey: string;
type: "image" | "file";
accountId?: string;
maxBytes?: number;
}): Promise<DownloadMessageResourceResult> {
const { cfg, messageId, fileKey, type, accountId } = params;
const { cfg, messageId, fileKey, type, accountId, maxBytes = 30 * 1024 * 1024 } = params;
const normalizedFileKey = normalizeFeishuExternalKey(fileKey);
if (!normalizedFileKey) {
throw new Error("Feishu message resource download failed: invalid file_key");
@@ -377,6 +509,7 @@ export async function downloadMessageResourceFeishu(params: {
messageId,
fileKey: normalizedFileKey,
type,
maxBytes,
});
} catch (err) {
if (type !== "file" || !isHttpStatusError(err, 502)) {
@@ -388,6 +521,51 @@ export async function downloadMessageResourceFeishu(params: {
messageId,
fileKey: normalizedFileKey,
type: "media",
maxBytes,
});
} catch {
throw err;
}
}
}
export async function saveMessageResourceFeishu(params: {
cfg: ClawdbotConfig;
messageId: string;
fileKey: string;
type: "image" | "file";
accountId?: string;
maxBytes: number;
originalFilename?: string;
}): Promise<SaveMessageResourceResult> {
const { cfg, messageId, fileKey, type, accountId, maxBytes, originalFilename } = params;
const normalizedFileKey = normalizeFeishuExternalKey(fileKey);
if (!normalizedFileKey) {
throw new Error("Feishu message resource download failed: invalid file_key");
}
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
try {
return await saveMessageResourceWithType({
client,
messageId,
fileKey: normalizedFileKey,
type,
maxBytes,
originalFilename,
});
} catch (err) {
if (type !== "file" || !isHttpStatusError(err, 502)) {
throw err;
}
try {
return await saveMessageResourceWithType({
client,
messageId,
fileKey: normalizedFileKey,
type: "media",
maxBytes,
originalFilename,
});
} catch {
throw err;

View File

@@ -33,7 +33,10 @@ export {
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/runtime-group-policy";
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
export { fetchRemoteMedia, resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
export {
readRemoteMediaBuffer,
resolveChannelMediaMaxBytes,
} from "openclaw/plugin-sdk/media-runtime";
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
export { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";

View File

@@ -117,7 +117,7 @@ describe("googlechat message actions", () => {
});
resolveGoogleChatAccount.mockReturnValue(account);
resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/AAA");
const fetchRemoteMedia = vi.fn(async () => ({
const readRemoteMediaBuffer = vi.fn(async () => ({
buffer: Buffer.from("remote-bytes"),
fileName: "remote.png",
contentType: "image/png",
@@ -125,7 +125,7 @@ describe("googlechat message actions", () => {
getGoogleChatRuntime.mockReturnValue({
channel: {
media: {
fetchRemoteMedia,
readRemoteMediaBuffer,
},
},
});
@@ -155,7 +155,7 @@ describe("googlechat message actions", () => {
account,
target: "spaces/AAA",
});
expect(fetchRemoteMedia).toHaveBeenCalledWith({
expect(readRemoteMediaBuffer).toHaveBeenCalledWith({
url: "https://example.com/file.png",
maxBytes: 5 * 1024 * 1024,
});
@@ -188,7 +188,7 @@ describe("googlechat message actions", () => {
getGoogleChatRuntime.mockReturnValue({
channel: {
media: {
fetchRemoteMedia: vi.fn(),
readRemoteMediaBuffer: vi.fn(),
},
},
});

View File

@@ -57,7 +57,7 @@ async function loadGoogleChatActionMedia(params: {
}) {
const runtime = getGoogleChatRuntime();
return /^https?:\/\//i.test(params.mediaUrl)
? await runtime.channel.media.fetchRemoteMedia({
? await runtime.channel.media.readRemoteMediaBuffer({
url: params.mediaUrl,
maxBytes: params.maxBytes,
})

View File

@@ -1,5 +1,6 @@
import crypto from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { getGoogleChatAccessToken } from "./auth.js";
@@ -108,30 +109,14 @@ async function fetchBuffer(
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
}
}
if (!maxBytes || !res.body) {
if (!maxBytes) {
const buffer = Buffer.from(await res.arrayBuffer());
const contentType = res.headers.get("content-type") ?? undefined;
return { buffer, contentType };
}
const reader = res.body.getReader();
const chunks: Buffer[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value) {
continue;
}
total += value.length;
if (total > maxBytes) {
await reader.cancel();
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
}
chunks.push(Buffer.from(value));
}
const buffer = Buffer.concat(chunks, total);
const buffer = await readResponseWithLimit(res, maxBytes, {
onOverflow: () => new Error(`Google Chat media exceeds max bytes (${maxBytes})`),
});
const contentType = res.headers.get("content-type") ?? undefined;
return { buffer, contentType };
},

View File

@@ -23,7 +23,7 @@ import {
import {
type ResolvedGoogleChatAccount,
chunkTextForOutbound,
fetchRemoteMedia,
readRemoteMediaBuffer,
isGoogleChatUserTarget,
loadOutboundMediaFromUrl,
missingTargetError,
@@ -280,7 +280,7 @@ export const googlechatOutboundAdapter = {
});
const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
const loaded = /^https?:\/\//i.test(mediaUrl)
? await fetchRemoteMedia({
? await readRemoteMediaBuffer({
url: mediaUrl,
maxBytes: effectiveMaxBytes,
})

View File

@@ -2,7 +2,7 @@ export {
buildChannelConfigSchema,
chunkTextForOutbound,
DEFAULT_ACCOUNT_ID,
fetchRemoteMedia,
readRemoteMediaBuffer,
GoogleChatConfigSchema,
loadOutboundMediaFromUrl,
missingTargetError,

View File

@@ -18,7 +18,7 @@ const uploadGoogleChatAttachmentMock = vi.hoisted(() => vi.fn());
const sendGoogleChatMessageMock = vi.hoisted(() => vi.fn());
const resolveGoogleChatAccountMock = vi.hoisted(() => vi.fn());
const resolveGoogleChatOutboundSpaceMock = vi.hoisted(() => vi.fn());
const fetchRemoteMediaMock = vi.hoisted(() => vi.fn());
const readRemoteMediaBufferMock = vi.hoisted(() => vi.fn());
const loadOutboundMediaFromUrlMock = vi.hoisted(() => vi.fn());
const probeGoogleChatMock = vi.hoisted(() => vi.fn());
const startGoogleChatMonitorMock = vi.hoisted(() => vi.fn());
@@ -82,7 +82,7 @@ function mockGoogleChatMediaLoaders() {
fileName: mediaUrl.split("/").pop() || "attachment",
contentType: "application/octet-stream",
}));
fetchRemoteMediaMock.mockImplementation(async () => ({
readRemoteMediaBufferMock.mockImplementation(async () => ({
buffer: Buffer.from("remote-bytes"),
fileName: "remote.png",
contentType: "image/png",
@@ -127,7 +127,7 @@ vi.mock("./channel.deps.runtime.js", () => {
return chunks;
},
createAccountStatusSink: () => () => {},
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMediaMock(...args),
readRemoteMediaBuffer: (...args: unknown[]) => readRemoteMediaBufferMock(...args),
getChatChannelMeta: (id: string) => ({ id, name: id }),
isGoogleChatSpaceTarget: (value: string) => value.toLowerCase().startsWith("spaces/"),
isGoogleChatUserTarget: (value: string) => value.toLowerCase().startsWith("users/"),
@@ -203,16 +203,16 @@ function setupRuntimeMediaMocks(params: { loadFileName: string; loadBytes: strin
fileName: params.loadFileName,
contentType: "image/png",
}));
const fetchRemoteMedia = vi.fn(async () => ({
const readRemoteMediaBuffer = vi.fn(async () => ({
buffer: Buffer.from("remote-bytes"),
fileName: "remote.png",
contentType: "image/png",
}));
loadOutboundMediaFromUrlMock.mockImplementation(loadOutboundMediaFromUrl);
fetchRemoteMediaMock.mockImplementation(fetchRemoteMedia);
readRemoteMediaBufferMock.mockImplementation(readRemoteMediaBuffer);
return { loadOutboundMediaFromUrl, fetchRemoteMedia };
return { loadOutboundMediaFromUrl, readRemoteMediaBuffer };
}
function requireMockArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0): unknown {
@@ -308,7 +308,7 @@ describe("googlechatPlugin outbound sendMedia", () => {
});
it("loads local media with mediaLocalRoots via runtime media loader", async () => {
const { loadOutboundMediaFromUrl, fetchRemoteMedia } = setupRuntimeMediaMocks({
const { loadOutboundMediaFromUrl, readRemoteMediaBuffer } = setupRuntimeMediaMocks({
loadFileName: "image.png",
loadBytes: "image-bytes",
});
@@ -337,7 +337,7 @@ describe("googlechatPlugin outbound sendMedia", () => {
];
expect(mediaUrl).toBe("/tmp/workspace/image.png");
expect(mediaOptions.mediaLocalRoots).toEqual(["/tmp/workspace"]);
expect(fetchRemoteMedia).not.toHaveBeenCalled();
expect(readRemoteMediaBuffer).not.toHaveBeenCalled();
const uploadRequest = requireMockArg(uploadGoogleChatAttachmentMock) as {
space?: string;
filename?: string;
@@ -357,8 +357,8 @@ describe("googlechatPlugin outbound sendMedia", () => {
expect(result.receipt.primaryPlatformMessageId).toBe("spaces/AAA/messages/msg-1");
});
it("keeps remote URL media fetch on fetchRemoteMedia with maxBytes cap", async () => {
const { loadOutboundMediaFromUrl, fetchRemoteMedia } = setupRuntimeMediaMocks({
it("keeps remote URL media fetch on readRemoteMediaBuffer with maxBytes cap", async () => {
const { loadOutboundMediaFromUrl, readRemoteMediaBuffer } = setupRuntimeMediaMocks({
loadFileName: "unused.png",
loadBytes: "should-not-be-used",
});
@@ -380,7 +380,7 @@ describe("googlechatPlugin outbound sendMedia", () => {
accountId: "default",
});
const remoteRequest = requireMockArg(fetchRemoteMedia) as {
const remoteRequest = requireMockArg(readRemoteMediaBuffer) as {
url?: string;
maxBytes?: number;
};
@@ -602,7 +602,7 @@ describe("googlechatPlugin outbound cfg threading", () => {
config: { mediaMaxMb: 20 },
credentialSource: "inline" as const,
};
const { fetchRemoteMedia } = setupRuntimeMediaMocks({
const { readRemoteMediaBuffer } = setupRuntimeMediaMocks({
loadFileName: "unused.png",
loadBytes: "should-not-be-used",
});
@@ -628,7 +628,7 @@ describe("googlechatPlugin outbound cfg threading", () => {
cfg,
accountId: "default",
});
const remoteRequest = requireMockArg(fetchRemoteMedia) as {
const remoteRequest = requireMockArg(readRemoteMediaBuffer) as {
url?: string;
maxBytes?: number;
};

View File

@@ -107,7 +107,7 @@ export async function deliverGoogleChatReply(params: {
},
sendMedia: async ({ mediaUrl, caption }) => {
try {
const loaded = await core.channel.media.fetchRemoteMedia({
const loaded = await core.channel.media.readRemoteMediaBuffer({
url: mediaUrl,
maxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
});

View File

@@ -37,7 +37,7 @@ function createCore(params?: {
chunkMarkdownTextWithMode: vi.fn((text: string) => params?.chunks ?? [text]),
},
media: {
fetchRemoteMedia: vi.fn(async () => params?.media ?? { buffer: Buffer.from("image") }),
readRemoteMediaBuffer: vi.fn(async () => params?.media ?? { buffer: Buffer.from("image") }),
},
},
} as unknown as GoogleChatCoreRuntime;

View File

@@ -1,7 +1,7 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const getMessageContentMock = vi.hoisted(() => vi.fn());
const saveMediaBufferMock = vi.hoisted(() => vi.fn());
const saveMediaStreamMock = vi.hoisted(() => vi.fn());
vi.mock("@line/bot-sdk", () => ({
messagingApi: {
@@ -28,7 +28,7 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
}));
vi.mock("openclaw/plugin-sdk/media-store", () => ({
saveMediaBuffer: saveMediaBufferMock,
saveMediaStream: saveMediaStreamMock,
}));
let downloadLineMedia: typeof import("./download.js").downloadLineMedia;
@@ -39,14 +39,24 @@ async function* chunks(parts: Buffer[]): AsyncGenerator<Buffer> {
}
}
function saveMediaBufferCall(): unknown[] {
const call = saveMediaBufferMock.mock.calls[0];
function saveMediaStreamCall(): unknown[] {
const call = saveMediaStreamMock.mock.calls.at(0);
if (!call) {
throw new Error("Expected saveMediaBuffer call");
throw new Error("Expected saveMediaStream call");
}
return call;
}
function detectMockContentType(buffer: Buffer, contentType?: string): string | undefined {
if (buffer[0] === 0xff && buffer[1] === 0xd8) {
return "image/jpeg";
}
if (buffer.toString("ascii", 4, 8) === "ftyp") {
return buffer.toString("ascii", 8, 12) === "M4A " ? "audio/x-m4a" : "video/mp4";
}
return contentType;
}
describe("downloadLineMedia", () => {
beforeAll(async () => {
({ downloadLineMedia } = await import("./download.js"));
@@ -62,12 +72,20 @@ describe("downloadLineMedia", () => {
beforeEach(() => {
vi.restoreAllMocks();
getMessageContentMock.mockReset();
saveMediaBufferMock.mockReset();
saveMediaBufferMock.mockImplementation(
async (_buffer: Buffer, contentType?: string, subdir?: string) => ({
path: `/home/user/.openclaw/media/${subdir ?? "unknown"}/saved-media`,
contentType,
}),
saveMediaStreamMock.mockReset();
saveMediaStreamMock.mockImplementation(
async (stream: AsyncIterable<Buffer>, contentType?: string, subdir?: string) => {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk));
}
const buffer = Buffer.concat(chunks);
return {
path: `/home/user/.openclaw/media/${subdir ?? "unknown"}/saved-media`,
contentType: detectMockContentType(buffer, contentType),
size: buffer.length,
};
},
);
});
@@ -77,10 +95,9 @@ describe("downloadLineMedia", () => {
const result = await downloadLineMedia("mid-jpeg", "token");
expect(saveMediaBufferMock).toHaveBeenCalledTimes(1);
const call = saveMediaBufferCall();
expect((call[0] as Buffer).equals(jpeg)).toBe(true);
expect(call[1]).toBe("image/jpeg");
expect(saveMediaStreamMock).toHaveBeenCalledTimes(1);
const call = saveMediaStreamCall();
expect(call[1]).toBeUndefined();
expect(call[2]).toBe("inbound");
expect(call[3]).toBe(10 * 1024 * 1024);
expect(result).toEqual({
@@ -90,7 +107,7 @@ describe("downloadLineMedia", () => {
});
});
it("does not pass the external messageId to saveMediaBuffer", async () => {
it("does not pass the external messageId to saveMediaStream", async () => {
const messageId = "a/../../../../etc/passwd";
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
@@ -99,21 +116,22 @@ describe("downloadLineMedia", () => {
expect(result.size).toBe(jpeg.length);
expect(result.contentType).toBe("image/jpeg");
for (const arg of saveMediaBufferCall()) {
for (const arg of saveMediaStreamCall()) {
if (typeof arg === "string") {
expect(arg).not.toContain(messageId);
}
}
});
it("rejects oversized media before invoking saveMediaBuffer", async () => {
it("delegates oversized media rejection to saveMediaStream", async () => {
getMessageContentMock.mockResolvedValueOnce(chunks([Buffer.alloc(4), Buffer.alloc(4)]));
saveMediaStreamMock.mockRejectedValueOnce(new Error("Media exceeds 0MB limit"));
await expect(downloadLineMedia("mid", "token", 7)).rejects.toThrow(/Media exceeds/i);
expect(saveMediaBufferMock).not.toHaveBeenCalled();
expect(saveMediaStreamMock).toHaveBeenCalledTimes(1);
});
it("classifies M4A ftyp major brand as audio/mp4", async () => {
it("uses media store content type for M4A media", async () => {
const m4aHeader = Buffer.from([
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20,
]);
@@ -121,12 +139,11 @@ describe("downloadLineMedia", () => {
const result = await downloadLineMedia("mid-audio", "token");
expect(result.contentType).toBe("audio/mp4");
expect(saveMediaBufferCall()[1]).toBe("audio/mp4");
expect(saveMediaBufferCall()[2]).toBe("inbound");
expect(result.contentType).toBe("audio/x-m4a");
expect(saveMediaStreamCall()[2]).toBe("inbound");
});
it("detects MP4 video from ftyp major brand (isom)", async () => {
it("uses media store content type for MP4 video", async () => {
const mp4 = Buffer.from([
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d,
]);
@@ -135,13 +152,12 @@ describe("downloadLineMedia", () => {
const result = await downloadLineMedia("mid-mp4", "token");
expect(result.contentType).toBe("video/mp4");
expect(saveMediaBufferCall()[1]).toBe("video/mp4");
});
it("propagates media store failures", async () => {
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
saveMediaBufferMock.mockRejectedValueOnce(new Error("Media exceeds 0MB limit"));
saveMediaStreamMock.mockRejectedValueOnce(new Error("Media exceeds 0MB limit"));
await expect(downloadLineMedia("mid-bad", "token")).rejects.toThrow(/Media exceeds/i);
});

View File

@@ -1,7 +1,6 @@
import { messagingApi } from "@line/bot-sdk";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
import { saveMediaStream } from "openclaw/plugin-sdk/media-store";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/string-coerce-runtime";
interface DownloadResult {
path: string;
@@ -9,8 +8,6 @@ interface DownloadResult {
size: number;
}
const AUDIO_BRANDS = new Set(["m4a ", "m4b ", "m4p ", "m4r ", "f4a ", "f4b "]);
export async function downloadLineMedia(
messageId: string,
channelAccessToken: string,
@@ -21,67 +18,17 @@ export async function downloadLineMedia(
});
const response = await client.getMessageContent(messageId);
const chunks: Buffer[] = [];
let totalSize = 0;
for await (const chunk of response as AsyncIterable<Buffer>) {
totalSize += chunk.length;
if (totalSize > maxBytes) {
throw new Error(`Media exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`);
}
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
const contentType = detectContentType(buffer);
const saved = await saveMediaBuffer(buffer, contentType, "inbound", maxBytes);
logVerbose(`line: persisted media ${messageId} to ${saved.path} (${buffer.length} bytes)`);
const saved = await saveMediaStream(
response as AsyncIterable<Buffer>,
undefined,
"inbound",
maxBytes,
);
logVerbose(`line: persisted media ${messageId} to ${saved.path} (${saved.size} bytes)`);
return {
path: saved.path,
contentType: saved.contentType,
size: buffer.length,
size: saved.size,
};
}
function detectContentType(buffer: Buffer): string {
const hasFtypBox =
buffer.length >= 12 &&
buffer[4] === 0x66 &&
buffer[5] === 0x74 &&
buffer[6] === 0x79 &&
buffer[7] === 0x70;
if (buffer.length >= 2) {
if (buffer[0] === 0xff && buffer[1] === 0xd8) {
return "image/jpeg";
}
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
return "image/png";
}
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
return "image/gif";
}
if (
buffer[0] === 0x52 &&
buffer[1] === 0x49 &&
buffer[2] === 0x46 &&
buffer[3] === 0x46 &&
buffer[8] === 0x57 &&
buffer[9] === 0x45 &&
buffer[10] === 0x42 &&
buffer[11] === 0x50
) {
return "image/webp";
}
if (hasFtypBox) {
const majorBrand = lowercasePreservingWhitespace(buffer.toString("ascii", 8, 12));
if (AUDIO_BRANDS.has(majorBrand)) {
return "audio/mp4";
}
return "video/mp4";
}
}
return "application/octet-stream";
}

View File

@@ -2,6 +2,7 @@ import {
implicitMentionKindWhen,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-mention-gating";
import { createPluginRuntimeMediaMock } from "openclaw/plugin-sdk/channel-test-helpers";
import { vi } from "vitest";
import type { PluginRuntime } from "./runtime-api.js";
import { setMatrixRuntime } from "./runtime.js";
@@ -75,10 +76,9 @@ export function installMatrixMonitorTestRuntime(
implicitMentionKindWhen,
resolveInboundMentionDecision,
},
media: {
fetchRemoteMedia: vi.fn(),
media: createPluginRuntimeMediaMock({
saveMediaBuffer: options.saveMediaBuffer ?? vi.fn(),
},
}) as unknown as PluginRuntime["channel"]["media"],
},
});
}

View File

@@ -33,11 +33,7 @@ describe("mattermost monitor resources", () => {
});
it("downloads media, preserves auth headers, and infers media kind", async () => {
const fetchRemoteMedia = vi.fn(async () => ({
buffer: new Uint8Array([1, 2, 3]),
contentType: "image/png",
}));
const saveMediaBuffer = vi.fn(async () => ({
const saveRemoteMedia = vi.fn(async () => ({
path: "/tmp/file.png",
contentType: "image/png",
}));
@@ -52,8 +48,7 @@ describe("mattermost monitor resources", () => {
} as never,
logger: {},
mediaMaxBytes: 1024,
fetchRemoteMedia,
saveMediaBuffer,
saveRemoteMedia,
mediaKindFromMime: () => "image",
});
@@ -65,7 +60,7 @@ describe("mattermost monitor resources", () => {
},
]);
expect(fetchRemoteMedia).toHaveBeenCalledWith({
expect(saveRemoteMedia).toHaveBeenCalledWith({
url: "https://chat.example.com/api/v4/files/file-1",
requestInit: {
headers: {
@@ -89,8 +84,7 @@ describe("mattermost monitor resources", () => {
client: {} as never,
logger: {},
mediaMaxBytes: 1024,
fetchRemoteMedia: vi.fn(),
saveMediaBuffer: vi.fn(),
saveRemoteMedia: vi.fn(),
mediaKindFromMime: () => "document",
});
@@ -135,8 +129,7 @@ describe("mattermost monitor resources", () => {
client,
logger: {},
mediaMaxBytes: 1024,
fetchRemoteMedia: vi.fn(),
saveMediaBuffer: vi.fn(),
saveRemoteMedia: vi.fn(),
mediaKindFromMime: () => "document",
});

View File

@@ -20,20 +20,13 @@ export type MattermostMediaInfo = {
const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
const USER_CACHE_TTL_MS = 10 * 60_000;
type FetchRemoteMedia = (params: {
type SaveRemoteMedia = (params: {
url: string;
requestInit?: RequestInit;
filePathHint?: string;
maxBytes: number;
ssrfPolicy?: { allowedHostnames?: string[] };
}) => Promise<{ buffer: Uint8Array; contentType?: string | null }>;
type SaveMediaBuffer = (
buffer: Uint8Array,
contentType: string | undefined,
direction: "inbound" | "outbound",
maxBytes: number,
) => Promise<{ path: string; contentType?: string | null }>;
}) => Promise<{ path: string; contentType?: string | null }>;
export function createMattermostMonitorResources(params: {
accountId: string;
@@ -41,8 +34,7 @@ export function createMattermostMonitorResources(params: {
client: MattermostClient;
logger: { debug?: (...args: unknown[]) => void };
mediaMaxBytes: number;
fetchRemoteMedia: FetchRemoteMedia;
saveMediaBuffer: SaveMediaBuffer;
saveRemoteMedia: SaveRemoteMedia;
mediaKindFromMime: (contentType?: string) => MattermostMediaKind | null | undefined;
}) {
const {
@@ -51,8 +43,7 @@ export function createMattermostMonitorResources(params: {
client,
logger,
mediaMaxBytes,
fetchRemoteMedia,
saveMediaBuffer,
saveRemoteMedia,
mediaKindFromMime,
} = params;
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
@@ -68,7 +59,7 @@ export function createMattermostMonitorResources(params: {
const out: MattermostMediaInfo[] = [];
for (const fileId of ids) {
try {
const fetched = await fetchRemoteMedia({
const saved = await saveRemoteMedia({
url: `${client.apiBaseUrl}/files/${fileId}`,
requestInit: {
headers: {
@@ -79,13 +70,7 @@ export function createMattermostMonitorResources(params: {
maxBytes: mediaMaxBytes,
ssrfPolicy: { allowedHostnames: [new URL(client.baseUrl).hostname] },
});
const saved = await saveMediaBuffer(
Buffer.from(fetched.buffer),
fetched.contentType ?? undefined,
"inbound",
mediaMaxBytes,
);
const contentType = saved.contentType ?? fetched.contentType ?? undefined;
const contentType = saved.contentType ?? undefined;
out.push({
path: saved.path,
contentType,

View File

@@ -236,7 +236,7 @@ function createRuntimeCore(cfg: OpenClawConfig) {
resolveRequireMention: () => false,
},
media: {
fetchRemoteMedia: vi.fn(),
readRemoteMediaBuffer: vi.fn(),
saveMediaBuffer: vi.fn(),
},
mentions: {

View File

@@ -823,9 +823,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
debug: (message) => logger.debug?.(String(message)),
},
mediaMaxBytes,
fetchRemoteMedia: (params) => core.channel.media.fetchRemoteMedia(params),
saveMediaBuffer: (buffer, contentType, direction, maxBytes) =>
core.channel.media.saveMediaBuffer(Buffer.from(buffer), contentType, direction, maxBytes),
saveRemoteMedia: (params) => core.channel.media.saveRemoteMedia(params),
mediaKindFromMime: (contentType) => core.media.mediaKindFromMime(contentType) as MediaKind,
});

View File

@@ -18,13 +18,21 @@ const CONTENT_TYPE_APPLICATION_PDF = "application/pdf";
const PNG_BUFFER = Buffer.from("png");
const detectMimeMock = vi.fn(async () => CONTENT_TYPE_IMAGE_PNG);
const saveMediaBufferMock = vi.fn(async () => ({
id: "saved.png",
path: "/tmp/saved.png",
size: Buffer.byteLength(PNG_BUFFER),
contentType: CONTENT_TYPE_IMAGE_PNG,
}));
const fetchRemoteMediaMock = vi.fn(
const saveMediaBufferMock = vi.fn(
async (
_buffer: Buffer,
contentType?: string,
_subdir?: string,
_maxBytes?: number,
_originalFilename?: string,
) => ({
id: "saved.png",
path: "/tmp/saved.png",
size: Buffer.byteLength(PNG_BUFFER),
contentType: contentType ?? CONTENT_TYPE_IMAGE_PNG,
}),
);
const readRemoteMediaBufferMock = vi.fn(
async (params: {
url: string;
maxBytes?: number;
@@ -36,6 +44,43 @@ const fetchRemoteMediaMock = vi.fn(
return readRemoteMediaResponse(res, params);
},
);
const saveRemoteMediaMock = vi.fn(
async (params: {
url: string;
maxBytes?: number;
filePathHint?: string;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}) => {
const fetched = await readRemoteMediaBufferMock(params);
return await saveMediaBufferMock(
fetched.buffer,
fetched.contentType,
"inbound",
params.maxBytes,
params.filePathHint,
);
},
);
const saveResponseMediaMock = vi.fn(
async (
res: Response,
options: {
maxBytes?: number;
fallbackContentType?: string;
subdir?: string;
originalFilename?: string;
},
) => {
const buffer = Buffer.from(await res.arrayBuffer());
return await saveMediaBufferMock(
buffer,
options.fallbackContentType,
options.subdir ?? "inbound",
options.maxBytes,
options.originalFilename,
);
},
);
const runtimeStub = {
media: {
@@ -43,7 +88,9 @@ const runtimeStub = {
},
channel: {
media: {
fetchRemoteMedia: fetchRemoteMediaMock,
readRemoteMediaBuffer: readRemoteMediaBufferMock,
saveRemoteMedia: saveRemoteMediaMock,
saveResponseMedia: saveResponseMediaMock,
saveMediaBuffer: saveMediaBufferMock,
},
},
@@ -222,6 +269,18 @@ const GRAPH_MEDIA_SUCCESS_CASES: GraphMediaSuccessCase[] = [
expectMediaBufferSaved();
},
}),
withLabel("streams hostedContent value responses through shared response saver", {
buildOptions: () => ({
hostedContents: [{ id: "hosted-1", contentType: CONTENT_TYPE_APPLICATION_PDF }],
onUnhandled: (url) =>
url.endsWith("/hostedContents/hosted-1/$value") ? createPdfResponse() : undefined,
}),
expectedLength: 1,
assert: () => {
expect(saveResponseMediaMock).toHaveBeenCalledTimes(1);
expectMediaBufferSaved();
},
}),
withLabel("merges SharePoint reference attachments with hosted content", {
buildOptions: () => {
return {
@@ -242,7 +301,9 @@ describe("msteams graph attachments", () => {
ssrfMock?.mockRestore();
ssrfMock = mockPinnedHostnameResolution();
detectMimeMock.mockClear();
fetchRemoteMediaMock.mockClear();
readRemoteMediaBufferMock.mockClear();
saveRemoteMediaMock.mockClear();
saveResponseMediaMock.mockClear();
saveMediaBufferMock.mockClear();
setMSTeamsRuntime(runtimeStub);
});

View File

@@ -5,6 +5,25 @@ import { downloadMSTeamsAttachments } from "./attachments/download.js";
import { resolveRequestUrl } from "./attachments/shared.js";
import { setMSTeamsRuntime } from "./runtime.js";
const saveResponseMediaMock = vi.hoisted(() =>
vi.fn(async (response: Response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const contentType = response.headers.get("content-type") ?? "image/png";
return {
id: contentType === "application/pdf" ? "saved.pdf" : "saved.png",
path: contentType === "application/pdf" ? "/tmp/saved.pdf" : "/tmp/saved.png",
size: 42,
contentType,
};
}),
);
vi.mock("openclaw/plugin-sdk/media-runtime", async () => ({
saveResponseMedia: saveResponseMediaMock,
}));
const GRAPH_HOST = "graph.microsoft.com";
const _SHAREPOINT_HOST = "contoso.sharepoint.com";
const AZUREEDGE_HOST = "azureedge.net";
@@ -41,12 +60,20 @@ type RemoteMediaFetchParams = {
};
const detectMimeMock = vi.fn(async () => CONTENT_TYPE_IMAGE_PNG);
const saveMediaBufferMock = vi.fn(async () => ({
id: "saved.png",
path: SAVED_PNG_PATH,
size: Buffer.byteLength(PNG_BUFFER),
contentType: CONTENT_TYPE_IMAGE_PNG,
}));
const saveMediaBufferMock = vi.fn(
async (
_buffer: Buffer,
contentType?: string,
_subdir?: string,
_maxBytes?: number,
_originalFilename?: string,
) => ({
id: "saved.png",
path: contentType === CONTENT_TYPE_APPLICATION_PDF ? SAVED_PDF_PATH : SAVED_PNG_PATH,
size: Buffer.byteLength(PNG_BUFFER),
contentType: contentType ?? CONTENT_TYPE_IMAGE_PNG,
}),
);
function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean {
if (pattern.startsWith("*.")) {
const suffix = pattern.slice(2);
@@ -65,7 +92,7 @@ function isUrlAllowedBySsrfPolicy(url: string, policy?: SsrFPolicy): boolean {
);
}
async function fetchRemoteMediaWithRedirects(
async function readRemoteMediaBufferWithRedirects(
params: RemoteMediaFetchParams,
requestInit?: RequestInit,
) {
@@ -89,8 +116,18 @@ async function fetchRemoteMediaWithRedirects(
throw new Error("too many redirects");
}
const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
return await fetchRemoteMediaWithRedirects(params);
const readRemoteMediaBufferMock = vi.fn(async (params: RemoteMediaFetchParams) => {
return await readRemoteMediaBufferWithRedirects(params);
});
const saveRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
const fetched = await readRemoteMediaBufferWithRedirects(params);
return await saveMediaBufferMock(
fetched.buffer,
fetched.contentType,
"inbound",
params.maxBytes,
params.filePathHint,
);
});
const runtimeStub = {
@@ -99,7 +136,9 @@ const runtimeStub = {
},
channel: {
media: {
fetchRemoteMedia: fetchRemoteMediaMock,
readRemoteMediaBuffer: readRemoteMediaBufferMock,
saveRemoteMedia: saveRemoteMediaMock,
saveResponseMedia: saveResponseMediaMock,
saveMediaBuffer: saveMediaBufferMock,
},
},
@@ -261,7 +300,9 @@ const expectSingleMedia = (media: DownloadedMedia, expected: DownloadedMediaExpe
expectFirstMedia(media, expected);
};
const expectMediaBufferSaved = () => {
expect(saveMediaBufferMock).toHaveBeenCalled();
expect(
saveResponseMediaMock.mock.calls.length + saveMediaBufferMock.mock.calls.length,
).toBeGreaterThan(0);
};
const expectFirstMedia = (media: DownloadedMedia, expected: DownloadedMediaExpectation) => {
const first = media[0];
@@ -383,7 +424,9 @@ describe("msteams attachments", () => {
beforeEach(() => {
detectMimeMock.mockClear();
saveMediaBufferMock.mockClear();
fetchRemoteMediaMock.mockClear();
readRemoteMediaBufferMock.mockClear();
saveRemoteMediaMock.mockClear();
saveResponseMediaMock.mockClear();
setMSTeamsRuntime(runtimeStub);
});
@@ -425,8 +468,8 @@ describe("msteams attachments", () => {
return createNotFoundResponse();
});
fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
return await fetchRemoteMediaWithRedirects(params, {
readRemoteMediaBufferMock.mockImplementationOnce(async (params) => {
return await readRemoteMediaBufferWithRedirects(params, {
dispatcher: {},
} as RequestInit);
});

View File

@@ -50,7 +50,30 @@ function installRuntime(): MockRuntime {
});
return { path: state.savePath, contentType: state.savedContentType };
},
fetchRemoteMedia: async () => ({ buffer: Buffer.alloc(0), contentType: undefined }),
readRemoteMediaBuffer: async () => ({ buffer: Buffer.alloc(0), contentType: undefined }),
saveRemoteMedia: async () => ({
path: state.savePath,
contentType: state.savedContentType,
}),
saveResponseMedia: async (
response: Response,
options: {
fallbackContentType?: string;
subdir?: string;
maxBytes?: number;
originalFilename?: string;
},
) => {
const buffer = Buffer.from(await response.arrayBuffer());
state.saveCalls.push({
buffer,
contentType: options.fallbackContentType,
direction: options.subdir ?? "inbound",
maxBytes: options.maxBytes ?? 0,
originalFilename: options.originalFilename,
});
return { path: state.savePath, contentType: state.savedContentType };
},
},
},
} as unknown as Parameters<typeof setMSTeamsRuntime>[0]);

View File

@@ -1,4 +1,3 @@
import { Buffer } from "node:buffer";
import { getMSTeamsRuntime } from "../runtime.js";
import { ensureUserAgentHeader } from "../user-agent.js";
import {
@@ -104,17 +103,20 @@ async function fetchBotFrameworkAttachmentInfo(params: {
}
}
async function fetchBotFrameworkAttachmentView(params: {
async function saveBotFrameworkAttachmentView(params: {
serviceUrl: string;
attachmentId: string;
viewId: string;
accessToken: string;
maxBytes: number;
fileNameHint?: string;
contentTypeHint?: string;
preserveFilenames?: boolean;
policy: MSTeamsAttachmentFetchPolicy;
fetchFn?: typeof fetch;
resolveFn?: MSTeamsAttachmentResolveFn;
logger?: MSTeamsAttachmentDownloadLogger;
}): Promise<Buffer | undefined> {
}): Promise<{ path: string; contentType?: string } | undefined> {
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}/views/${encodeURIComponent(params.viewId)}`;
// See `fetchBotFrameworkAttachmentInfo` for why this uses
// `safeFetchWithPolicy` instead of `fetchWithSsrFGuard` on Node 24+ (#63396).
@@ -146,14 +148,16 @@ async function fetchBotFrameworkAttachmentView(params: {
return undefined;
}
try {
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
if (buffer.byteLength > params.maxBytes) {
return undefined;
}
return buffer;
return await getMSTeamsRuntime().channel.media.saveResponseMedia(response, {
sourceUrl: url,
filePathHint: params.fileNameHint,
maxBytes: params.maxBytes,
fallbackContentType: params.contentTypeHint,
subdir: "inbound",
originalFilename: params.preserveFilenames ? params.fileNameHint : undefined,
});
} catch (err) {
params.logger?.warn?.("msteams botFramework attachmentView body read failed", {
params.logger?.warn?.("msteams botFramework attachmentView save failed", {
error: err instanceof Error ? err.message : String(err),
});
return undefined;
@@ -238,21 +242,6 @@ export async function downloadMSTeamsBotFrameworkAttachment(params: {
return undefined;
}
const buffer = await fetchBotFrameworkAttachmentView({
serviceUrl: params.serviceUrl,
attachmentId: params.attachmentId,
viewId,
accessToken,
maxBytes: params.maxBytes,
policy,
fetchFn: params.fetchFn,
resolveFn: params.resolveFn,
logger: params.logger,
});
if (!buffer) {
return undefined;
}
const fileNameHint =
(typeof params.fileNameHint === "string" && params.fileNameHint) ||
(typeof info.name === "string" && info.name) ||
@@ -262,32 +251,29 @@ export async function downloadMSTeamsBotFrameworkAttachment(params: {
(typeof info.type === "string" && info.type) ||
undefined;
const mime = await getMSTeamsRuntime().media.detectMime({
buffer,
headerMime: contentTypeHint,
filePath: fileNameHint,
const saved = await saveBotFrameworkAttachmentView({
serviceUrl: params.serviceUrl,
attachmentId: params.attachmentId,
viewId,
accessToken,
maxBytes: params.maxBytes,
fileNameHint,
contentTypeHint,
preserveFilenames: params.preserveFilenames,
policy,
fetchFn: params.fetchFn,
resolveFn: params.resolveFn,
logger: params.logger,
});
try {
const originalFilename = params.preserveFilenames ? fileNameHint : undefined;
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
buffer,
mime ?? contentTypeHint,
"inbound",
params.maxBytes,
originalFilename,
);
return {
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: fileNameHint }),
};
} catch (err) {
params.logger?.warn?.("msteams botFramework save failed", {
error: err instanceof Error ? err.message : String(err),
});
if (!saved) {
return undefined;
}
return {
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: fileNameHint }),
};
}
/**

View File

@@ -277,7 +277,7 @@ export async function downloadMSTeamsAttachments(params: {
preserveFilenames: params.preserveFilenames,
ssrfPolicy,
// `fetchImpl` below already validates each hop against the hostname
// allowlist via `safeFetchWithPolicy`, so skip `fetchRemoteMedia`'s
// allowlist via `safeFetchWithPolicy`, so skip `readRemoteMediaBuffer`'s
// strict SSRF dispatcher (incompatible with Node 24+ / undici v7;
// see issue #63396).
useDirectFetch: true,

View File

@@ -225,13 +225,17 @@ async function downloadGraphHostedContent(params: {
if (!valRes.ok) {
continue;
}
// Check Content-Length before buffering to avoid RSS spikes on large files.
const cl = valRes.headers.get("content-length");
if (cl && Number(cl) > params.maxBytes) {
continue;
}
const ab = await valRes.arrayBuffer();
buffer = Buffer.from(ab);
const saved = await getMSTeamsRuntime().channel.media.saveResponseMedia(valRes, {
sourceUrl: valueUrl,
maxBytes: params.maxBytes,
fallbackContentType: item.contentType ?? undefined,
subdir: "inbound",
});
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder({ contentType: saved.contentType }),
});
} finally {
await release();
}
@@ -241,6 +245,7 @@ async function downloadGraphHostedContent(params: {
});
continue;
}
continue;
} else {
continue;
}

View File

@@ -1,9 +1,24 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the runtime so we can assert whether the strict-dispatcher path
// (`fetchRemoteMedia`) was invoked versus the new direct-fetch path added
// (`saveRemoteMedia`) was invoked versus the new direct-fetch path added
// for issue #63396 (Node 24+ / undici v7 compat).
const runtimeFetchRemoteMediaMock = vi.fn();
const runtimeSaveRemoteMediaMock = vi.fn(
async (
_params: unknown,
): Promise<{
id: string;
path: string;
size: number;
contentType?: string;
fileName?: string;
}> => ({
id: "saved",
path: "/tmp/saved.png",
size: 42,
contentType: "image/png",
}),
);
const runtimeDetectMimeMock = vi.fn(async () => "image/png");
const runtimeSaveMediaBufferMock = vi.fn(async (_buf: Buffer, contentType?: string) => ({
id: "saved",
@@ -11,13 +26,35 @@ const runtimeSaveMediaBufferMock = vi.fn(async (_buf: Buffer, contentType?: stri
size: 42,
contentType: contentType ?? "image/png",
}));
const saveResponseMediaMock = vi.hoisted(() =>
vi.fn(async (response: Response, options: { maxBytes?: number }) => {
if (!response.ok) {
const statusText = response.statusText ? ` ${response.statusText}` : "";
throw new Error(`HTTP ${response.status}${statusText}`);
}
const contentLength = Number(response.headers.get("content-length"));
if (Number.isFinite(contentLength) && options.maxBytes && contentLength > options.maxBytes) {
throw new Error(`content length ${contentLength} exceeds maxBytes ${options.maxBytes}`);
}
return {
id: "saved",
path: "/tmp/saved.png",
size: 42,
contentType: response.headers.get("content-type") ?? "image/png",
};
}),
);
vi.mock("openclaw/plugin-sdk/media-runtime", async () => ({
saveResponseMedia: saveResponseMediaMock,
}));
vi.mock("../runtime.js", () => ({
getMSTeamsRuntime: () => ({
media: { detectMime: runtimeDetectMimeMock },
channel: {
media: {
fetchRemoteMedia: runtimeFetchRemoteMediaMock,
saveRemoteMedia: runtimeSaveRemoteMediaMock,
saveMediaBuffer: runtimeSaveMediaBufferMock,
},
},
@@ -42,13 +79,14 @@ function requireFirstFetchUrl(mock: ReturnType<typeof vi.fn>): unknown {
describe("downloadAndStoreMSTeamsRemoteMedia", () => {
beforeEach(() => {
runtimeFetchRemoteMediaMock.mockReset();
runtimeSaveRemoteMediaMock.mockClear();
saveResponseMediaMock.mockClear();
runtimeDetectMimeMock.mockClear();
runtimeSaveMediaBufferMock.mockClear();
});
describe("useDirectFetch: true (Node 24+ / undici v7 path for issue #63396)", () => {
it("bypasses fetchRemoteMedia and calls the supplied fetchImpl directly", async () => {
it("bypasses readRemoteMediaBuffer and calls the supplied fetchImpl directly", async () => {
// `fetchImpl` here simulates the "pre-validated hostname" contract from
// `safeFetchWithPolicy`: the caller has already enforced the allowlist,
// so the strict SSRF dispatcher is not needed.
@@ -67,7 +105,7 @@ describe("downloadAndStoreMSTeamsRemoteMedia", () => {
expect(fetchImpl).toHaveBeenCalledTimes(1);
const calledUrl = requireFirstFetchUrl(fetchImpl);
expect(calledUrl).toBe("https://graph.microsoft.com/v1.0/shares/abc/driveItem/content");
expect(runtimeFetchRemoteMediaMock).not.toHaveBeenCalled();
expect(runtimeSaveRemoteMediaMock).not.toHaveBeenCalled();
expect(result.path).toBe("/tmp/saved.png");
});
@@ -83,7 +121,7 @@ describe("downloadAndStoreMSTeamsRemoteMedia", () => {
fetchImpl,
}),
).rejects.toThrow(/HTTP 403/);
expect(runtimeFetchRemoteMediaMock).not.toHaveBeenCalled();
expect(runtimeSaveRemoteMediaMock).not.toHaveBeenCalled();
});
it("rejects a response whose Content-Length exceeds maxBytes", async () => {
@@ -103,14 +141,16 @@ describe("downloadAndStoreMSTeamsRemoteMedia", () => {
fetchImpl,
}),
).rejects.toThrow(/exceeds maxBytes/);
expect(runtimeFetchRemoteMediaMock).not.toHaveBeenCalled();
expect(runtimeSaveRemoteMediaMock).not.toHaveBeenCalled();
});
it("falls back to the runtime fetchRemoteMedia path when useDirectFetch is omitted", async () => {
it("falls back to the runtime saveRemoteMedia path when useDirectFetch is omitted", async () => {
// Non-SharePoint caller, no pre-validated fetchImpl: make sure the strict
// SSRF dispatcher path is still used.
runtimeFetchRemoteMediaMock.mockResolvedValueOnce({
buffer: PNG_BYTES,
runtimeSaveRemoteMediaMock.mockResolvedValueOnce({
id: "saved",
path: "/tmp/saved.png",
size: 42,
contentType: "image/png",
fileName: "file.png",
});
@@ -121,12 +161,14 @@ describe("downloadAndStoreMSTeamsRemoteMedia", () => {
maxBytes: 1024,
});
expect(runtimeFetchRemoteMediaMock).toHaveBeenCalledTimes(1);
expect(runtimeSaveRemoteMediaMock).toHaveBeenCalledTimes(1);
});
it("does not use the direct path when useDirectFetch is true but fetchImpl is missing", async () => {
runtimeFetchRemoteMediaMock.mockResolvedValueOnce({
buffer: PNG_BYTES,
runtimeSaveRemoteMediaMock.mockResolvedValueOnce({
id: "saved",
path: "/tmp/saved.png",
size: 42,
contentType: "image/png",
});
@@ -139,7 +181,7 @@ describe("downloadAndStoreMSTeamsRemoteMedia", () => {
// Without a fetchImpl to delegate to, we must fall back to the runtime
// path rather than crashing.
expect(runtimeFetchRemoteMediaMock).toHaveBeenCalledTimes(1);
expect(runtimeSaveRemoteMediaMock).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,4 +1,4 @@
import { readResponseWithLimit } from "openclaw/plugin-sdk/media-runtime";
import { saveResponseMedia, type SavedRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
import type { SsrFPolicy } from "../../runtime-api.js";
import { getMSTeamsRuntime } from "../runtime.js";
import { inferPlaceholder } from "./shared.js";
@@ -6,17 +6,12 @@ import type { MSTeamsInboundMedia } from "./types.js";
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
type FetchedRemoteMedia = {
buffer: Buffer;
contentType?: string;
};
/**
* Direct fetch path used when the caller's `fetchImpl` has already validated
* the URL against a hostname allowlist (for example `safeFetchWithPolicy`).
*
* Bypasses the strict SSRF dispatcher on `fetchRemoteMedia` because:
* 1. The pinned undici dispatcher used by `fetchRemoteMedia` is incompatible
* Bypasses the strict SSRF dispatcher on `readRemoteMediaBuffer` because:
* 1. The pinned undici dispatcher used by `readRemoteMediaBuffer` is incompatible
* with Node 24+'s built-in undici v7 (fails with "invalid onRequestStart
* method"), which silently breaks SharePoint/OneDrive downloads. See
* issue #63396.
@@ -24,36 +19,22 @@ type FetchedRemoteMedia = {
* (`safeFetch` validates every redirect hop against the hostname
* allowlist before following).
*/
async function fetchRemoteMediaDirect(params: {
async function saveRemoteMediaDirect(params: {
url: string;
filePathHint: string;
fetchImpl: FetchLike;
maxBytes: number;
}): Promise<FetchedRemoteMedia> {
contentTypeHint?: string;
originalFilename?: string;
}): Promise<SavedRemoteMedia> {
const response = await params.fetchImpl(params.url, { redirect: "follow" });
if (!response.ok) {
const statusText = response.statusText ? ` ${response.statusText}` : "";
throw new Error(`HTTP ${response.status}${statusText}`);
}
// Enforce the max-bytes cap before buffering the full body so a rogue
// response cannot drive RSS usage past the configured limit.
const contentLength = response.headers.get("content-length");
if (contentLength) {
const length = Number(contentLength);
if (Number.isFinite(length) && length > params.maxBytes) {
throw new Error(`content length ${length} exceeds maxBytes ${params.maxBytes}`);
}
}
const buffer = await readResponseWithLimit(response, params.maxBytes, {
onOverflow: ({ size, maxBytes }) =>
new Error(`payload size ${size} exceeds maxBytes ${maxBytes}`),
return await saveResponseMedia(response, {
sourceUrl: params.url,
filePathHint: params.filePathHint,
maxBytes: params.maxBytes,
fallbackContentType: params.contentTypeHint,
originalFilename: params.originalFilename,
});
return {
buffer,
contentType: response.headers.get("content-type") ?? undefined,
};
}
export async function downloadAndStoreMSTeamsRemoteMedia(params: {
@@ -66,42 +47,35 @@ export async function downloadAndStoreMSTeamsRemoteMedia(params: {
placeholder?: string;
preserveFilenames?: boolean;
/**
* Opt into a direct fetch path that bypasses `fetchRemoteMedia`'s strict
* Opt into a direct fetch path that bypasses `readRemoteMediaBuffer`'s strict
* SSRF dispatcher. Required for SharePoint/OneDrive downloads on Node 24+
* (see issue #63396). Only safe when the supplied `fetchImpl` has already
* validated the URL against a hostname allowlist.
*/
useDirectFetch?: boolean;
}): Promise<MSTeamsInboundMedia> {
let fetched: FetchedRemoteMedia;
const originalFilename = params.preserveFilenames ? params.filePathHint : undefined;
let saved: SavedRemoteMedia;
if (params.useDirectFetch && params.fetchImpl) {
fetched = await fetchRemoteMediaDirect({
saved = await saveRemoteMediaDirect({
url: params.url,
filePathHint: params.filePathHint,
fetchImpl: params.fetchImpl,
maxBytes: params.maxBytes,
contentTypeHint: params.contentTypeHint,
originalFilename,
});
} else {
fetched = await getMSTeamsRuntime().channel.media.fetchRemoteMedia({
saved = await getMSTeamsRuntime().channel.media.saveRemoteMedia({
url: params.url,
fetchImpl: params.fetchImpl,
filePathHint: params.filePathHint,
maxBytes: params.maxBytes,
ssrfPolicy: params.ssrfPolicy,
fallbackContentType: params.contentTypeHint,
originalFilename,
});
}
const mime = await getMSTeamsRuntime().media.detectMime({
buffer: fetched.buffer,
headerMime: fetched.contentType ?? params.contentTypeHint,
filePath: params.filePathHint,
});
const originalFilename = params.preserveFilenames ? params.filePathHint : undefined;
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
fetched.buffer,
mime ?? params.contentTypeHint,
"inbound",
params.maxBytes,
originalFilename,
);
return {
path: saved.path,
contentType: saved.contentType,

View File

@@ -41,7 +41,7 @@ import { getBridgeLogger } from "./logger.js";
function createBuiltinAdapter(): PlatformAdapter {
return {
async validateRemoteUrl(_url: string, _options?: { allowPrivate?: boolean }): Promise<void> {
// Built-in version delegates SSRF validation to fetchRemoteMedia's ssrfPolicy.
// Built-in version delegates SSRF validation to readRemoteMediaBuffer's ssrfPolicy.
},
async resolveSecret(value): Promise<string | undefined> {
@@ -52,8 +52,8 @@ function createBuiltinAdapter(): PlatformAdapter {
},
async downloadFile(url: string, destDir: string, filename?: string): Promise<string> {
const { fetchRemoteMedia } = await import("openclaw/plugin-sdk/media-runtime");
const result = await fetchRemoteMedia({ url, filePathHint: filename });
const { readRemoteMediaBuffer } = await import("openclaw/plugin-sdk/media-runtime");
const result = await readRemoteMediaBuffer({ url, filePathHint: filename });
const fs = await import("node:fs");
const path = await import("node:path");
if (!fs.existsSync(destDir)) {
@@ -65,8 +65,8 @@ function createBuiltinAdapter(): PlatformAdapter {
},
async fetchMedia(options: FetchMediaOptions): Promise<FetchMediaResult> {
const { fetchRemoteMedia } = await import("openclaw/plugin-sdk/media-runtime");
const result = await fetchRemoteMedia({
const { readRemoteMediaBuffer } = await import("openclaw/plugin-sdk/media-runtime");
const result = await readRemoteMediaBuffer({
url: options.url,
filePathHint: options.filePathHint,
maxBytes: options.maxBytes,

View File

@@ -156,7 +156,7 @@ const IMAGE_PROBE_SSRF_POLICY: SsrfPolicyConfig = {};
/**
* Fetch image dimensions from a public URL using only the first 64 KB.
*
* Uses {@link fetchRemoteMedia} with SSRF guard to block probes against
* Uses {@link readRemoteMediaBuffer} with SSRF guard to block probes against
* private/reserved/loopback/link-local/metadata destinations.
*/
export async function getImageSizeFromUrl(

View File

@@ -10,6 +10,7 @@ import fs from "node:fs/promises";
import nodePath from "node:path";
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
import { detectMime } from "openclaw/plugin-sdk/media-runtime";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import WebSocket from "ws";
export type ContainerRpcOptions = {
@@ -105,36 +106,9 @@ async function readCappedResponseBuffer(res: Response, maxResponseBytes: number)
if (contentLength !== undefined && contentLength > maxResponseBytes) {
throw new Error("Signal REST attachment exceeded size limit");
}
const reader = res.body?.getReader();
if (!reader) {
const arrayBuffer = await res.arrayBuffer();
if (arrayBuffer.byteLength > maxResponseBytes) {
throw new Error("Signal REST attachment exceeded size limit");
}
return Buffer.from(arrayBuffer);
}
const chunks: Buffer[] = [];
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = Buffer.from(value ?? new Uint8Array());
totalBytes += chunk.byteLength;
if (totalBytes > maxResponseBytes) {
await reader.cancel().catch(() => {});
throw new Error("Signal REST attachment exceeded size limit");
}
chunks.push(chunk);
}
} finally {
reader.releaseLock();
}
return Buffer.concat(chunks);
return await readResponseWithLimit(res, maxResponseBytes, {
onOverflow: () => new Error("Signal REST attachment exceeded size limit"),
});
}
/**

View File

@@ -1,4 +1,8 @@
export { fetchWithRuntimeDispatcher } from "openclaw/plugin-sdk/runtime-fetch";
export type { FetchLike, SavedMedia } from "openclaw/plugin-sdk/media-runtime";
export { fetchRemoteMedia, saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
export {
readRemoteMediaBuffer,
saveMediaBuffer,
saveRemoteMedia,
} from "openclaw/plugin-sdk/media-runtime";
export { logVerbose } from "openclaw/plugin-sdk/runtime-env";

View File

@@ -25,7 +25,7 @@ function expectSlackMediaResult(
return result;
}
const fetchRemoteMediaMock = vi.hoisted(() =>
const readRemoteMediaBufferMock = vi.hoisted(() =>
vi.fn(
async (params: {
url: string;
@@ -65,21 +65,46 @@ const fetchRemoteMediaMock = vi.hoisted(() =>
),
);
const saveMediaBufferMock = vi.hoisted(() =>
vi.fn(async (_buffer: Buffer, contentType?: string) => ({
id: "saved-media-id",
path: "/tmp/test.bin",
size: _buffer.byteLength,
contentType,
})),
vi.fn(
async (
_buffer: Buffer,
contentType?: string,
_subdir?: string,
_maxBytes?: number,
_originalFilename?: string,
) => ({
id: "saved-media-id",
path: "/tmp/test.bin",
size: _buffer.byteLength,
contentType,
}),
),
);
const saveRemoteMediaMock = vi.hoisted(() =>
vi.fn(async (params: Parameters<typeof readRemoteMediaBufferMock>[0]) => {
const fetched = await readRemoteMediaBufferMock(params);
const saved = await saveMediaBufferMock(
fetched.buffer,
fetched.contentType,
"inbound",
params.maxBytes,
params.filePathHint,
);
return {
...saved,
fileName: fetched.fileName,
};
}),
);
const fetchWithRuntimeDispatcherMock = vi.hoisted(() => vi.fn());
const logVerboseMock = vi.hoisted(() => vi.fn());
vi.mock("./media.runtime.js", () => ({
fetchRemoteMedia: fetchRemoteMediaMock,
readRemoteMediaBuffer: readRemoteMediaBufferMock,
fetchWithRuntimeDispatcher: fetchWithRuntimeDispatcherMock,
logVerbose: logVerboseMock,
saveMediaBuffer: saveMediaBufferMock,
saveRemoteMedia: saveRemoteMediaMock,
}));
vi.mock("./thread.runtime.js", () => ({
@@ -98,10 +123,41 @@ const originalFetch = globalThis.fetch;
let mockFetch: ReturnType<typeof vi.fn<FetchMock>>;
beforeEach(() => {
fetchRemoteMediaMock.mockClear();
readRemoteMediaBufferMock.mockClear();
fetchWithRuntimeDispatcherMock.mockClear();
logVerboseMock.mockClear();
saveMediaBufferMock.mockClear();
saveMediaBufferMock.mockReset();
saveMediaBufferMock.mockImplementation(
async (
_buffer: Buffer,
contentType?: string,
_subdir?: string,
_maxBytes?: number,
_originalFilename?: string,
) => ({
id: "saved-media-id",
path: "/tmp/test.bin",
size: _buffer.byteLength,
contentType,
}),
);
saveRemoteMediaMock.mockReset();
saveRemoteMediaMock.mockImplementation(
async (params: Parameters<typeof readRemoteMediaBufferMock>[0]) => {
const fetched = await readRemoteMediaBufferMock(params);
const saved = await saveMediaBufferMock(
fetched.buffer,
fetched.contentType,
"inbound",
params.maxBytes,
params.filePathHint,
);
return {
...saved,
fileName: fetched.fileName,
};
},
);
});
const createSavedMedia = (filePath: string, contentType: string): SavedMedia => ({
@@ -422,8 +478,8 @@ describe("resolveSlackMedia", () => {
expectSlackMediaResult(result);
const fetchOptions = requireRecord(
requireMockCall(fetchRemoteMediaMock, 0, "fetchRemoteMedia")[0],
"fetchRemoteMedia options",
requireMockCall(readRemoteMediaBufferMock, 0, "readRemoteMediaBuffer")[0],
"readRemoteMediaBuffer options",
) as { readIdleTimeoutMs?: number; requestInit?: RequestInit };
expect(fetchOptions.readIdleTimeoutMs).toBe(SLACK_MEDIA_READ_IDLE_TIMEOUT_MS);
expect(fetchOptions.requestInit?.signal).toBeInstanceOf(AbortSignal);
@@ -436,7 +492,7 @@ describe("resolveSlackMedia", () => {
vi.useFakeTimers();
try {
let abortSignal: AbortSignal | undefined;
fetchRemoteMediaMock.mockImplementationOnce(
readRemoteMediaBufferMock.mockImplementationOnce(
(params) =>
new Promise<never>((_resolve, reject) => {
abortSignal = params.requestInit?.signal ?? undefined;
@@ -600,7 +656,6 @@ describe("resolveSlackMedia", () => {
});
it("rejects HTML auth pages for non-HTML files", async () => {
const saveMediaBufferMock = vi.spyOn(mediaRuntime, "saveMediaBuffer");
mockFetch.mockResolvedValueOnce(
new Response("<!DOCTYPE html><html><body>login</body></html>", {
status: 200,
@@ -615,7 +670,7 @@ describe("resolveSlackMedia", () => {
});
expect(result).toBeNull();
expect(saveMediaBufferMock).not.toHaveBeenCalled();
expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1);
});
it("allows expected HTML uploads", async () => {
@@ -649,9 +704,13 @@ describe("resolveSlackMedia", () => {
// saveMediaBuffer re-detects MIME from buffer bytes, so it may return
// video/mp4 for MP4 containers. Verify resolveSlackMedia preserves
// the overridden audio/* type in its return value despite this.
const saveMediaBufferMock = vi
.spyOn(mediaRuntime, "saveMediaBuffer")
.mockResolvedValue(createSavedMedia("/tmp/voice.mp4", "video/mp4"));
saveRemoteMediaMock.mockResolvedValueOnce({
id: "saved-media-id",
path: "/tmp/voice.mp4",
size: 128,
contentType: "video/mp4",
fileName: "voice.mp4",
});
const mockResponse = new Response(Buffer.from("audio data"), {
status: 200,
@@ -674,10 +733,13 @@ describe("resolveSlackMedia", () => {
const media = expectSlackMediaResult(result);
expect(media).toHaveLength(1);
// saveMediaBuffer should receive the overridden audio/mp4
expectSaveMediaBufferCall(saveMediaBufferMock, "audio/mp4", 16 * 1024 * 1024);
expect(
requireRecord(requireMockCall(saveRemoteMediaMock, 0, "saveRemoteMedia")[0], "save params"),
).toMatchObject({
fallbackContentType: "audio/mp4",
});
// Returned contentType must be the overridden value, not the
// re-detected video/mp4 from saveMediaBuffer
// re-detected video/mp4 from the saved file
expect(media[0]?.contentType).toBe("audio/mp4");
});
@@ -873,7 +935,7 @@ describe("Slack media SSRF policy", () => {
new Response(Buffer.from("img"), { status: 200, headers: { "content-type": "image/jpeg" } }),
);
const spy = vi.spyOn(mediaRuntime, "fetchRemoteMedia");
const spy = vi.spyOn(mediaRuntime, "readRemoteMediaBuffer");
await resolveSlackMedia({
files: [{ url_private: "https://files.slack.com/test.jpg", name: "test.jpg" }],
@@ -882,8 +944,10 @@ describe("Slack media SSRF policy", () => {
});
const policy = requireRecord(
requireRecord(requireMockCall(spy, 0, "fetchRemoteMedia")[0], "fetchRemoteMedia params")
.ssrfPolicy,
requireRecord(
requireMockCall(spy, 0, "readRemoteMediaBuffer")[0],
"readRemoteMediaBuffer params",
).ssrfPolicy,
"ssrfPolicy",
);
expect(policy.allowRfc2544BenchmarkRange).toBe(true);
@@ -901,7 +965,7 @@ describe("Slack media SSRF policy", () => {
new Response(Buffer.from("fwd"), { status: 200, headers: { "content-type": "image/jpeg" } }),
);
const spy = vi.spyOn(mediaRuntime, "fetchRemoteMedia");
const spy = vi.spyOn(mediaRuntime, "readRemoteMediaBuffer");
await resolveSlackAttachmentContent({
attachments: [{ is_share: true, image_url: "https://files.slack.com/forwarded.jpg" }],
@@ -910,8 +974,10 @@ describe("Slack media SSRF policy", () => {
});
const policy = requireRecord(
requireRecord(requireMockCall(spy, 0, "fetchRemoteMedia")[0], "fetchRemoteMedia params")
.ssrfPolicy,
requireRecord(
requireMockCall(spy, 0, "readRemoteMediaBuffer")[0],
"readRemoteMediaBuffer params",
).ssrfPolicy,
"ssrfPolicy",
);
expect(policy.allowRfc2544BenchmarkRange).toBe(true);

View File

@@ -1,3 +1,4 @@
import fs from "node:fs/promises";
import type { WebClient as SlackWebClient } from "@slack/web-api";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeHostname } from "openclaw/plugin-sdk/host-runtime";
@@ -10,12 +11,7 @@ import { formatSlackFileReference } from "../file-reference.js";
import type { SlackAttachment, SlackFile } from "../types.js";
export { MAX_SLACK_MEDIA_FILES, type SlackMediaResult } from "./media-types.js";
import { MAX_SLACK_MEDIA_FILES, type SlackMediaResult } from "./media-types.js";
import {
type FetchLike,
fetchRemoteMedia,
fetchWithRuntimeDispatcher,
saveMediaBuffer,
} from "./media.runtime.js";
import { type FetchLike, fetchWithRuntimeDispatcher, saveRemoteMedia } from "./media.runtime.js";
import { logVerbose } from "./thread.runtime.js";
export {
resetSlackThreadStarterCacheForTest,
@@ -146,7 +142,7 @@ const SLACK_MEDIA_SSRF_POLICY = {
};
export const SLACK_MEDIA_READ_IDLE_TIMEOUT_MS = 60_000;
export const SLACK_MEDIA_TOTAL_TIMEOUT_MS = 120_000;
type SlackFetchRemoteMediaOptions = Parameters<typeof fetchRemoteMedia>[0];
type SlackSaveRemoteMediaOptions = Parameters<typeof saveRemoteMedia>[0];
function mergeAbortSignals(signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal));
@@ -178,12 +174,12 @@ function mergeAbortSignals(signals: Array<AbortSignal | undefined>): AbortSignal
return controller.signal;
}
async function fetchSlackMedia(params: {
options: SlackFetchRemoteMediaOptions;
async function saveSlackMedia(params: {
options: SlackSaveRemoteMediaOptions;
readIdleTimeoutMs?: number;
totalTimeoutMs?: number;
abortSignal?: AbortSignal;
}): ReturnType<typeof fetchRemoteMedia> {
}): ReturnType<typeof saveRemoteMedia> {
const timeoutAbortController = params.totalTimeoutMs ? new AbortController() : undefined;
const signal = mergeAbortSignals([
params.abortSignal,
@@ -193,7 +189,7 @@ async function fetchSlackMedia(params: {
let timedOut = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const fetchPromise = fetchRemoteMedia({
const savePromise = saveRemoteMedia({
...params.options,
readIdleTimeoutMs: params.readIdleTimeoutMs ?? SLACK_MEDIA_READ_IDLE_TIMEOUT_MS,
...(signal
@@ -213,7 +209,7 @@ async function fetchSlackMedia(params: {
try {
if (!params.totalTimeoutMs) {
return await fetchPromise;
return await savePromise;
}
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
@@ -223,7 +219,7 @@ async function fetchSlackMedia(params: {
}, params.totalTimeoutMs);
timeoutHandle.unref?.();
});
return await Promise.race([fetchPromise, timeoutPromise]);
return await Promise.race([savePromise, timeoutPromise]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
@@ -255,6 +251,20 @@ function looksLikeHtmlBuffer(buffer: Buffer): boolean {
return head.startsWith("<!doctype html") || head.startsWith("<html");
}
async function looksLikeHtmlFile(filePath: string): Promise<boolean> {
const handle = await fs.open(filePath, "r").catch(() => null);
if (!handle) {
return false;
}
try {
const buffer = Buffer.alloc(512);
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, 0);
return looksLikeHtmlBuffer(buffer.subarray(0, bytesRead));
} finally {
await handle.close().catch(() => undefined);
}
}
const MAX_SLACK_MEDIA_CONCURRENCY = 3;
const MAX_SLACK_FORWARDED_ATTACHMENTS = 8;
@@ -294,12 +304,13 @@ async function downloadSlackMediaFile(params: {
}): Promise<SlackMediaResult | null> {
const { url: slackUrl, requestInit } = createSlackMediaRequest(params.url, params.token);
const fetchImpl = createSlackMediaFetch();
const fetched = await fetchSlackMedia({
const saved = await saveSlackMedia({
options: {
url: slackUrl,
fetchImpl,
requestInit,
filePathHint: params.file.name,
fallbackContentType: resolveSlackMediaMimetype(params.file, params.file.mimetype),
maxBytes: params.maxBytes,
ssrfPolicy: SLACK_MEDIA_SSRF_POLICY,
},
@@ -307,9 +318,6 @@ async function downloadSlackMediaFile(params: {
totalTimeoutMs: params.totalTimeoutMs ?? SLACK_MEDIA_TOTAL_TIMEOUT_MS,
abortSignal: params.abortSignal,
});
if (fetched.buffer.byteLength > params.maxBytes) {
return null;
}
// Guard against auth/login HTML pages returned instead of binary media.
// Allow user-provided HTML files through.
@@ -318,15 +326,15 @@ async function downloadSlackMediaFile(params: {
const isExpectedHtml =
fileMime === "text/html" || fileName.endsWith(".html") || fileName.endsWith(".htm");
if (!isExpectedHtml) {
const detectedMime = normalizeOptionalLowercaseString(fetched.contentType?.split(";")[0]);
if (detectedMime === "text/html" || looksLikeHtmlBuffer(fetched.buffer)) {
const detectedMime = normalizeOptionalLowercaseString(saved.contentType?.split(";")[0]);
if (detectedMime === "text/html" || (await looksLikeHtmlFile(saved.path))) {
await fs.rm(saved.path, { force: true }).catch(() => undefined);
return null;
}
}
const effectiveMime = resolveSlackMediaMimetype(params.file, fetched.contentType);
const saved = await saveMediaBuffer(fetched.buffer, effectiveMime, "inbound", params.maxBytes);
const label = fetched.fileName ?? params.file.name;
const effectiveMime = resolveSlackMediaMimetype(params.file, saved.contentType);
const label = saved.fileName ?? params.file.name;
const contentType = effectiveMime ?? saved.contentType;
return {
path: saved.path,
@@ -479,7 +487,7 @@ export async function resolveSlackAttachmentContent(params: {
try {
const { url: slackUrl, requestInit } = createSlackMediaRequest(imageUrl, params.token);
const fetchImpl = createSlackMediaFetch();
const fetched = await fetchSlackMedia({
const saved = await saveSlackMedia({
options: {
url: slackUrl,
fetchImpl,
@@ -491,20 +499,12 @@ export async function resolveSlackAttachmentContent(params: {
totalTimeoutMs: params.totalTimeoutMs ?? SLACK_MEDIA_TOTAL_TIMEOUT_MS,
abortSignal: params.abortSignal,
});
if (fetched.buffer.byteLength <= params.maxBytes) {
const saved = await saveMediaBuffer(
fetched.buffer,
fetched.contentType,
"inbound",
params.maxBytes,
);
const label = fetched.fileName ?? "forwarded image";
allMedia.push({
path: saved.path,
contentType: fetched.contentType ?? saved.contentType,
placeholder: `[Forwarded image: ${label}]`,
});
}
const label = saved.fileName ?? "forwarded image";
allMedia.push({
path: saved.path,
contentType: saved.contentType,
placeholder: `[Forwarded image: ${label}]`,
});
} catch {
// Skip images that fail to download
}

View File

@@ -11,7 +11,8 @@ type TelegramBotRuntimeForTest = NonNullable<
type DispatchReplyWithBufferedBlockDispatcherFn =
typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithBufferedBlockDispatcher;
type DispatchReplyHarnessParams = Parameters<DispatchReplyWithBufferedBlockDispatcherFn>[0];
type FetchRemoteMediaFn = typeof import("openclaw/plugin-sdk/media-runtime").fetchRemoteMedia;
type ReadRemoteMediaBufferFn =
typeof import("openclaw/plugin-sdk/media-runtime").readRemoteMediaBuffer;
const useSpy: Mock = vi.fn();
const middlewareUseSpy: Mock = vi.fn();
@@ -30,9 +31,9 @@ function resetUndiciFetchMock() {
undiciFetchSpy.mockImplementation(defaultUndiciFetch);
}
async function defaultFetchRemoteMedia(
params: Parameters<FetchRemoteMediaFn>[0],
): ReturnType<FetchRemoteMediaFn> {
async function defaultReadRemoteMediaBuffer(
params: Parameters<ReadRemoteMediaBufferFn>[0],
): ReturnType<ReadRemoteMediaBufferFn> {
if (!params.fetchImpl) {
throw new Error(`Missing fetchImpl for ${params.url}`);
}
@@ -47,14 +48,14 @@ async function defaultFetchRemoteMedia(
buffer: Buffer.from(arrayBuffer),
contentType: response.headers.get("content-type") ?? undefined,
fileName: params.filePathHint ? path.basename(params.filePathHint) : undefined,
} as Awaited<ReturnType<FetchRemoteMediaFn>>;
} as Awaited<ReturnType<ReadRemoteMediaBufferFn>>;
}
export const fetchRemoteMediaSpy: Mock = vi.fn(defaultFetchRemoteMedia);
export const readRemoteMediaBufferSpy: Mock = vi.fn(defaultReadRemoteMediaBuffer);
export function resetFetchRemoteMediaMock() {
fetchRemoteMediaSpy.mockReset();
fetchRemoteMediaSpy.mockImplementation(defaultFetchRemoteMedia);
export function resetReadRemoteMediaBufferMock() {
readRemoteMediaBufferSpy.mockReset();
readRemoteMediaBufferSpy.mockImplementation(defaultReadRemoteMediaBuffer);
}
async function defaultSaveMediaBuffer(buffer: Buffer, contentType?: string) {
@@ -173,7 +174,7 @@ beforeEach(() => {
resetInboundDedupe();
resetSaveMediaBufferMock();
resetUndiciFetchMock();
resetFetchRemoteMediaMock();
resetReadRemoteMediaBufferMock();
});
vi.doMock("./bot.runtime.js", () => ({
@@ -198,10 +199,24 @@ vi.mock("undici", () => ({
}));
vi.mock("./telegram-media.runtime.js", () => ({
fetchRemoteMedia: (...args: Parameters<typeof fetchRemoteMediaSpy>) =>
fetchRemoteMediaSpy(...args),
readRemoteMediaBuffer: (...args: Parameters<typeof readRemoteMediaBufferSpy>) =>
readRemoteMediaBufferSpy(...args),
getAgentScopedMediaLocalRoots: vi.fn(() => []),
saveMediaBuffer: (...args: Parameters<typeof saveMediaBufferSpy>) => saveMediaBufferSpy(...args),
saveRemoteMedia: async (...args: Parameters<typeof readRemoteMediaBufferSpy>) => {
const fetched = (await readRemoteMediaBufferSpy(...args)) as {
buffer: Buffer;
contentType?: string;
fileName?: string;
};
return await saveMediaBufferSpy(
fetched.buffer,
fetched.contentType,
"inbound",
args[0]?.maxBytes,
args[0]?.originalFilename ?? fetched.fileName ?? args[0]?.filePathHint,
);
},
}));
vi.doMock("./bot-message-context.session.runtime.js", async () => {

View File

@@ -21,16 +21,16 @@ let createTelegramBotRef: typeof import("./bot.js").createTelegramBot;
let replySpyRef: ReturnType<typeof vi.fn>;
let onSpyRef: Mock;
let sendChatActionSpyRef: Mock;
let fetchRemoteMediaSpyRef: Mock;
let readRemoteMediaBufferSpyRef: Mock;
let undiciFetchSpyRef: Mock;
let resetFetchRemoteMediaMockRef: () => void;
let resetReadRemoteMediaBufferMockRef: () => void;
type FetchMockHandle = Mock & { mockRestore: () => void };
function createFetchMockHandle(): FetchMockHandle {
return Object.assign(fetchRemoteMediaSpyRef, {
return Object.assign(readRemoteMediaBufferSpyRef, {
mockRestore: () => {
resetFetchRemoteMediaMockRef();
resetReadRemoteMediaBufferMockRef();
},
}) as FetchMockHandle;
}
@@ -88,7 +88,7 @@ export function mockTelegramFileDownload(params: {
headers: { "content-type": params.contentType },
}),
);
fetchRemoteMediaSpyRef.mockResolvedValueOnce({
readRemoteMediaBufferSpyRef.mockResolvedValueOnce({
buffer: Buffer.from(params.bytes),
contentType: params.contentType,
fileName: "mock-file",
@@ -103,7 +103,7 @@ export function mockTelegramPngDownload(): FetchMockHandle {
headers: { "content-type": "image/png" },
}),
);
fetchRemoteMediaSpyRef.mockResolvedValue({
readRemoteMediaBufferSpyRef.mockResolvedValue({
buffer: Buffer.from(new Uint8Array([0x89, 0x50, 0x4e, 0x47])),
contentType: "image/png",
fileName: "mock-file.png",
@@ -118,9 +118,9 @@ export function watchTelegramFetch(): FetchMockHandle {
async function loadTelegramBotHarness() {
onSpyRef = harness.onSpy;
sendChatActionSpyRef = harness.sendChatActionSpy;
fetchRemoteMediaSpyRef = harness.fetchRemoteMediaSpy;
readRemoteMediaBufferSpyRef = harness.readRemoteMediaBufferSpy;
undiciFetchSpyRef = harness.undiciFetchSpy;
resetFetchRemoteMediaMockRef = harness.resetFetchRemoteMediaMock;
resetReadRemoteMediaBufferMockRef = harness.resetReadRemoteMediaBufferMock;
const botModule = await import("./bot.js");
botModule.setTelegramBotRuntimeForTest(
harness.telegramBotRuntimeForTest as unknown as Parameters<

View File

@@ -5,7 +5,27 @@ import { resolveMedia } from "./delivery.resolve-media.js";
import type { TelegramContext } from "./types.js";
const saveMediaBuffer = vi.fn();
const fetchRemoteMedia = vi.fn();
const readRemoteMediaBuffer = vi.fn();
const saveRemoteMedia = vi.fn(async (...args: unknown[]) => {
const fetched = (await readRemoteMediaBuffer(...args)) as {
buffer: Buffer;
contentType?: string;
fileName?: string;
};
return await saveMediaBuffer(
fetched.buffer,
fetched.contentType,
"inbound",
args[0] && typeof args[0] === "object"
? (args[0] as { maxBytes?: unknown }).maxBytes
: undefined,
args[0] && typeof args[0] === "object"
? ((args[0] as { originalFilename?: unknown }).originalFilename ??
fetched.fileName ??
(args[0] as { filePathHint?: unknown }).filePathHint)
: undefined,
);
});
const rootRead = vi.fn();
vi.mock("openclaw/plugin-sdk/file-access-runtime", () => ({
@@ -30,7 +50,7 @@ vi.mock("./delivery.resolve-media.runtime.js", () => {
}
}
return {
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args),
readRemoteMediaBuffer: (...args: unknown[]) => readRemoteMediaBuffer(...args),
formatErrorMessage: (err: unknown) => (err instanceof Error ? err.message : String(err)),
logVerbose: () => {},
MediaFetchError,
@@ -38,6 +58,7 @@ vi.mock("./delivery.resolve-media.runtime.js", () => {
apiRoot?.trim() ? apiRoot.replace(/\/+$/u, "") : "https://api.telegram.org",
retryAsync,
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
saveRemoteMedia: (...args: unknown[]) => saveRemoteMedia(...args),
shouldRetryTelegramTransportFallback: vi.fn(() => false),
warn: (s: string) => s,
};
@@ -140,7 +161,7 @@ function setupTransientGetFileRetry() {
.mockRejectedValueOnce(new Error("Network request for 'getFile' failed!"))
.mockResolvedValueOnce({ file_path: "voice/file_0.oga" });
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("audio"),
contentType: "audio/ogg",
fileName: "file_0.oga",
@@ -154,7 +175,7 @@ function setupTransientGetFileRetry() {
}
function mockPdfFetchAndSave(fileName: string | undefined) {
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("pdf-data"),
contentType: "application/pdf",
fileName,
@@ -204,21 +225,21 @@ function expectRecordFields(record: Record<string, unknown>, fields: Record<stri
}
}
function requireFetchRemoteMediaParams(callIndex = 0): Record<string, unknown> {
const call = (fetchRemoteMedia.mock.calls as unknown[][])[callIndex];
function requireReadRemoteMediaBufferParams(callIndex = 0): Record<string, unknown> {
const call = (readRemoteMediaBuffer.mock.calls as unknown[][])[callIndex];
if (!call) {
throw new Error(`expected fetchRemoteMedia call ${callIndex}`);
throw new Error(`expected readRemoteMediaBuffer call ${callIndex}`);
}
return requireRecord(call[0], `fetchRemoteMedia call ${callIndex} params`);
return requireRecord(call[0], `readRemoteMediaBuffer call ${callIndex} params`);
}
function expectFetchRemoteMediaFields(fields: Record<string, unknown>, callIndex = 0) {
expectRecordFields(requireFetchRemoteMediaParams(callIndex), fields);
function expectReadRemoteMediaBufferFields(fields: Record<string, unknown>, callIndex = 0) {
expectRecordFields(requireReadRemoteMediaBufferParams(callIndex), fields);
}
function expectFetchSsrfPolicyFields(fields: Record<string, unknown>, callIndex = 0) {
const params = requireFetchRemoteMediaParams(callIndex);
expectRecordFields(requireRecord(params.ssrfPolicy, "fetchRemoteMedia ssrfPolicy"), fields);
const params = requireReadRemoteMediaBufferParams(callIndex);
expectRecordFields(requireRecord(params.ssrfPolicy, "readRemoteMediaBuffer ssrfPolicy"), fields);
}
function expectResolvedMediaFields(
@@ -263,7 +284,7 @@ async function expectTransientGetFileRetrySuccess() {
await flushRetryTimers();
const result = await promise;
expect(getFile).toHaveBeenCalledTimes(2);
expectFetchRemoteMediaFields({
expectReadRemoteMediaBufferFields({
url: `https://api.telegram.org/file/bot${BOT_TOKEN}/voice/file_0.oga`,
});
expectFetchSsrfPolicyFields({
@@ -280,8 +301,9 @@ async function flushRetryTimers() {
describe("resolveMedia getFile retry", () => {
beforeEach(() => {
vi.useFakeTimers();
fetchRemoteMedia.mockReset();
readRemoteMediaBuffer.mockReset();
saveMediaBuffer.mockReset();
saveRemoteMedia.mockClear();
rootRead.mockReset();
});
@@ -311,9 +333,9 @@ describe("resolveMedia getFile retry", () => {
},
);
it("does not catch errors from fetchRemoteMedia (only getFile is retried)", async () => {
it("does not catch errors from readRemoteMediaBuffer (only getFile is retried)", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "voice/file_0.oga" });
fetchRemoteMedia.mockRejectedValueOnce(new Error("download failed"));
readRemoteMediaBuffer.mockRejectedValueOnce(new Error("download failed"));
await expect(resolveMediaWithDefaults(makeCtx("voice", getFile))).rejects.toThrow(
"download failed",
@@ -378,7 +400,7 @@ describe("resolveMedia getFile retry", () => {
.mockRejectedValueOnce(new Error("Network request for 'getFile' failed!"))
.mockResolvedValueOnce({ file_path: "stickers/file_0.webp" });
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("sticker-data"),
contentType: "image/webp",
fileName: "file_0.webp",
@@ -430,7 +452,7 @@ describe("resolveMedia getFile retry", () => {
dispatcherAttempts,
close: async () => {},
};
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("pdf-data"),
contentType: "application/pdf",
fileName: "file_42.pdf",
@@ -445,7 +467,7 @@ describe("resolveMedia getFile retry", () => {
});
expect(result?.path).toBe("/tmp/file_42---uuid.pdf");
const params = requireFetchRemoteMediaParams();
const params = requireReadRemoteMediaBufferParams();
expectRecordFields(params, {
fetchImpl: callerFetch,
dispatcherAttempts,
@@ -463,7 +485,7 @@ describe("resolveMedia getFile retry", () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "stickers/file_0.webp" });
const callerFetch = vi.fn() as unknown as typeof fetch;
const callerTransport = { fetch: callerFetch, sourceFetch: callerFetch, close: async () => {} };
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("sticker-data"),
contentType: "image/webp",
fileName: "file_0.webp",
@@ -478,12 +500,12 @@ describe("resolveMedia getFile retry", () => {
});
expect(result?.path).toBe("/tmp/file_0.webp");
expectFetchRemoteMediaFields({ fetchImpl: callerFetch });
expectReadRemoteMediaBufferFields({ fetchImpl: callerFetch });
});
it("allows an explicit Telegram apiRoot host without broadening the default SSRF allowlist", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("pdf-data"),
contentType: "application/pdf",
fileName: "file_42.pdf",
@@ -498,7 +520,7 @@ describe("resolveMedia getFile retry", () => {
dangerouslyAllowPrivateNetwork: true,
});
expectFetchRemoteMediaFields({
expectReadRemoteMediaBufferFields({
url: `https://telegram.internal:8443/custom/file/bot${BOT_TOKEN}/documents/file_42.pdf`,
});
expectFetchSsrfPolicyFields({
@@ -526,7 +548,7 @@ describe("resolveMedia getFile retry", () => {
{ trustedLocalFileRoots: ["/var/lib/telegram-bot-api"] },
);
expect(fetchRemoteMedia).not.toHaveBeenCalled();
expect(readRemoteMediaBuffer).not.toHaveBeenCalled();
expect(rootRead).toHaveBeenCalledWith({
rootDir: "/var/lib/telegram-bot-api",
relativePath: "file.pdf",
@@ -564,7 +586,7 @@ describe("resolveMedia getFile retry", () => {
trustedLocalFileRoots: ["/var/lib/telegram-bot-api"],
});
expect(fetchRemoteMedia).not.toHaveBeenCalled();
expect(readRemoteMediaBuffer).not.toHaveBeenCalled();
expect(rootRead).toHaveBeenCalledWith({
rootDir: "/var/lib/telegram-bot-api",
relativePath: "sticker.webp",
@@ -625,14 +647,14 @@ describe("resolveMedia getFile retry", () => {
);
expect(rootRead).not.toHaveBeenCalled();
expect(fetchRemoteMedia).not.toHaveBeenCalled();
expect(readRemoteMediaBuffer).not.toHaveBeenCalled();
});
});
describe("resolveMedia original filename preservation", () => {
beforeEach(() => {
vi.useFakeTimers();
fetchRemoteMedia.mockClear();
readRemoteMediaBuffer.mockClear();
saveMediaBuffer.mockClear();
});
@@ -642,7 +664,7 @@ describe("resolveMedia original filename preservation", () => {
it("passes document.file_name to saveMediaBuffer instead of server-side path", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("pdf-data"),
contentType: "application/pdf",
fileName: "file_42.pdf",
@@ -668,7 +690,7 @@ describe("resolveMedia original filename preservation", () => {
it("passes audio.file_name to saveMediaBuffer", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "music/file_99.mp3" });
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("audio-data"),
contentType: "audio/mpeg",
fileName: "file_99.mp3",
@@ -692,7 +714,7 @@ describe("resolveMedia original filename preservation", () => {
it("passes video.file_name to saveMediaBuffer", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "videos/file_55.mp4" });
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("video-data"),
contentType: "video/mp4",
fileName: "file_55.mp4",
@@ -786,7 +808,7 @@ describe("resolveMedia original filename preservation", () => {
const ctx = makeCtx("document", getFile);
const result = await resolveMediaWithDefaults(ctx, { apiRoot: customApiRoot });
expectFetchRemoteMediaFields({
expectReadRemoteMediaBufferFields({
url: `${customApiRoot}/file/bot${BOT_TOKEN}/documents/file_42.pdf`,
});
requireResolvedMedia(result, "custom apiRoot document URL");
@@ -794,7 +816,7 @@ describe("resolveMedia original filename preservation", () => {
it("constructs correct download URL with custom apiRoot for stickers", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "stickers/file_0.webp" });
fetchRemoteMedia.mockResolvedValueOnce({
readRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.from("sticker-data"),
contentType: "image/webp",
fileName: "file_0.webp",
@@ -808,7 +830,7 @@ describe("resolveMedia original filename preservation", () => {
const ctx = makeCtx("sticker", getFile);
const result = await resolveMediaWithDefaults(ctx, { apiRoot: customApiRoot });
expectFetchRemoteMediaFields({
expectReadRemoteMediaBufferFields({
url: `${customApiRoot}/file/bot${BOT_TOKEN}/stickers/file_0.webp`,
});
requireResolvedMedia(result, "custom apiRoot sticker URL");

View File

@@ -1,16 +1,22 @@
import { logVerbose, retryAsync, warn } from "openclaw/plugin-sdk/runtime-env";
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveTelegramApiBase, shouldRetryTelegramTransportFallback } from "../fetch.js";
import { fetchRemoteMedia, MediaFetchError, saveMediaBuffer } from "../telegram-media.runtime.js";
import {
readRemoteMediaBuffer,
MediaFetchError,
saveMediaBuffer,
saveRemoteMedia,
} from "../telegram-media.runtime.js";
export {
fetchRemoteMedia,
readRemoteMediaBuffer,
formatErrorMessage,
logVerbose,
MediaFetchError,
resolveTelegramApiBase,
retryAsync,
saveMediaBuffer,
saveRemoteMedia,
shouldRetryTelegramTransportFallback,
warn,
};

View File

@@ -4,13 +4,13 @@ import { root as fsRoot } from "openclaw/plugin-sdk/file-access-runtime";
import type { TelegramTransport } from "../fetch.js";
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
import {
fetchRemoteMedia,
formatErrorMessage,
logVerbose,
MediaFetchError,
resolveTelegramApiBase,
retryAsync,
saveMediaBuffer,
saveRemoteMedia,
shouldRetryTelegramTransportFallback,
warn,
} from "./delivery.resolve-media.runtime.js";
@@ -231,25 +231,28 @@ async function downloadAndSaveTelegramFile(params: {
const transport = resolveRequiredTelegramTransport(params.transport);
const apiBase = resolveTelegramApiBase(params.apiRoot);
const url = `${apiBase}/file/bot${params.token}/${params.filePath}`;
const fetched = await fetchRemoteMedia({
return await saveRemoteMedia({
url,
fetchImpl: transport.sourceFetch,
dispatcherAttempts: transport.dispatcherAttempts,
trustExplicitProxyDns: usesTrustedTelegramExplicitProxy(transport),
shouldRetryFetchError: shouldRetryTelegramTransportFallback,
retry: {
attempts: 3,
minDelayMs: 1000,
maxDelayMs: 4000,
jitter: 0.2,
label: "telegram:media-download",
onRetry: ({ attempt, maxAttempts }) =>
logVerbose(`telegram: media download retry ${attempt}/${maxAttempts}`),
},
filePathHint: params.filePath,
maxBytes: params.maxBytes,
readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS,
ssrfPolicy: buildTelegramMediaSsrfPolicy(params.apiRoot, params.dangerouslyAllowPrivateNetwork),
fallbackContentType: params.mimeType,
originalFilename: params.telegramFileName,
});
const originalName = params.telegramFileName ?? fetched.fileName ?? params.filePath;
return saveMediaBuffer(
fetched.buffer,
fetched.contentType,
"inbound",
params.maxBytes,
originalName,
);
}
async function resolveStickerMedia(params: {

View File

@@ -1,5 +1,6 @@
export {
fetchRemoteMedia,
readRemoteMediaBuffer,
MediaFetchError,
saveMediaBuffer,
saveRemoteMedia,
} from "openclaw/plugin-sdk/media-runtime";

View File

@@ -1,19 +1,19 @@
import {
fetchRemoteMedia,
readRemoteMediaBuffer,
MAX_IMAGE_BYTES,
saveMediaBuffer,
saveRemoteMedia,
} from "openclaw/plugin-sdk/media-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { downloadMedia, extractImageBlocks } from "./media.js";
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
MAX_IMAGE_BYTES: 6 * 1024 * 1024,
fetchRemoteMedia: vi.fn(),
saveMediaBuffer: vi.fn(),
readRemoteMediaBuffer: vi.fn(),
saveRemoteMedia: vi.fn(),
}));
const fetchRemoteMediaMock = vi.mocked(fetchRemoteMedia);
const saveMediaBufferMock = vi.mocked(saveMediaBuffer);
const readRemoteMediaBufferMock = vi.mocked(readRemoteMediaBuffer);
const saveRemoteMediaMock = vi.mocked(saveRemoteMedia);
describe("tlon monitor media", () => {
beforeEach(() => {
@@ -40,12 +40,7 @@ describe("tlon monitor media", () => {
});
it("stores fetched media through the shared inbound media store with the image cap", async () => {
fetchRemoteMediaMock.mockResolvedValue({
buffer: Buffer.from("image-data"),
contentType: "image/png",
fileName: "photo.png",
});
saveMediaBufferMock.mockResolvedValue({
saveRemoteMediaMock.mockResolvedValue({
id: "photo---uuid.png",
path: "/tmp/openclaw/media/inbound/photo---uuid.png",
size: "image-data".length,
@@ -54,21 +49,15 @@ describe("tlon monitor media", () => {
const result = await downloadMedia("https://example.com/photo.png");
expect(fetchRemoteMediaMock).toHaveBeenCalledTimes(1);
expect(fetchRemoteMediaMock).toHaveBeenCalledWith({
expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1);
expect(saveRemoteMediaMock).toHaveBeenCalledWith({
url: "https://example.com/photo.png",
maxBytes: MAX_IMAGE_BYTES,
readIdleTimeoutMs: 30_000,
ssrfPolicy: undefined,
requestInit: { method: "GET" },
});
expect(saveMediaBufferMock).toHaveBeenCalledWith(
Buffer.from("image-data"),
"image/png",
"inbound",
MAX_IMAGE_BYTES,
"photo.png",
);
expect(result).toEqual({
localPath: "/tmp/openclaw/media/inbound/photo---uuid.png",
contentType: "image/png",
@@ -77,7 +66,7 @@ describe("tlon monitor media", () => {
});
it("returns null when the fetch exceeds the image cap", async () => {
fetchRemoteMediaMock.mockRejectedValue(
saveRemoteMediaMock.mockRejectedValue(
new Error(
`Failed to fetch media from https://example.com/photo.png: payload exceeds maxBytes ${MAX_IMAGE_BYTES}`,
),
@@ -86,6 +75,6 @@ describe("tlon monitor media", () => {
const result = await downloadMedia("https://example.com/photo.png");
expect(result).toBeNull();
expect(saveMediaBufferMock).not.toHaveBeenCalled();
expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
});
});

View File

@@ -4,9 +4,9 @@ import * as path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import {
fetchRemoteMedia,
readRemoteMediaBuffer,
MAX_IMAGE_BYTES,
saveMediaBuffer,
saveRemoteMedia,
} from "openclaw/plugin-sdk/media-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { getDefaultSsrFPolicy } from "../urbit/context.js";
@@ -67,29 +67,24 @@ export async function downloadMedia(
return null;
}
const fetched = await fetchRemoteMedia({
const fetchOptions = {
url,
maxBytes: MAX_IMAGE_BYTES,
readIdleTimeoutMs: TLON_MEDIA_DOWNLOAD_IDLE_TIMEOUT_MS,
ssrfPolicy: getDefaultSsrFPolicy(),
requestInit: { method: "GET" },
});
};
if (!mediaDir) {
const saved = await saveMediaBuffer(
fetched.buffer,
fetched.contentType,
"inbound",
MAX_IMAGE_BYTES,
fetched.fileName,
);
const saved = await saveRemoteMedia(fetchOptions);
return {
localPath: saved.path,
contentType: saved.contentType ?? fetched.contentType ?? "application/octet-stream",
contentType: saved.contentType ?? "application/octet-stream",
originalUrl: url,
};
}
const fetched = await readRemoteMediaBuffer(fetchOptions);
await mkdir(mediaDir, { recursive: true });
const ext =
getExtensionFromFileName(fetched.fileName) ||

View File

@@ -14,7 +14,7 @@ type MockMessageInput = Parameters<typeof mockNormalizeMessageContent>[0];
const readAllowFromStoreMock = vi.fn().mockResolvedValue([]);
const upsertPairingRequestMock = vi.fn().mockResolvedValue({ code: "PAIRCODE", created: true });
const saveMediaBufferSpy = vi.fn();
const saveMediaStreamSpy = vi.fn();
let currentMockSocket:
| {
ev: import("node:events").EventEmitter;
@@ -82,9 +82,9 @@ vi.mock("openclaw/plugin-sdk/media-store", async () => {
);
return {
...actual,
saveMediaBuffer: vi.fn(async (...args: Parameters<typeof actual.saveMediaBuffer>) => {
saveMediaBufferSpy(...args);
return actual.saveMediaBuffer(...args);
saveMediaStream: vi.fn(async (...args: Parameters<typeof actual.saveMediaStream>) => {
saveMediaStreamSpy(...args);
return actual.saveMediaStream(...args);
}),
};
});
@@ -95,6 +95,7 @@ process.env.HOME = HOME;
vi.mock("baileys", async () => {
const actual = await vi.importActual<typeof import("baileys")>("baileys");
const { Readable } = require("node:stream") as typeof import("node:stream");
const jpegBuffer = Buffer.from([
0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x02, 0x02, 0x02, 0x03, 0x02, 0x02,
0x02, 0x03, 0x03, 0x03, 0x03, 0x04, 0x06, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08, 0x06, 0x06, 0x05,
@@ -110,7 +111,7 @@ vi.mock("baileys", async () => {
return {
...actual,
DisconnectReason: actual.DisconnectReason ?? { loggedOut: 401 },
downloadMediaMessage: vi.fn().mockResolvedValue(jpegBuffer),
downloadMediaMessage: vi.fn().mockImplementation(() => Readable.from([jpegBuffer])),
extractMessageContent: vi.fn((message: MockMessageInput) => mockExtractMessageContent(message)),
getContentType: vi.fn((message: MockMessageInput) => mockGetContentType(message)),
isJidGroup: vi.fn((jid: string | undefined | null) => mockIsJidGroup(jid)),
@@ -156,10 +157,10 @@ async function waitForMessage(onMessage: ReturnType<typeof vi.fn>) {
return onMessage.mock.calls[0]?.[0];
}
function latestSaveMediaBufferCall() {
const call = saveMediaBufferSpy.mock.calls[saveMediaBufferSpy.mock.calls.length - 1];
function latestSaveMediaStreamCall() {
const call = saveMediaStreamSpy.mock.calls[saveMediaStreamSpy.mock.calls.length - 1];
if (!call) {
throw new Error("expected saveMediaBuffer call");
throw new Error("expected saveMediaStream call");
}
return call;
}
@@ -181,7 +182,7 @@ describe("web inbound media saves with extension", () => {
beforeEach(() => {
vi.useRealTimers();
currentMockSocket = undefined;
saveMediaBufferSpy.mockClear();
saveMediaStreamSpy.mockClear();
resetWebInboundDedupe();
});
@@ -246,8 +247,8 @@ describe("web inbound media saves with extension", () => {
const second = await waitForMessage(onMessage);
expect(second.mediaFileName).toBe(fileName);
expect(saveMediaBufferSpy).toHaveBeenCalled();
const lastCall = latestSaveMediaBufferCall();
expect(saveMediaStreamSpy).toHaveBeenCalled();
const lastCall = latestSaveMediaStreamCall();
expect(lastCall[4]).toBe(fileName);
await listener.close();
@@ -294,14 +295,14 @@ describe("web inbound media saves with extension", () => {
expect(inbound.replyToBody).toBe("<media:image>");
const mediaPath = requireMediaPath(inbound.mediaPath);
expect(path.extname(mediaPath)).toBe(".jpg");
expect(saveMediaBufferSpy).toHaveBeenCalled();
const lastCall = latestSaveMediaBufferCall();
expect(saveMediaStreamSpy).toHaveBeenCalled();
const lastCall = latestSaveMediaStreamCall();
expect(lastCall[1]).toBe("image/jpeg");
await listener.close();
});
it("passes mediaMaxMb to saveMediaBuffer", async () => {
it("passes mediaMaxMb to saveMediaStream", async () => {
const onMessage = vi.fn();
const listener = await monitorWebInbox({
cfg: {
@@ -330,8 +331,8 @@ describe("web inbound media saves with extension", () => {
realSock.ev.emit("messages.upsert", upsert);
await waitForMessage(onMessage);
expect(saveMediaBufferSpy).toHaveBeenCalled();
const lastCall = latestSaveMediaBufferCall();
expect(saveMediaStreamSpy).toHaveBeenCalled();
const lastCall = latestSaveMediaStreamCall();
expect(lastCall[3]).toBe(1 * 1024 * 1024);
await listener.close();

View File

@@ -1,11 +1,13 @@
import { Readable } from "node:stream";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { mockNormalizeMessageContent } from "../../../../test/mocks/baileys.js";
type MockMessageInput = Parameters<typeof mockNormalizeMessageContent>[0];
const { normalizeMessageContent, downloadMediaMessage } = vi.hoisted(() => ({
const { normalizeMessageContent, downloadMediaMessage, saveMediaStream } = vi.hoisted(() => ({
normalizeMessageContent: vi.fn((msg: MockMessageInput) => mockNormalizeMessageContent(msg)),
downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from("fake-media-data")),
saveMediaStream: vi.fn(),
}));
vi.mock("baileys", async () => {
@@ -16,6 +18,10 @@ vi.mock("baileys", async () => {
};
});
vi.mock("openclaw/plugin-sdk/media-store", () => ({
saveMediaStream,
}));
let downloadInboundMedia: typeof import("./media.js").downloadInboundMedia;
const mockSock = {
@@ -26,7 +32,12 @@ const mockSock = {
async function expectMimetype(message: Record<string, unknown>, expected: string) {
const result = await downloadInboundMedia({ message } as never, mockSock as never);
expect(result).toEqual({
buffer: Buffer.from("fake-media-data"),
saved: {
id: "saved-media",
path: "/tmp/saved-media",
size: Buffer.byteLength("fake-media-data"),
contentType: expected,
},
mimetype: expected,
fileName: undefined,
});
@@ -40,6 +51,25 @@ describe("downloadInboundMedia", () => {
beforeEach(() => {
normalizeMessageContent.mockClear();
downloadMediaMessage.mockClear();
downloadMediaMessage.mockImplementation(() => Readable.from([Buffer.from("fake-media-data")]));
saveMediaStream.mockClear();
saveMediaStream.mockImplementation(
async (
stream: AsyncIterable<Buffer>,
contentType: string | undefined,
_subdir: string,
maxBytes: number,
) => {
let total = 0;
for await (const chunk of stream) {
total += chunk.byteLength;
if (total > maxBytes) {
throw new Error("Media exceeds limit");
}
}
return { id: "saved-media", path: "/tmp/saved-media", size: total, contentType };
},
);
mockSock.updateMediaMessage.mockClear();
});
@@ -80,9 +110,29 @@ describe("downloadInboundMedia", () => {
} as never;
const result = await downloadInboundMedia(msg, mockSock as never);
expect(result).toEqual({
buffer: Buffer.from("fake-media-data"),
saved: {
id: "saved-media",
path: "/tmp/saved-media",
size: Buffer.byteLength("fake-media-data"),
contentType: "application/pdf",
},
mimetype: "application/pdf",
fileName: "report.pdf",
});
});
it("downloads in stream mode and rejects over the configured cap", async () => {
downloadMediaMessage.mockImplementationOnce(() =>
Readable.from([Buffer.alloc(4), Buffer.alloc(4)]),
);
await expect(
downloadInboundMedia(
{ message: { imageMessage: { mimetype: "image/jpeg" } } } as never,
mockSock as never,
7,
),
).rejects.toThrow(/Media exceeds/i);
expect(downloadMediaMessage.mock.calls[0]?.[1]).toBe("stream");
});
});

View File

@@ -1,9 +1,17 @@
import type { proto, WAMessage } from "baileys";
import { saveMediaStream, type SavedMedia } from "openclaw/plugin-sdk/media-store";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { createWaSocket } from "../session.js";
import { extractContextInfo } from "./extract.js";
import { downloadMediaMessage, normalizeMessageContent } from "./runtime-api.js";
export class WhatsAppInboundMediaLimitExceededError extends Error {
constructor(maxBytes: number) {
super(`Media exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`);
this.name = "WhatsAppInboundMediaLimitExceededError";
}
}
function unwrapMessage(message: proto.IMessage | undefined): proto.IMessage | undefined {
const normalized = normalizeMessageContent(message);
return normalized;
@@ -43,7 +51,8 @@ function resolveMediaMimetype(message: proto.IMessage): string | undefined {
export async function downloadInboundMedia(
msg: proto.IWebMessageInfo,
sock: Awaited<ReturnType<typeof createWaSocket>>,
): Promise<{ buffer: Buffer; mimetype?: string; fileName?: string } | undefined> {
maxBytes = 50 * 1024 * 1024,
): Promise<{ saved: SavedMedia; mimetype?: string; fileName?: string } | undefined> {
const message = unwrapMessage(msg.message as proto.IMessage | undefined);
if (!message) {
return undefined;
@@ -60,17 +69,32 @@ export async function downloadInboundMedia(
return undefined;
}
try {
const buffer = await downloadMediaMessage(
const stream = await downloadMediaMessage(
msg as WAMessage,
"buffer",
"stream",
{},
{
reuploadRequest: sock.updateMediaMessage,
logger: sock.logger,
},
);
return { buffer, mimetype, fileName };
const saved = await saveMediaStream(
stream as AsyncIterable<unknown>,
mimetype,
"inbound",
maxBytes,
fileName,
).catch((err) => {
if (err instanceof Error && /Media exceeds/i.test(err.message)) {
throw new WhatsAppInboundMediaLimitExceededError(maxBytes);
}
throw err;
});
return { saved, mimetype, fileName };
} catch (err) {
if (err instanceof WhatsAppInboundMediaLimitExceededError) {
throw err;
}
logVerbose(`downloadMediaMessage failed: ${String(err)}`);
return undefined;
}
@@ -79,7 +103,8 @@ export async function downloadInboundMedia(
export async function downloadQuotedInboundMedia(
msg: proto.IWebMessageInfo,
sock: Awaited<ReturnType<typeof createWaSocket>>,
): Promise<{ buffer: Buffer; mimetype?: string; fileName?: string } | undefined> {
maxBytes = 50 * 1024 * 1024,
): Promise<{ saved: SavedMedia; mimetype?: string; fileName?: string } | undefined> {
const message = unwrapMessage(msg.message as proto.IMessage | undefined);
const contextInfo = extractContextInfo(message);
if (!contextInfo?.quotedMessage) {
@@ -98,5 +123,6 @@ export async function downloadQuotedInboundMedia(
messageTimestamp: msg.messageTimestamp,
},
sock,
maxBytes,
);
}

View File

@@ -46,7 +46,7 @@ import {
resolveWhatsAppOutboundMentions,
type WhatsAppOutboundMentionParticipant,
} from "./outbound-mentions.js";
import { DisconnectReason, isJidGroup, saveMediaBuffer } from "./runtime-api.js";
import { DisconnectReason, isJidGroup } from "./runtime-api.js";
import { createWebSendApi } from "./send-api.js";
import { normalizeWhatsAppSendResult } from "./send-result.js";
import type { WebInboundMessage, WebListenerCloseReason } from "./types.js";
@@ -656,32 +656,25 @@ export async function attachWebInboxToSocket(
let mediaPath: string | undefined;
let mediaType: string | undefined;
let mediaFileName: string | undefined;
const maxMb =
typeof options.mediaMaxMb === "number" && options.mediaMaxMb > 0 ? options.mediaMaxMb : 50;
const maxBytes = maxMb * 1024 * 1024;
const saveInboundMedia = async (
inboundMedia: Awaited<ReturnType<typeof downloadInboundMedia>>,
) => {
if (!inboundMedia) {
return;
}
const maxMb =
typeof options.mediaMaxMb === "number" && options.mediaMaxMb > 0 ? options.mediaMaxMb : 50;
const maxBytes = maxMb * 1024 * 1024;
const saved = await saveMediaBuffer(
inboundMedia.buffer,
inboundMedia.mimetype,
"inbound",
maxBytes,
inboundMedia.fileName,
);
mediaPath = saved.path;
mediaPath = inboundMedia.saved.path;
mediaType = inboundMedia.mimetype;
mediaFileName = inboundMedia.fileName;
};
try {
const inboundMedia = await downloadInboundMedia(msg as proto.IWebMessageInfo, sock);
const inboundMedia = await downloadInboundMedia(msg as proto.IWebMessageInfo, sock, maxBytes);
await saveInboundMedia(inboundMedia);
if (!mediaPath && replyContext) {
await saveInboundMedia(
await downloadQuotedInboundMedia(msg as proto.IWebMessageInfo, sock),
await downloadQuotedInboundMedia(msg as proto.IWebMessageInfo, sock, maxBytes),
);
}
} catch (err) {

View File

@@ -20,7 +20,8 @@ describe("Zalo polling image handling", () => {
core,
finalizeInboundContextMock,
recordInboundSessionMock,
fetchRemoteMediaMock,
readRemoteMediaBufferMock,
saveRemoteMediaMock,
saveMediaBufferMock,
} = createImageLifecycleCore();
@@ -57,9 +58,11 @@ describe("Zalo polling image handling", () => {
});
await settleAsyncWork();
expect(fetchRemoteMediaMock).toHaveBeenCalledTimes(1);
expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1);
expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
expectImageLifecycleDelivery({
fetchRemoteMediaMock,
readRemoteMediaBufferMock,
saveRemoteMediaMock,
saveMediaBufferMock,
finalizeInboundContextMock,
recordInboundSessionMock,
@@ -99,7 +102,7 @@ describe("Zalo polling image handling", () => {
await settleAsyncWork();
expect(sendMessageMock).toHaveBeenCalledTimes(1);
expect(fetchRemoteMediaMock).not.toHaveBeenCalled();
expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
expect(saveMediaBufferMock).not.toHaveBeenCalled();
expect(finalizeInboundContextMock).not.toHaveBeenCalled();
expect(recordInboundSessionMock).not.toHaveBeenCalled();

View File

@@ -378,13 +378,7 @@ async function handleImageMessage(params: ZaloImageMessageParams): Promise<void>
if (photo_url) {
try {
const maxBytes = mediaMaxMb * 1024 * 1024;
const fetched = await core.channel.media.fetchRemoteMedia({ url: photo_url, maxBytes });
const saved = await core.channel.media.saveMediaBuffer(
fetched.buffer,
fetched.contentType,
"inbound",
maxBytes,
);
const saved = await core.channel.media.saveRemoteMedia({ url: photo_url, maxBytes });
mediaPath = saved.path;
mediaType = saved.contentType;
} catch (err) {

View File

@@ -549,7 +549,8 @@ describe("handleZaloWebhookRequest", () => {
core,
finalizeInboundContextMock,
recordInboundSessionMock,
fetchRemoteMediaMock,
readRemoteMediaBufferMock,
saveRemoteMediaMock,
saveMediaBufferMock,
} = createImageLifecycleCore();
const unregister = registerTarget({
@@ -582,9 +583,11 @@ describe("handleZaloWebhookRequest", () => {
unregister();
}
await vi.waitFor(() => expect(fetchRemoteMediaMock).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1));
expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
expectImageLifecycleDelivery({
fetchRemoteMediaMock,
readRemoteMediaBufferMock,
saveRemoteMediaMock,
saveMediaBufferMock,
finalizeInboundContextMock,
recordInboundSessionMock,

View File

@@ -1,4 +1,5 @@
import { request as httpRequest } from "node:http";
import { createPluginRuntimeMediaMock } from "openclaw/plugin-sdk/channel-test-helpers";
import { expect, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import type { ResolvedZaloAccount } from "../types.js";
@@ -167,10 +168,14 @@ export function createImageLifecycleCore() {
}),
);
const recordInboundSessionMock = vi.fn(async () => undefined);
const fetchRemoteMediaMock = vi.fn(async () => ({
const readRemoteMediaBufferMock = vi.fn(async () => ({
buffer: Buffer.from("image-bytes"),
contentType: "image/jpeg",
}));
const saveRemoteMediaMock = vi.fn(async () => ({
path: "/tmp/zalo-photo.jpg",
contentType: "image/jpeg",
}));
const saveMediaBufferMock = vi.fn(async () => ({
path: "/tmp/zalo-photo.jpg",
contentType: "image/jpeg",
@@ -212,12 +217,14 @@ export function createImageLifecycleCore() {
() => "code",
) as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
},
media: {
fetchRemoteMedia:
fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
media: createPluginRuntimeMediaMock({
readRemoteMediaBuffer:
readRemoteMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["readRemoteMediaBuffer"],
saveRemoteMedia:
saveRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["saveRemoteMedia"],
saveMediaBuffer:
saveMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
},
}) as unknown as PluginRuntime["channel"]["media"],
reply: {
finalizeInboundContext:
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
@@ -341,7 +348,8 @@ export function createImageLifecycleCore() {
core,
finalizeInboundContextMock,
recordInboundSessionMock,
fetchRemoteMediaMock,
readRemoteMediaBufferMock,
saveRemoteMediaMock,
saveMediaBufferMock,
readAllowFromStoreMock,
upsertPairingRequestMock,
@@ -349,7 +357,8 @@ export function createImageLifecycleCore() {
}
export function expectImageLifecycleDelivery(params: {
fetchRemoteMediaMock: ReturnType<typeof vi.fn>;
readRemoteMediaBufferMock: ReturnType<typeof vi.fn>;
saveRemoteMediaMock?: ReturnType<typeof vi.fn>;
saveMediaBufferMock: ReturnType<typeof vi.fn>;
finalizeInboundContextMock: ReturnType<typeof vi.fn>;
recordInboundSessionMock: ReturnType<typeof vi.fn>;
@@ -362,11 +371,12 @@ export function expectImageLifecycleDelivery(params: {
const senderName = params.senderName ?? "Test User";
const mediaPath = params.mediaPath ?? "/tmp/zalo-photo.jpg";
const mediaType = params.mediaType ?? "image/jpeg";
expect(params.fetchRemoteMediaMock).toHaveBeenCalledWith({
const saveRemoteMediaMock = params.saveRemoteMediaMock ?? params.readRemoteMediaBufferMock;
expect(saveRemoteMediaMock).toHaveBeenCalledWith({
url: photoUrl,
maxBytes: 5 * 1024 * 1024,
});
expect(params.saveMediaBufferMock).toHaveBeenCalledTimes(1);
expect(params.saveMediaBufferMock).not.toHaveBeenCalled();
expect(params.finalizeInboundContextMock).toHaveBeenCalledWith(
expect.objectContaining({
SenderName: senderName,

View File

@@ -1357,6 +1357,7 @@
"check:import-cycles": "node --import tsx scripts/check-import-cycles.ts",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
"check:madge-import-cycles": "node --import tsx scripts/check-madge-import-cycles.ts",
"check:media-download-helpers": "node scripts/check-media-download-helper-roundtrip.mjs",
"check:no-conflict-markers": "node scripts/check-no-conflict-markers.mjs",
"check:no-runtime-action-load-config": "node scripts/check-no-runtime-action-load-config.mjs",
"check:opengrep-rule-metadata": "node security/opengrep/check-rule-metadata.mjs",

View File

@@ -156,6 +156,7 @@ export function createChangedCheckPlan(result, options = {}) {
}
if (runAll) {
add("media download helper guard", ["check:media-download-helpers"]);
add("runtime sidecar loader guard", ["check:runtime-sidecar-loaders"]);
addTypecheck("typecheck all", ["tsgo:all"]);
addLint("lint", ["lint"]);
@@ -200,6 +201,7 @@ export function createChangedCheckPlan(result, options = {}) {
}
if (lanes.core || lanes.extensions) {
add("media download helper guard", ["check:media-download-helpers"]);
add("runtime sidecar loader guard", ["check:runtime-sidecar-loaders"]);
add("runtime import cycles", ["check:import-cycles"]);
}

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
const WINDOW_LINES = 80;
const READ_HELPER_RE = /\b(?:readRemoteMediaBuffer|fetchRemoteMedia)\s*\(/;
const SAVE_BUFFER_RE = /(?:\.|\b)saveMediaBuffer\s*\(/;
function listTrackedExtensionSources() {
return execFileSync("git", ["ls-files", "extensions/**/*.ts"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
})
.split("\n")
.filter(Boolean)
.filter((file) => file.includes("/src/"))
.filter((file) => !isTestOrFixture(file));
}
function isTestOrFixture(file) {
return (
file.endsWith(".test.ts") ||
file.endsWith(".e2e.test.ts") ||
file.endsWith(".test-harness.ts") ||
file.endsWith(".test-utils.ts") ||
file.endsWith("/test-runtime.ts") ||
file.endsWith("/test-helpers.ts") ||
file.includes("/test-support/") ||
file.includes("/fixtures/")
);
}
const findings = [];
for (const file of listTrackedExtensionSources()) {
const lines = readFileSync(file, "utf8").split("\n");
for (let index = 0; index < lines.length; index += 1) {
if (!READ_HELPER_RE.test(lines[index])) {
continue;
}
const end = Math.min(lines.length, index + WINDOW_LINES);
for (let nextIndex = index; nextIndex < end; nextIndex += 1) {
if (!SAVE_BUFFER_RE.test(lines[nextIndex])) {
continue;
}
findings.push({
file,
line: index + 1,
saveLine: nextIndex + 1,
});
break;
}
}
}
if (findings.length > 0) {
console.error("Avoid remote-media buffer/store round trips in plugin production code.");
console.error(
"Use saveRemoteMedia(...) for URL-to-store or saveResponseMedia(...) for fetched Response objects.",
);
for (const finding of findings) {
console.error(`- ${finding.file}:${finding.line} -> saveMediaBuffer at ${finding.saveLine}`);
}
process.exitCode = 1;
}

View File

@@ -43,6 +43,7 @@ export async function main(argv = process.argv.slice(2)) {
name: "deprecated channel access seams",
args: ["lint:extensions:no-deprecated-channel-access"],
},
{ name: "media download helper guard", args: ["check:media-download-helpers"] },
{ name: "runtime sidecar loader guard", args: ["check:runtime-sidecar-loaders"] },
{ name: "tool display", args: ["tool-display:check"] },
{ name: "host env policy", args: ["check:host-env-policy:swift"] },

View File

@@ -29,7 +29,7 @@ const hasAvailableAuthForProviderMock = vi.hoisted(() =>
const getApiKeyForModelMock = vi.hoisted(() =>
vi.fn(async () => ({ apiKey: "test-key", source: "test", mode: "api-key" })),
);
const fetchRemoteMediaMock = vi.hoisted(() => vi.fn());
const readRemoteMediaBufferMock = vi.hoisted(() => vi.fn());
const runExecMock = vi.hoisted(() => vi.fn());
const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn());
const mockDeliverOutboundPayloads = vi.hoisted(() => vi.fn());
@@ -177,7 +177,7 @@ describe("applyMediaUnderstanding echo transcript", () => {
resolveAuthProfileOrder: vi.fn(() => []),
}));
vi.doMock("../media/fetch.js", () => ({
fetchRemoteMedia: fetchRemoteMediaMock,
readRemoteMediaBuffer: readRemoteMediaBufferMock,
MediaFetchError: MediaFetchErrorMock,
}));
vi.doMock("../process/exec.js", () => ({
@@ -232,7 +232,7 @@ describe("applyMediaUnderstanding echo transcript", () => {
resolveApiKeyForProviderMock.mockClear();
hasAvailableAuthForProviderMock.mockClear();
getApiKeyForModelMock.mockClear();
fetchRemoteMediaMock.mockClear();
readRemoteMediaBufferMock.mockClear();
runExecMock.mockReset();
runCommandWithTimeoutMock.mockReset();
mockDeliverOutboundPayloads.mockClear();

View File

@@ -25,14 +25,14 @@ const hasAvailableAuthForProviderMock = vi.hoisted(() =>
return Boolean(resolved?.apiKey);
}),
);
const fetchRemoteMediaMock = vi.hoisted(() => vi.fn());
const readRemoteMediaBufferMock = vi.hoisted(() => vi.fn());
const runFfmpegMock = vi.hoisted(() => vi.fn());
const runExecMock = vi.hoisted(() => vi.fn());
let applyMediaUnderstanding: typeof import("./apply.js").applyMediaUnderstanding;
let clearMediaUnderstandingBinaryCacheForTests: typeof import("./runner.js").clearMediaUnderstandingBinaryCacheForTests;
const mockedResolveApiKey = resolveApiKeyForProviderMock;
const mockedFetchRemoteMedia = fetchRemoteMediaMock;
const mockedReadRemoteMediaBuffer = readRemoteMediaBufferMock;
const mockedRunFfmpeg = runFfmpegMock;
const mockedRunExec = runExecMock;
@@ -279,7 +279,7 @@ describe("applyMediaUnderstanding", () => {
},
}));
vi.doMock("../media/fetch.js", () => ({
fetchRemoteMedia: fetchRemoteMediaMock,
readRemoteMediaBuffer: readRemoteMediaBufferMock,
}));
vi.doMock("../media/ffmpeg-exec.js", () => ({
runFfmpeg: runFfmpegMock,
@@ -333,10 +333,10 @@ describe("applyMediaUnderstanding", () => {
mode: "api-key",
});
hasAvailableAuthForProviderMock.mockClear();
mockedFetchRemoteMedia.mockClear();
mockedReadRemoteMediaBuffer.mockClear();
mockedRunFfmpeg.mockReset();
mockedRunExec.mockReset();
mockedFetchRemoteMedia.mockResolvedValue({
mockedReadRemoteMediaBuffer.mockResolvedValue({
buffer: createSafeAudioFixtureBuffer(2048),
contentType: "audio/ogg",
fileName: "note.ogg",
@@ -484,7 +484,7 @@ describe("applyMediaUnderstanding", () => {
});
it("injects a placeholder transcript when URL-only audio is too small", async () => {
mockedFetchRemoteMedia.mockResolvedValueOnce({
mockedReadRemoteMediaBuffer.mockResolvedValueOnce({
buffer: Buffer.alloc(100),
contentType: "audio/ogg",
fileName: "tiny.ogg",

View File

@@ -4,7 +4,7 @@ import { logVerbose, shouldLogVerbose } from "../globals.js";
import { FsSafeError, openLocalFileSafely } from "../infra/fs-safe.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { isAbortError } from "../infra/unhandled-rejections.js";
import { fetchRemoteMedia, MediaFetchError } from "../media/fetch.js";
import { readRemoteMediaBuffer, MediaFetchError } from "../media/fetch.js";
import { isInboundPathAllowed, mergeInboundPathRoots } from "../media/inbound-path-policy.js";
import { getDefaultMediaLocalRoots } from "../media/local-roots.js";
import { detectMime } from "../media/mime.js";
@@ -162,7 +162,7 @@ export class MediaAttachmentCache {
try {
const fetchImpl = (input: RequestInfo | URL, init?: RequestInit) =>
fetchWithTimeout(resolveRequestUrl(input), init ?? {}, params.timeoutMs, globalThis.fetch);
const fetched = await fetchRemoteMedia({
const fetched = await readRemoteMediaBuffer({
url,
fetchImpl,
maxBytes: params.maxBytes,

View File

@@ -5,29 +5,29 @@ import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { withTempDir } from "../test-helpers/temp-dir.js";
import { MediaAttachmentCache } from "./attachments.js";
const fetchRemoteMediaMock = vi.hoisted(() => vi.fn());
const readRemoteMediaBufferMock = vi.hoisted(() => vi.fn());
vi.mock("../media/fetch.js", async () => {
const actual = await vi.importActual<typeof import("../media/fetch.js")>("../media/fetch.js");
return {
...actual,
fetchRemoteMedia: fetchRemoteMediaMock,
readRemoteMediaBuffer: readRemoteMediaBufferMock,
};
});
function requireFetchRemoteMediaInput(): {
function requireReadRemoteMediaBufferInput(): {
url?: unknown;
fetchImpl?: unknown;
maxBytes?: unknown;
ssrfPolicy?: unknown;
} {
const [call] = fetchRemoteMediaMock.mock.calls;
const [call] = readRemoteMediaBufferMock.mock.calls;
if (!call) {
throw new Error("expected fetchRemoteMedia call");
throw new Error("expected readRemoteMediaBuffer call");
}
const [input] = call;
if (typeof input !== "object" || input === null || Array.isArray(input)) {
throw new Error("expected fetchRemoteMedia input to be an object");
throw new Error("expected readRemoteMediaBuffer input to be an object");
}
return input;
}
@@ -51,7 +51,7 @@ async function withBlockedLocalAttachmentFallback(
localPathRoots: [allowedRoot],
},
);
fetchRemoteMediaMock.mockResolvedValue({
readRemoteMediaBufferMock.mockResolvedValue({
buffer: Buffer.from("fallback-buffer"),
contentType: "image/jpeg",
fileName: "fallback.jpg",
@@ -64,7 +64,7 @@ async function withBlockedLocalAttachmentFallback(
describe("media understanding attachment URL fallback", () => {
afterEach(() => {
vi.restoreAllMocks();
fetchRemoteMediaMock.mockReset();
readRemoteMediaBufferMock.mockReset();
});
it("getPath falls back to URL fetch when local path is blocked", async () => {
@@ -81,8 +81,8 @@ describe("media understanding attachment URL fallback", () => {
expect(path.dirname(result.path)).toBe(resolvePreferredOpenClawTmpDir());
expect(path.basename(result.path).startsWith("openclaw-media-")).toBe(true);
expect(path.extname(result.path)).toBe(".jpg");
expect(fetchRemoteMediaMock).toHaveBeenCalledTimes(1);
const fetchInput = requireFetchRemoteMediaInput();
expect(readRemoteMediaBufferMock).toHaveBeenCalledTimes(1);
const fetchInput = requireReadRemoteMediaBufferInput();
const fetchImpl = fetchInput.fetchImpl;
expect(fetchInput).toStrictEqual({
url: fallbackUrl,
@@ -109,8 +109,8 @@ describe("media understanding attachment URL fallback", () => {
timeoutMs: 1000,
});
expect(result.buffer.toString()).toBe("fallback-buffer");
expect(fetchRemoteMediaMock).toHaveBeenCalledTimes(1);
const fetchInput = requireFetchRemoteMediaInput();
expect(readRemoteMediaBufferMock).toHaveBeenCalledTimes(1);
const fetchInput = requireReadRemoteMediaBufferInput();
const fetchImpl = fetchInput.fetchImpl;
expect(fetchInput).toStrictEqual({
url: fallbackUrl,

View File

@@ -1,4 +1,6 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js";
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
@@ -12,10 +14,13 @@ vi.mock("../infra/net/fetch-guard.js", () => ({
}));
type FetchModule = typeof import("./fetch.js");
type FetchRemoteMedia = FetchModule["fetchRemoteMedia"];
type LookupFn = NonNullable<Parameters<FetchRemoteMedia>[0]["lookupFn"]>;
let fetchRemoteMedia: FetchRemoteMedia;
type ReadRemoteMediaBuffer = FetchModule["readRemoteMediaBuffer"];
type SaveRemoteMedia = FetchModule["saveRemoteMedia"];
type LookupFn = NonNullable<Parameters<ReadRemoteMediaBuffer>[0]["lookupFn"]>;
let readRemoteMediaBuffer: ReadRemoteMediaBuffer;
let saveRemoteMedia: SaveRemoteMedia;
let defaultFetchMediaMaxBytes: number;
let tempHome: TempHomeEnv;
function makeStream(chunks: Uint8Array[]) {
return new ReadableStream<Uint8Array>({
@@ -54,11 +59,11 @@ function requireFetchGuardRequest(): unknown {
}
async function expectRemoteMediaMaxBytesError(params: {
fetchImpl: Parameters<typeof fetchRemoteMedia>[0]["fetchImpl"];
fetchImpl: Parameters<typeof readRemoteMediaBuffer>[0]["fetchImpl"];
maxBytes: number;
}) {
await expect(
fetchRemoteMedia({
readRemoteMediaBuffer({
url: "https://example.com/file.bin",
fetchImpl: params.fetchImpl,
maxBytes: params.maxBytes,
@@ -71,9 +76,9 @@ async function expectRedactedBotTokenFetchError(params: {
botFileUrl: string;
botToken: string;
expectedErrorText: string;
fetchImpl: Parameters<typeof fetchRemoteMedia>[0]["fetchImpl"];
fetchImpl: Parameters<typeof readRemoteMediaBuffer>[0]["fetchImpl"];
}) {
const error = await fetchRemoteMedia({
const error = await readRemoteMediaBuffer({
url: params.botFileUrl,
fetchImpl: params.fetchImpl,
lookupFn: makeLookupFn(),
@@ -90,9 +95,9 @@ async function expectRedactedBotTokenFetchError(params: {
expect(errorText).toBe(params.expectedErrorText);
}
async function expectFetchRemoteMediaRejected(params: {
async function expectReadRemoteMediaBufferRejected(params: {
url: string;
fetchImpl: Parameters<typeof fetchRemoteMedia>[0]["fetchImpl"];
fetchImpl: Parameters<typeof readRemoteMediaBuffer>[0]["fetchImpl"];
maxBytes?: number;
readIdleTimeoutMs?: number;
lookupFn?: LookupFn;
@@ -106,12 +111,12 @@ async function expectFetchRemoteMediaRejected(params: {
...(params.readIdleTimeoutMs ? { readIdleTimeoutMs: params.readIdleTimeoutMs } : {}),
};
if (params.expectedError instanceof RegExp || typeof params.expectedError === "string") {
await expect(fetchRemoteMedia(request)).rejects.toThrow(params.expectedError);
await expect(readRemoteMediaBuffer(request)).rejects.toThrow(params.expectedError);
return;
}
let fetchError: unknown;
try {
await fetchRemoteMedia(request);
await readRemoteMediaBuffer(request);
} catch (error) {
fetchError = error;
}
@@ -121,26 +126,26 @@ async function expectFetchRemoteMediaRejected(params: {
}
}
async function expectFetchRemoteMediaResolvesToError(
params: Parameters<typeof fetchRemoteMedia>[0],
async function expectReadRemoteMediaBufferResolvesToError(
params: Parameters<typeof readRemoteMediaBuffer>[0],
): Promise<Error> {
const result = await fetchRemoteMedia(params).catch((err: unknown) => err);
const result = await readRemoteMediaBuffer(params).catch((err: unknown) => err);
expect(result).toBeInstanceOf(Error);
if (!(result instanceof Error)) {
expect.unreachable("expected fetchRemoteMedia to reject");
expect.unreachable("expected readRemoteMediaBuffer to reject");
}
return result;
}
async function expectFetchRemoteMediaIdleTimeoutCase(params: {
async function expectReadRemoteMediaBufferIdleTimeoutCase(params: {
lookupFn: LookupFn;
fetchImpl: Parameters<typeof fetchRemoteMedia>[0]["fetchImpl"];
fetchImpl: Parameters<typeof readRemoteMediaBuffer>[0]["fetchImpl"];
readIdleTimeoutMs: number;
expectedError: Record<string, unknown>;
}) {
vi.useFakeTimers();
try {
const rejection = expectFetchRemoteMediaRejected({
const rejection = expectReadRemoteMediaBufferRejected({
url: "https://example.com/file.bin",
fetchImpl: params.fetchImpl,
lookupFn: params.lookupFn,
@@ -156,10 +161,10 @@ async function expectFetchRemoteMediaIdleTimeoutCase(params: {
}
async function expectBoundedErrorBodyCase(
fetchImpl: Parameters<typeof fetchRemoteMedia>[0]["fetchImpl"],
fetchImpl: Parameters<typeof readRemoteMediaBuffer>[0]["fetchImpl"],
) {
const result = await expectFetchRemoteMediaResolvesToError(
createFetchRemoteMediaParams({
const result = await expectReadRemoteMediaBufferResolvesToError(
createReadRemoteMediaBufferParams({
url: "https://example.com/file.bin",
fetchImpl,
}),
@@ -170,7 +175,7 @@ async function expectBoundedErrorBodyCase(
async function expectPrivateIpFetchBlockedCase() {
const fetchImpl = vi.fn();
await expectFetchRemoteMediaRejected({
await expectReadRemoteMediaBufferRejected({
url: "http://127.0.0.1/secret.jpg",
fetchImpl,
expectedError: /private|internal|blocked/i,
@@ -178,8 +183,8 @@ async function expectPrivateIpFetchBlockedCase() {
expect(fetchImpl).not.toHaveBeenCalled();
}
function createFetchRemoteMediaParams(
params: Omit<Parameters<typeof fetchRemoteMedia>[0], "lookupFn"> & { lookupFn?: LookupFn },
function createReadRemoteMediaBufferParams(
params: Omit<Parameters<typeof readRemoteMediaBuffer>[0], "lookupFn"> & { lookupFn?: LookupFn },
) {
return {
lookupFn: params.lookupFn ?? makeLookupFn(),
@@ -188,14 +193,16 @@ function createFetchRemoteMediaParams(
};
}
describe("fetchRemoteMedia", () => {
describe("readRemoteMediaBuffer", () => {
const botToken = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcd";
const redactedBotToken = `${botToken.slice(0, 6)}${botToken.slice(-4)}`;
const botFileUrl = `https://files.example.test/file/bot${botToken}/photos/1.jpg`;
beforeAll(async () => {
tempHome = await createTempHomeEnv("openclaw-test-home-");
const fetchModule = await import("./fetch.js");
fetchRemoteMedia = fetchModule.fetchRemoteMedia;
readRemoteMediaBuffer = fetchModule.readRemoteMediaBuffer;
saveRemoteMedia = fetchModule.saveRemoteMedia;
defaultFetchMediaMaxBytes = fetchModule.DEFAULT_FETCH_MEDIA_MAX_BYTES;
});
@@ -222,6 +229,10 @@ describe("fetchRemoteMedia", () => {
});
});
afterAll(async () => {
await tempHome.restore();
});
it.each([
{
name: "rejects when content-length exceeds maxBytes",
@@ -252,7 +263,7 @@ describe("fetchRemoteMedia", () => {
);
await expect(
fetchRemoteMedia({
readRemoteMediaBuffer({
url: "https://example.com/file.bin",
fetchImpl,
lookupFn: makeLookupFn(),
@@ -297,7 +308,7 @@ describe("fetchRemoteMedia", () => {
},
},
] as const)("$name", async ({ lookupFn, fetchImpl, readIdleTimeoutMs, expectedError }) => {
await expectFetchRemoteMediaIdleTimeoutCase({
await expectReadRemoteMediaBufferIdleTimeoutCase({
lookupFn,
fetchImpl,
readIdleTimeoutMs,
@@ -339,7 +350,7 @@ describe("fetchRemoteMedia", () => {
allowPrivateProxy: true,
};
await fetchRemoteMedia({
await readRemoteMediaBuffer({
url: "https://files.example.test/file/bot123/photos/test.jpg",
fetchImpl,
lookupFn,
@@ -363,4 +374,227 @@ describe("fetchRemoteMedia", () => {
mode: "trusted_explicit_proxy",
});
});
it("streams successful responses directly into the media store", async () => {
const fetchImpl = vi.fn(
async () =>
new Response(makeStream([new Uint8Array([1, 2, 3]), new Uint8Array([4])]), {
status: 200,
headers: {
"content-disposition": 'attachment; filename="photo"',
"content-type": "image/png",
},
}),
);
const saved = await saveRemoteMedia({
url: "https://example.com/download",
fetchImpl,
lookupFn: makeLookupFn(),
maxBytes: 8,
});
expect(saved.fileName).toBe("photo");
expect(saved.contentType).toBe("image/png");
expect(saved.path).toMatch(/[a-f0-9-]{36}\.png$/);
expect(saved.path).not.toMatch(/photo---/);
await expect(fs.readFile(saved.path)).resolves.toStrictEqual(Buffer.from([1, 2, 3, 4]));
});
it("uses caller filename hints for MIME detection without preserving storage basenames", async () => {
const contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
const fetchImpl = vi.fn(
async () =>
new Response(makeStream([new Uint8Array([1, 2, 3])]), {
status: 200,
headers: { "content-type": "application/octet-stream" },
}),
);
const saved = await saveRemoteMedia({
url: "https://smba.trafficmanager.net/v3/attachments/att-1/views/original",
fetchImpl,
lookupFn: makeLookupFn(),
filePathHint: "document.docx",
maxBytes: 8,
});
expect(saved.fileName).toBe("document.docx");
expect(saved.contentType).toBe(contentType);
expect(saved.path).toMatch(/[a-f0-9-]{36}\.docx$/);
expect(saved.path).not.toMatch(/document---/);
await expect(fs.readFile(saved.path)).resolves.toStrictEqual(Buffer.from([1, 2, 3]));
});
it("does not let filename hints force stored extensions before byte sniffing", async () => {
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
const fetchImpl = vi.fn(
async () =>
new Response(makeStream([jpeg]), {
status: 200,
headers: { "content-type": "application/octet-stream" },
}),
);
const saved = await saveRemoteMedia({
url: "https://example.com/views/original",
fetchImpl,
lookupFn: makeLookupFn(),
filePathHint: "document.docx",
maxBytes: 8,
});
expect(saved.fileName).toBe("document.docx");
expect(saved.contentType).toBe("image/jpeg");
expect(saved.path).toMatch(/[a-f0-9-]{36}\.jpg$/);
expect(saved.path).not.toMatch(/\.docx$/);
expect(saved.path).not.toMatch(/document---/);
await expect(fs.readFile(saved.path)).resolves.toStrictEqual(jpeg);
});
it("preserves explicit original filenames when saving streams", async () => {
const contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
const fetchImpl = vi.fn(
async () =>
new Response(makeStream([new Uint8Array([1, 2, 3])]), {
status: 200,
headers: { "content-type": "application/octet-stream" },
}),
);
const saved = await saveRemoteMedia({
url: "https://smba.trafficmanager.net/v3/attachments/att-1/views/original",
fetchImpl,
lookupFn: makeLookupFn(),
filePathHint: "document.docx",
fallbackContentType: contentType,
originalFilename: "document.docx",
maxBytes: 8,
});
expect(saved.fileName).toBe("document.docx");
expect(saved.contentType).toBe(contentType);
expect(saved.path).toMatch(/document---.+\.docx$/);
});
it("uses fallback content type when streamed response headers are generic", async () => {
const contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
const fetchImpl = vi.fn(
async () =>
new Response(makeStream([new Uint8Array([4, 5, 6])]), {
status: 200,
headers: { "content-type": "application/octet-stream" },
}),
);
const saved = await saveRemoteMedia({
url: "https://example.com/views/original",
fetchImpl,
lookupFn: makeLookupFn(),
filePathHint: "document",
fallbackContentType: contentType,
maxBytes: 8,
});
expect(saved.fileName).toBe("document");
expect(saved.contentType).toBe(contentType);
expect(saved.path).toMatch(/[a-f0-9-]{36}\.docx$/);
expect(saved.path).not.toMatch(/document---/);
});
it("uses audio fallback content type when streamed response headers report matching video container", async () => {
const fetchImpl = vi.fn(
async () =>
new Response(makeStream([new Uint8Array([7, 8, 9])]), {
status: 200,
headers: { "content-type": "video/mp4" },
}),
);
const saved = await saveRemoteMedia({
url: "https://example.com/voice.mp4",
fetchImpl,
lookupFn: makeLookupFn(),
filePathHint: "voice.mp4",
fallbackContentType: "audio/mp4",
maxBytes: 8,
});
expect(saved.contentType).toBe("audio/mp4");
expect(saved.path).toMatch(/[a-f0-9-]{36}\.m4a$/);
});
it("cancels streamed response bodies when media save exceeds maxBytes", async () => {
const cancel = vi.fn();
const fetchImpl = vi.fn(
async () =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]));
controller.enqueue(new Uint8Array([4, 5, 6]));
},
cancel,
}),
{ status: 200 },
),
);
await expect(
saveRemoteMedia({
url: "https://example.com/large.bin",
fetchImpl,
lookupFn: makeLookupFn(),
maxBytes: 4,
}),
).rejects.toThrow("exceeds maxBytes");
expect(cancel).toHaveBeenCalledTimes(1);
});
it("retries saveRemoteMedia after a transient fetch failure", async () => {
const fetchImpl = vi
.fn()
.mockRejectedValueOnce(new TypeError("socket reset"))
.mockResolvedValueOnce(
new Response(makeStream([new Uint8Array([5, 6])]), {
status: 200,
headers: { "content-type": "image/png" },
}),
);
const onRetry = vi.fn();
const saved = await saveRemoteMedia({
url: "https://example.com/retry.png",
fetchImpl,
lookupFn: makeLookupFn(),
maxBytes: 8,
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0, onRetry },
});
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(onRetry).toHaveBeenCalledTimes(1);
expect(saved.contentType).toBe("image/png");
await expect(fs.readFile(saved.path)).resolves.toStrictEqual(Buffer.from([5, 6]));
});
it("does not retry permanent media limit failures", async () => {
const fetchImpl = vi.fn(
async () =>
new Response(makeStream([new Uint8Array([1, 2, 3, 4, 5])]), {
status: 200,
headers: { "content-length": "5" },
}),
);
await expect(
saveRemoteMedia({
url: "https://example.com/too-large.bin",
fetchImpl,
lookupFn: makeLookupFn(),
maxBytes: 4,
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
}),
).rejects.toThrow("exceeds maxBytes");
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
});

View File

@@ -6,10 +6,12 @@ import {
withTrustedExplicitProxyGuardedFetchMode,
} from "../infra/net/fetch-guard.js";
import type { LookupFn, PinnedDispatcherPolicy, SsrFPolicy } from "../infra/net/ssrf.js";
import { retryAsync, type RetryOptions } from "../infra/retry.js";
import { redactSensitiveText } from "../logging/redact.js";
import { MAX_DOCUMENT_BYTES } from "./constants.js";
import { detectMime, extensionForMime } from "./mime.js";
import { readResponseTextSnippet, readResponseWithLimit } from "./read-response-with-limit.js";
import { saveMediaBuffer, saveMediaStream, type SavedMedia } from "./store.js";
export const DEFAULT_FETCH_MEDIA_MAX_BYTES = MAX_DOCUMENT_BYTES;
@@ -19,14 +21,26 @@ type FetchMediaResult = {
fileName?: string;
};
export type SavedRemoteMedia = SavedMedia & {
fileName?: string;
};
export type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed";
export type MediaFetchRetryOptions = RetryOptions;
export class MediaFetchError extends Error {
readonly code: MediaFetchErrorCode;
readonly status?: number;
constructor(code: MediaFetchErrorCode, message: string, options?: { cause?: unknown }) {
constructor(
code: MediaFetchErrorCode,
message: string,
options?: { cause?: unknown; status?: number },
) {
super(message, options);
this.code = code;
this.status = options?.status;
this.name = "MediaFetchError";
}
}
@@ -52,6 +66,11 @@ type FetchMediaOptions = {
dispatcherPolicy?: PinnedDispatcherPolicy;
dispatcherAttempts?: FetchDispatcherAttempt[];
shouldRetryFetchError?: (error: unknown) => boolean;
/**
* Retries the complete guarded fetch/read-or-save operation. Dispatcher
* attempts still run inside each retry attempt.
*/
retry?: MediaFetchRetryOptions;
/**
* Allow an operator-configured explicit proxy to resolve target DNS after
* hostname-policy checks instead of forcing local pinned-DNS first.
@@ -59,6 +78,29 @@ type FetchMediaOptions = {
trustExplicitProxyDns?: boolean;
};
export type SaveResponseMediaOptions = {
sourceUrl?: string;
filePathHint?: string;
maxBytes?: number;
readIdleTimeoutMs?: number;
fallbackContentType?: string;
subdir?: string;
originalFilename?: string;
};
export type SaveRemoteMediaOptions = FetchMediaOptions & {
fallbackContentType?: string;
subdir?: string;
originalFilename?: string;
};
type GuardedMediaResponse = {
response: Response;
finalUrl: string;
release: (() => Promise<void>) | null;
sourceUrl: string;
};
function stripQuotes(value: string): string {
return value.replace(/^["']|["']$/g, "");
}
@@ -106,15 +148,14 @@ function redactMediaUrl(url: string): string {
return redactSensitiveText(url);
}
export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<FetchMediaResult> {
async function fetchGuardedMediaResponse(
options: FetchMediaOptions,
): Promise<GuardedMediaResponse> {
const {
url,
fetchImpl,
requestInit,
filePathHint,
maxBytes,
maxRedirects,
readIdleTimeoutMs,
ssrfPolicy,
lookupFn,
dispatcherPolicy,
@@ -124,9 +165,6 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
} = options;
const sourceUrl = redactMediaUrl(url);
let res: Response;
let finalUrl = url;
let release: (() => Promise<void>) | null = null;
const attempts =
dispatcherAttempts && dispatcherAttempts.length > 0
? dispatcherAttempts
@@ -180,9 +218,12 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
attemptErrors.push(err);
}
}
res = result.response;
finalUrl = result.finalUrl;
release = result.release;
return {
response: result.response,
finalUrl: result.finalUrl,
release: result.release,
sourceUrl,
};
} catch (err) {
throw new MediaFetchError(
"fetch_failed",
@@ -192,47 +233,363 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
},
);
}
}
async function assertMediaResponseOk(params: {
res: Response;
url: string;
finalUrl: string;
sourceUrl: string;
readIdleTimeoutMs?: number;
}): Promise<void> {
const { res, url, finalUrl, sourceUrl, readIdleTimeoutMs } = params;
if (res.ok) {
return;
}
const statusText = res.statusText ? ` ${res.statusText}` : "";
const redirected = finalUrl !== url ? ` (redirected to ${redactMediaUrl(finalUrl)})` : "";
let detail = `HTTP ${res.status}${statusText}`;
if (!res.body) {
detail = `HTTP ${res.status}${statusText}; empty response body`;
} else {
const snippet = await readErrorBodySnippet(res, { chunkTimeoutMs: readIdleTimeoutMs });
if (snippet) {
detail += `; body: ${snippet}`;
}
}
throw new MediaFetchError(
"http_error",
`Failed to fetch media from ${sourceUrl}${redirected}: ${redactSensitiveText(detail)}`,
{ status: res.status },
);
}
function assertMediaContentLength(params: {
res: Response;
sourceUrl: string;
maxBytes: number;
}): void {
const contentLength = params.res.headers.get("content-length");
if (!contentLength) {
return;
}
const length = Number(contentLength);
if (Number.isFinite(length) && length > params.maxBytes) {
throw new MediaFetchError(
"max_bytes",
`Failed to fetch media from ${params.sourceUrl}: content length ${length} exceeds maxBytes ${params.maxBytes}`,
);
}
}
function resolveRemoteFileName(params: {
res: Response;
finalUrl: string;
filePathHint?: string;
}): string | undefined {
let fileNameFromUrl: string | undefined;
try {
if (!res.ok) {
const statusText = res.statusText ? ` ${res.statusText}` : "";
const redirected = finalUrl !== url ? ` (redirected to ${redactMediaUrl(finalUrl)})` : "";
let detail = `HTTP ${res.status}${statusText}`;
if (!res.body) {
detail = `HTTP ${res.status}${statusText}; empty response body`;
} else {
const snippet = await readErrorBodySnippet(res, { chunkTimeoutMs: readIdleTimeoutMs });
if (snippet) {
detail += `; body: ${snippet}`;
}
const parsed = new URL(params.finalUrl);
const base = path.basename(parsed.pathname);
fileNameFromUrl = base || undefined;
} catch {
// ignore parse errors; leave undefined
}
const headerFileName = parseContentDispositionFileName(
params.res.headers.get("content-disposition"),
);
return (
headerFileName ||
(params.filePathHint ? path.basename(params.filePathHint) : undefined) ||
fileNameFromUrl
);
}
function isGenericResponseContentType(value?: string | null): boolean {
const normalized = value?.split(";")[0]?.trim().toLowerCase();
return (
!normalized ||
normalized === "application/octet-stream" ||
normalized === "binary/octet-stream" ||
normalized === "application/zip"
);
}
function resolveResponseContentType(params: {
headerContentType?: string | null;
fallbackContentType?: string;
}): string | undefined {
if (!params.fallbackContentType) {
return params.headerContentType ?? undefined;
}
if (isGenericResponseContentType(params.headerContentType)) {
return params.fallbackContentType;
}
const headerContentType = params.headerContentType?.split(";")[0]?.trim().toLowerCase();
const fallbackContentType = params.fallbackContentType.split(";")[0]?.trim().toLowerCase();
if (
headerContentType?.startsWith("video/") &&
fallbackContentType?.startsWith("audio/") &&
headerContentType.slice("video/".length) === fallbackContentType.slice("audio/".length)
) {
return params.fallbackContentType;
}
return params.headerContentType ?? params.fallbackContentType;
}
async function readChunkWithIdleTimeout(
reader: ReadableStreamDefaultReader<Uint8Array>,
chunkTimeoutMs: number,
): Promise<Awaited<ReturnType<typeof reader.read>>> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
return await new Promise((resolve, reject) => {
const clear = () => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
};
timeoutId = setTimeout(() => {
timedOut = true;
clear();
void reader.cancel().catch(() => undefined);
reject(new Error(`Media download stalled: no data received for ${chunkTimeoutMs}ms`));
}, chunkTimeoutMs);
void reader.read().then(
(result) => {
clear();
if (!timedOut) {
resolve(result);
}
},
(err) => {
clear();
if (!timedOut) {
reject(err);
}
},
);
});
}
async function* responseBodyChunks(
body: ReadableStream<Uint8Array>,
readIdleTimeoutMs?: number,
): AsyncIterable<Uint8Array> {
const reader = body.getReader();
let completed = false;
try {
while (true) {
const { done, value } = readIdleTimeoutMs
? await readChunkWithIdleTimeout(reader, readIdleTimeoutMs)
: await reader.read();
if (done) {
completed = true;
return;
}
if (value?.byteLength) {
yield value;
}
}
} finally {
if (!completed) {
await reader.cancel().catch(() => undefined);
}
try {
reader.releaseLock();
} catch {}
}
}
function isMediaLimitError(err: unknown): boolean {
return err instanceof Error && /Media exceeds .* limit/.test(err.message);
}
async function saveOkMediaResponse(params: {
res: Response;
finalUrl: string;
sourceUrl: string;
filePathHint?: string;
maxBytes: number;
readIdleTimeoutMs?: number;
fallbackContentType?: string;
subdir?: string;
originalFilename?: string;
}): Promise<SavedRemoteMedia> {
assertMediaContentLength({
res: params.res,
sourceUrl: params.sourceUrl,
maxBytes: params.maxBytes,
});
const fileName = resolveRemoteFileName({
res: params.res,
finalUrl: params.finalUrl,
filePathHint: params.filePathHint,
});
const contentType = resolveResponseContentType({
headerContentType: params.res.headers.get("content-type"),
fallbackContentType: params.fallbackContentType,
});
const detectionFilePathHint = isGenericResponseContentType(contentType)
? params.filePathHint
: undefined;
try {
const saved = params.res.body
? await saveMediaStream(
responseBodyChunks(params.res.body, params.readIdleTimeoutMs),
contentType ?? undefined,
params.subdir ?? "inbound",
params.maxBytes,
params.originalFilename,
detectionFilePathHint,
)
: await saveMediaBuffer(
Buffer.from(await params.res.arrayBuffer()),
contentType ?? undefined,
params.subdir ?? "inbound",
params.maxBytes,
params.originalFilename,
detectionFilePathHint,
);
return { ...saved, ...(fileName ? { fileName } : {}) };
} catch (err) {
if (err instanceof MediaFetchError) {
throw err;
}
if (isMediaLimitError(err)) {
throw new MediaFetchError(
"http_error",
`Failed to fetch media from ${sourceUrl}${redirected}: ${redactSensitiveText(detail)}`,
"max_bytes",
`Failed to fetch media from ${params.sourceUrl}: payload exceeds maxBytes ${params.maxBytes}`,
{ cause: err },
);
}
throw new MediaFetchError(
"fetch_failed",
`Failed to fetch media from ${params.sourceUrl}: ${formatErrorMessage(err)}`,
{ cause: err },
);
}
}
const effectiveMaxBytes = maxBytes ?? DEFAULT_FETCH_MEDIA_MAX_BYTES;
const contentLength = res.headers.get("content-length");
if (contentLength) {
const length = Number(contentLength);
if (Number.isFinite(length) && length > effectiveMaxBytes) {
throw new MediaFetchError(
"max_bytes",
`Failed to fetch media from ${sourceUrl}: content length ${length} exceeds maxBytes ${effectiveMaxBytes}`,
);
}
function shouldRetryMediaFetch(err: unknown): boolean {
if (err instanceof MediaFetchError) {
if (err.code === "max_bytes") {
return false;
}
if (err.code === "http_error") {
return typeof err.status === "number" && (err.status === 408 || err.status >= 500);
}
return true;
}
return true;
}
async function withMediaFetchRetry<T>(
options: FetchMediaOptions,
fn: () => Promise<T>,
): Promise<T> {
const retry = options.retry;
if (!retry) {
return await fn();
}
const callerShouldRetry = retry.shouldRetry;
return await retryAsync(fn, {
label: "media:fetch",
...retry,
shouldRetry: (err, attempt) =>
callerShouldRetry ? callerShouldRetry(err, attempt) : shouldRetryMediaFetch(err),
});
}
export async function saveResponseMedia(
res: Response,
options: SaveResponseMediaOptions = {},
): Promise<SavedRemoteMedia> {
const sourceUrl = redactMediaUrl((options.sourceUrl ?? res.url) || "response");
const finalUrl = options.sourceUrl ?? res.url;
await assertMediaResponseOk({
res,
url: options.sourceUrl ?? finalUrl,
finalUrl,
sourceUrl,
readIdleTimeoutMs: options.readIdleTimeoutMs,
});
return await saveOkMediaResponse({
res,
finalUrl,
sourceUrl,
filePathHint: options.filePathHint,
maxBytes: options.maxBytes ?? DEFAULT_FETCH_MEDIA_MAX_BYTES,
readIdleTimeoutMs: options.readIdleTimeoutMs,
fallbackContentType: options.fallbackContentType,
subdir: options.subdir,
originalFilename: options.originalFilename,
});
}
export async function saveRemoteMedia(options: SaveRemoteMediaOptions): Promise<SavedRemoteMedia> {
return await withMediaFetchRetry(options, () => saveRemoteMediaOnce(options));
}
async function saveRemoteMediaOnce(options: SaveRemoteMediaOptions): Promise<SavedRemoteMedia> {
const { response: res, finalUrl, release, sourceUrl } = await fetchGuardedMediaResponse(options);
try {
await assertMediaResponseOk({
res,
url: options.url,
finalUrl,
sourceUrl,
readIdleTimeoutMs: options.readIdleTimeoutMs,
});
return await saveOkMediaResponse({
res,
finalUrl,
sourceUrl,
filePathHint: options.filePathHint,
maxBytes: options.maxBytes ?? DEFAULT_FETCH_MEDIA_MAX_BYTES,
readIdleTimeoutMs: options.readIdleTimeoutMs,
fallbackContentType: options.fallbackContentType,
subdir: options.subdir,
originalFilename: options.originalFilename,
});
} finally {
if (release) {
await release();
}
}
}
export async function readRemoteMediaBuffer(options: FetchMediaOptions): Promise<FetchMediaResult> {
return await withMediaFetchRetry(options, () => readRemoteMediaBufferOnce(options));
}
/** @deprecated Use `readRemoteMediaBuffer` for buffer reads or `saveRemoteMedia` for URL-to-store. */
export const fetchRemoteMedia = readRemoteMediaBuffer;
async function readRemoteMediaBufferOnce(options: FetchMediaOptions): Promise<FetchMediaResult> {
const { response: res, finalUrl, release, sourceUrl } = await fetchGuardedMediaResponse(options);
try {
await assertMediaResponseOk({
res,
url: options.url,
finalUrl,
sourceUrl,
readIdleTimeoutMs: options.readIdleTimeoutMs,
});
const effectiveMaxBytes = options.maxBytes ?? DEFAULT_FETCH_MEDIA_MAX_BYTES;
assertMediaContentLength({ res, sourceUrl, maxBytes: effectiveMaxBytes });
let buffer: Buffer;
try {
buffer = await readResponseWithLimit(res, effectiveMaxBytes, {
onOverflow: ({ maxBytes, res }) =>
new MediaFetchError(
"max_bytes",
`Failed to fetch media from ${redactMediaUrl(res.url || url)}: payload exceeds maxBytes ${maxBytes}`,
`Failed to fetch media from ${redactMediaUrl(res.url || options.url)}: payload exceeds maxBytes ${maxBytes}`,
),
chunkTimeoutMs: readIdleTimeoutMs,
chunkTimeoutMs: options.readIdleTimeoutMs,
});
} catch (err) {
if (err instanceof MediaFetchError) {
@@ -240,25 +597,18 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
}
throw new MediaFetchError(
"fetch_failed",
`Failed to fetch media from ${redactMediaUrl(res.url || url)}: ${formatErrorMessage(err)}`,
`Failed to fetch media from ${redactMediaUrl(res.url || options.url)}: ${formatErrorMessage(err)}`,
{ cause: err },
);
}
let fileNameFromUrl: string | undefined;
try {
const parsed = new URL(finalUrl);
const base = path.basename(parsed.pathname);
fileNameFromUrl = base || undefined;
} catch {
// ignore parse errors; leave undefined
}
const headerFileName = parseContentDispositionFileName(res.headers.get("content-disposition"));
let fileName =
headerFileName || fileNameFromUrl || (filePathHint ? path.basename(filePathHint) : undefined);
let fileName = resolveRemoteFileName({
res,
finalUrl,
filePathHint: options.filePathHint,
});
const filePathForMime =
headerFileName && path.extname(headerFileName) ? headerFileName : (filePathHint ?? finalUrl);
fileName && path.extname(fileName) ? fileName : (options.filePathHint ?? finalUrl);
const contentType = await detectMime({
buffer,
headerMime: res.headers.get("content-type"),

View File

@@ -0,0 +1,26 @@
import { Readable } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import { readByteStreamWithLimit } from "./read-byte-stream-with-limit.js";
describe("readByteStreamWithLimit", () => {
it("returns concatenated bytes up to the limit", async () => {
const buffer = await readByteStreamWithLimit(Readable.from([Buffer.from("ab"), "cd"]), {
maxBytes: 4,
});
expect(buffer).toEqual(Buffer.from("abcd"));
});
it("throws and destroys node streams after overflow", async () => {
const stream = Readable.from([Buffer.alloc(4), Buffer.alloc(4)]);
const destroySpy = vi.spyOn(stream, "destroy");
await expect(
readByteStreamWithLimit(stream, {
maxBytes: 7,
onOverflow: ({ size, maxBytes }) => new Error(`too large ${size}/${maxBytes}`),
}),
).rejects.toThrow("too large 8/7");
expect(destroySpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,77 @@
export type ByteStreamLimitOverflow = {
size: number;
maxBytes: number;
};
export type ReadByteStreamWithLimitOptions = {
maxBytes: number;
onOverflow?: (params: ByteStreamLimitOverflow) => Error;
};
function normalizeByteChunk(chunk: unknown): Buffer {
if (Buffer.isBuffer(chunk)) {
return chunk;
}
if (typeof chunk === "string") {
return Buffer.from(chunk);
}
if (chunk instanceof ArrayBuffer) {
return Buffer.from(chunk);
}
if (ArrayBuffer.isView(chunk)) {
return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
}
throw new TypeError(`Unsupported byte stream chunk: ${typeof chunk}`);
}
function destroyReadableOnOverflow(stream: unknown, err: Error): void {
const readable = stream as {
destroy?: (error?: Error) => unknown;
cancel?: (reason?: unknown) => unknown;
};
if (typeof readable.destroy === "function") {
try {
readable.destroy(err);
} catch {}
return;
}
if (typeof readable.cancel === "function") {
try {
void readable.cancel(err);
} catch {}
}
}
export async function readByteStreamWithLimit(
stream: AsyncIterable<unknown>,
opts: ReadByteStreamWithLimitOptions,
): Promise<Buffer> {
const { maxBytes } = opts;
if (!Number.isFinite(maxBytes) || maxBytes < 0) {
throw new RangeError(`maxBytes must be a non-negative finite number: ${maxBytes}`);
}
const onOverflow =
opts.onOverflow ??
((params: ByteStreamLimitOverflow) =>
new Error(`Content too large: ${params.size} bytes (limit: ${params.maxBytes} bytes)`));
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of stream) {
const buffer = normalizeByteChunk(chunk);
if (buffer.byteLength === 0) {
continue;
}
const nextTotal = total + buffer.byteLength;
if (nextTotal > maxBytes) {
const err = onOverflow({ size: nextTotal, maxBytes });
destroyReadableOnOverflow(stream, err);
throw err;
}
chunks.push(buffer);
total = nextTotal;
}
return Buffer.concat(chunks, total);
}

View File

@@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
import JSZip from "jszip";
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
import sharp from "sharp";
@@ -405,6 +406,83 @@ describe("media store", () => {
await expectFailedBufferWriteCase();
},
},
{
name: "saves streams with detected extension without buffering first",
run: async () => {
await withTempStore(async (store) => {
const saved = await store.saveMediaStream(
Readable.from([Buffer.from([0xff, 0xd8, 0xff, 0x00])]),
undefined,
"stream-inbound",
1024,
"photo.bin",
);
expect(saved.id).toMatch(/^photo---[a-f0-9-]{36}\.jpg$/);
expect(saved.size).toBe(4);
expect(saved.contentType).toBe("image/jpeg");
await expect(fs.readFile(saved.path)).resolves.toEqual(
Buffer.from([0xff, 0xd8, 0xff, 0x00]),
);
});
},
},
{
name: "uses original filename to detect generic stream content type",
run: async () => {
await withTempStore(async (store) => {
const saved = await store.saveMediaStream(
Readable.from([Buffer.from("name,value\none,1\n")]),
"application/octet-stream",
"stream-inbound",
1024,
"report.csv",
);
expect(saved.id).toMatch(/^report---[a-f0-9-]{36}\.csv$/);
expect(saved.contentType).toBe("text/csv");
});
},
},
{
name: "prefers detected stream mime over generic zip header extension",
run: async () => {
await withTempStore(async (store) => {
const saved = await store.saveMediaStream(
Readable.from([Buffer.from("docx")]),
"application/zip",
"stream-inbound",
1024,
undefined,
"document.docx",
);
expect(saved.id).toMatch(/^[a-f0-9-]{36}\.docx$/);
expect(saved.contentType).toBe(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
);
});
},
},
{
name: "rejects oversized streams before writing a final artifact",
run: async () => {
await withTempStore(async (store, home) => {
await expect(
store.saveMediaStream(
Readable.from([Buffer.alloc(4), Buffer.alloc(4)]),
"application/octet-stream",
"oversized-stream",
7,
),
).rejects.toThrow("Media exceeds 0MB limit");
const targetDir = path.join(home, ".openclaw", "media", "oversized-stream");
const entries = await fs.readdir(targetDir).catch(() => []);
expect(entries).toStrictEqual([]);
});
},
},
{
name: "saves buffers when the best-effort fsync step reports EPERM",
run: async () => {
@@ -576,6 +654,14 @@ describe("media store", () => {
expectedContentType: "image/jpeg",
expectedExtension: ".jpg",
},
{
name: "uses original filename to detect generic buffer content type",
buffer: Buffer.from("name,value\none,1\n"),
contentType: "application/octet-stream",
originalFilename: "report.csv",
expectedContentType: "text/csv",
expectedExtension: ".csv",
},
{
name: "preserves original extension for generic file buffers",
buffer: Buffer.from("custom binary"),
@@ -598,7 +684,13 @@ describe("media store", () => {
...("originalFilename" in testCase
? {
assertSaved: async (saved: Awaited<ReturnType<typeof store.saveMediaBuffer>>) => {
expect(path.basename(saved.path)).toMatch(/^report---.+\.custom$/);
const escapedExtension = testCase.expectedExtension.replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&",
);
expect(path.basename(saved.path)).toMatch(
new RegExp(`^report---.+${escapedExtension}$`),
);
},
}
: {}),

View File

@@ -309,6 +309,17 @@ function safeOriginalFilenameExtension(originalFilename?: string): string | unde
return /^\.[a-z0-9]{1,16}$/.test(ext) ? ext : undefined;
}
function extensionForAuthoritativeHeaderMime(contentType?: string): string | undefined {
const mime = normalizeOptionalString(contentType?.split(";")[0]);
if (!mime || mime === "application/octet-stream" || mime === "binary/octet-stream") {
return undefined;
}
if (mime === "application/zip") {
return undefined;
}
return extensionForMime(mime);
}
function buildSavedMediaResult(params: {
dir: string;
id: string;
@@ -339,6 +350,52 @@ async function writeSavedMediaBuffer(params: {
);
}
async function writeMediaStreamToFile(params: {
stream: AsyncIterable<unknown>;
tempPath: string;
maxBytes: number;
}): Promise<{ sniffBuffer: Buffer; size: number }> {
const handle = await fs.open(params.tempPath, "wx", MEDIA_FILE_MODE);
const sniffChunks: Buffer[] = [];
let sniffLen = 0;
let total = 0;
try {
for await (const chunk of params.stream) {
const buffer = Buffer.isBuffer(chunk)
? chunk
: typeof chunk === "string"
? Buffer.from(chunk)
: chunk instanceof ArrayBuffer
? Buffer.from(chunk)
: ArrayBuffer.isView(chunk)
? Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
: undefined;
if (!buffer) {
throw new TypeError(`Unsupported media stream chunk: ${typeof chunk}`);
}
if (buffer.byteLength === 0) {
continue;
}
total += buffer.byteLength;
if (total > params.maxBytes) {
throw new Error(`Media exceeds ${formatMediaLimitMb(params.maxBytes)} limit`);
}
if (sniffLen < 16384) {
const remaining = 16384 - sniffLen;
sniffChunks.push(buffer.byteLength > remaining ? buffer.subarray(0, remaining) : buffer);
sniffLen += Math.min(buffer.byteLength, remaining);
}
await handle.write(buffer);
}
return {
sniffBuffer: Buffer.concat(sniffChunks, sniffLen),
size: total,
};
} finally {
await handle.close().catch(() => undefined);
}
}
export type SaveMediaSourceErrorCode =
| "invalid-path"
| "not-found"
@@ -452,6 +509,7 @@ export async function saveMediaBuffer(
subdir = "inbound",
maxBytes = MAX_BYTES,
originalFilename?: string,
detectionFilePathHint?: string,
): Promise<SavedMedia> {
if (buffer.byteLength > maxBytes) {
throw new Error(`Media exceeds ${formatMediaLimitMb(maxBytes)} limit`);
@@ -459,8 +517,12 @@ export async function saveMediaBuffer(
const dir = resolveMediaScopedDir(subdir, "saveMediaBuffer");
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
const uuid = crypto.randomUUID();
const headerExt = extensionForMime(normalizeOptionalString(contentType?.split(";")[0]));
const mime = await detectMime({ buffer, headerMime: contentType });
const headerExt = extensionForAuthoritativeHeaderMime(contentType);
const mime = await detectMime({
buffer,
headerMime: contentType,
filePath: originalFilename ?? detectionFilePathHint,
});
const ext =
headerExt ?? extensionForMime(mime) ?? safeOriginalFilenameExtension(originalFilename) ?? "";
const id = buildSavedMediaId({ baseId: uuid, ext, originalFilename });
@@ -468,6 +530,53 @@ export async function saveMediaBuffer(
return buildSavedMediaResult({ dir, id, size: buffer.byteLength, contentType: mime });
}
export async function saveMediaStream(
stream: AsyncIterable<unknown>,
contentType?: string,
subdir = "inbound",
maxBytes = MAX_BYTES,
originalFilename?: string,
detectionFilePathHint?: string,
): Promise<SavedMedia> {
const dir = resolveMediaScopedDir(subdir, "saveMediaStream");
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
const baseId = crypto.randomUUID();
const headerExt = extensionForAuthoritativeHeaderMime(contentType);
const saved = await retryAfterRecreatingDir(dir, () =>
writeSiblingTempFile<{ id: string; size: number; contentType?: string }>({
dir,
mode: MEDIA_FILE_MODE,
tempPrefix: `.${baseId}`,
writeTemp: async (tempPath) => {
const { sniffBuffer, size } = await writeMediaStreamToFile({
stream,
tempPath,
maxBytes,
});
const mime = await detectMime({
buffer: sniffBuffer,
headerMime: contentType,
filePath: originalFilename ?? detectionFilePathHint,
});
const ext =
headerExt ??
extensionForMime(mime) ??
safeOriginalFilenameExtension(originalFilename) ??
"";
const id = buildSavedMediaId({ baseId, ext, originalFilename });
return { id, size, contentType: mime };
},
resolveFinalPath: (result) => path.join(dir, result.id),
}),
);
return buildSavedMediaResult({
dir,
id: saved.result.id,
size: saved.result.size,
contentType: saved.result.contentType,
});
}
/**
* Resolves a media ID saved by saveMediaBuffer to its absolute physical path.
*

View File

@@ -7,7 +7,7 @@ import type { PinnedDispatcherPolicy, SsrFPolicy } from "../infra/net/ssrf.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { resolveUserPath } from "../utils.js";
import { maxBytesForKind, type MediaKind } from "./constants.js";
import { fetchRemoteMedia } from "./fetch.js";
import { readRemoteMediaBuffer } from "./fetch.js";
import {
convertHeicToJpeg,
hasAlphaChannel,
@@ -532,7 +532,7 @@ async function loadWebMediaInternal(
allowPrivateProxy: true,
}
: undefined;
const fetched = await fetchRemoteMedia({
const fetched = await readRemoteMediaBuffer({
url: mediaUrl,
fetchImpl,
requestInit,

View File

@@ -20,7 +20,11 @@ export {
} from "./test-helpers/outbound-delivery.js";
/** @deprecated Direct outbound delivery is runtime substrate; use channel message runtime helpers. */
export { deliverOutboundPayloads } from "./test-helpers/outbound-delivery.js";
export { createPluginRuntimeMock } from "./test-helpers/plugin-runtime-mock.js";
export {
createPluginRuntimeMediaMock,
createPluginRuntimeMock,
type PluginRuntimeMediaMock,
} from "./test-helpers/plugin-runtime-mock.js";
export {
createSendCfgThreadingRuntime,
expectProvidedCfgSkipsRuntimeLoad,

View File

@@ -20,6 +20,7 @@ export * from "../media/outbound-attachment.js";
export * from "../media/png-encode.ts";
export * from "../media/qr-image.ts";
export * from "../media/qr-terminal.ts";
export * from "../media/read-byte-stream-with-limit.js";
export * from "../media/read-response-with-limit.js";
export * from "../media/store.js";
export * from "../media/temp-files.js";

View File

@@ -1,3 +1,9 @@
// Narrow media store helpers for channel runtimes that do not need the full media runtime.
export { readMediaBuffer, resolveMediaBufferPath, saveMediaBuffer } from "../media/store.js";
export {
readMediaBuffer,
resolveMediaBufferPath,
saveMediaBuffer,
saveMediaStream,
} from "../media/store.js";
export type { SavedMedia } from "../media/store.js";

View File

@@ -1,3 +1,4 @@
// Narrow response-size reader for plugins that download bounded HTTP bodies.
export { readByteStreamWithLimit } from "../media/read-byte-stream-with-limit.js";
export { readResponseWithLimit } from "../media/read-response-with-limit.js";

View File

@@ -69,6 +69,33 @@ function createDeprecatedRuntimeConfigError(name: "loadConfig" | "writeConfigFil
);
}
export type PluginRuntimeMediaMock = PluginRuntime["channel"]["media"];
export function createPluginRuntimeMediaMock(
overrides: Partial<PluginRuntimeMediaMock> = {},
): PluginRuntimeMediaMock {
const readRemoteMediaBuffer =
vi.fn() as unknown as PluginRuntimeMediaMock["readRemoteMediaBuffer"];
return {
readRemoteMediaBuffer,
fetchRemoteMedia:
readRemoteMediaBuffer as unknown as PluginRuntimeMediaMock["fetchRemoteMedia"],
saveRemoteMedia: vi.fn().mockResolvedValue({
path: "/tmp/test-media.jpg",
contentType: "image/jpeg",
}) as unknown as PluginRuntimeMediaMock["saveRemoteMedia"],
saveResponseMedia: vi.fn().mockResolvedValue({
path: "/tmp/test-media.jpg",
contentType: "image/jpeg",
}) as unknown as PluginRuntimeMediaMock["saveResponseMedia"],
saveMediaBuffer: vi.fn().mockResolvedValue({
path: "/tmp/test-media.jpg",
contentType: "image/jpeg",
}) as unknown as PluginRuntimeMediaMock["saveMediaBuffer"],
...overrides,
};
}
export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime {
const taskFlow = {
bindSession: vi.fn(
@@ -524,14 +551,7 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
created: true,
}) as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
},
media: {
fetchRemoteMedia:
vi.fn() as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
saveMediaBuffer: vi.fn().mockResolvedValue({
path: "/tmp/test-media.jpg",
contentType: "image/jpeg",
}) as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
},
media: createPluginRuntimeMediaMock(),
session: {
resolveStorePath: vi.fn(
() => "/tmp/sessions.json",

View File

@@ -65,7 +65,7 @@ describe("plugin-sdk exports", () => {
"resolveStateDir",
"writeConfigFile",
"enqueueSystemEvent",
"fetchRemoteMedia",
"readRemoteMediaBuffer",
"saveMediaBuffer",
"formatAgentEnvelope",
"buildPairingReply",

View File

@@ -88,7 +88,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
'export { GoogleChatConfigSchema } from "openclaw/plugin-sdk/bundled-channel-config-schema";',
'export { GROUP_POLICY_BLOCKED_LABEL, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce } from "openclaw/plugin-sdk/runtime-group-policy";',
'export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";',
'export { fetchRemoteMedia, resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";',
'export { readRemoteMediaBuffer, resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";',
'export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";',
'export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";',
'export { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";',

View File

@@ -70,7 +70,12 @@ import {
} from "../../config/sessions.js";
import { getChannelActivity, recordChannelActivity } from "../../infra/channel-activity.js";
import { convertMarkdownTables } from "../../markdown/tables.js";
import { fetchRemoteMedia } from "../../media/fetch.js";
import {
fetchRemoteMedia,
readRemoteMediaBuffer,
saveRemoteMedia,
saveResponseMedia,
} from "../../media/fetch.js";
import { saveMediaBuffer } from "../../media/store.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
@@ -128,7 +133,10 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
}),
},
media: {
readRemoteMediaBuffer,
fetchRemoteMedia,
saveRemoteMedia,
saveResponseMedia,
saveMediaBuffer,
},
activity: {

View File

@@ -107,7 +107,11 @@ export type PluginRuntimeChannel = {
upsertPairingRequest: UpsertChannelPairingRequestForAccount;
};
media: {
readRemoteMediaBuffer: typeof import("../../media/fetch.js").readRemoteMediaBuffer;
/** @deprecated Use `readRemoteMediaBuffer`. */
fetchRemoteMedia: typeof import("../../media/fetch.js").fetchRemoteMedia;
saveRemoteMedia: typeof import("../../media/fetch.js").saveRemoteMedia;
saveResponseMedia: typeof import("../../media/fetch.js").saveResponseMedia;
saveMediaBuffer: typeof import("../../media/store.js").saveMediaBuffer;
};
activity: {