mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
refactor(media): centralize bounded remote downloads
Co-authored-by: samzong <samzong.lu@gmail.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -335,6 +335,7 @@ vi.mock("./send.js", () => ({
|
||||
|
||||
vi.mock("./media.js", () => ({
|
||||
downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu,
|
||||
saveMessageResourceFeishu: mockDownloadMessageResourceFeishu,
|
||||
}));
|
||||
|
||||
vi.mock("./audio-preflight.runtime.js", () => ({
|
||||
|
||||
@@ -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: "",
|
||||
});
|
||||
|
||||
expect(fetchRemoteMediaMock).toHaveBeenCalled();
|
||||
expect(readRemoteMediaBufferMock).toHaveBeenCalled();
|
||||
expect(driveUploadAllMock).not.toHaveBeenCalled();
|
||||
expect(blockPatchMock).not.toHaveBeenCalled();
|
||||
expect(result.details.images_processed).toBe(0);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ export {
|
||||
buildChannelConfigSchema,
|
||||
chunkTextForOutbound,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
fetchRemoteMedia,
|
||||
readRemoteMediaBuffer,
|
||||
GoogleChatConfigSchema,
|
||||
loadOutboundMediaFromUrl,
|
||||
missingTargetError,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -236,7 +236,7 @@ function createRuntimeCore(cfg: OpenClawConfig) {
|
||||
resolveRequireMention: () => false,
|
||||
},
|
||||
media: {
|
||||
fetchRemoteMedia: vi.fn(),
|
||||
readRemoteMediaBuffer: vi.fn(),
|
||||
saveMediaBuffer: vi.fn(),
|
||||
},
|
||||
mentions: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
fetchRemoteMedia,
|
||||
readRemoteMediaBuffer,
|
||||
MediaFetchError,
|
||||
saveMediaBuffer,
|
||||
saveRemoteMedia,
|
||||
} from "openclaw/plugin-sdk/media-runtime";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
65
scripts/check-media-download-helper-roundtrip.mjs
Normal file
65
scripts/check-media-download-helper-roundtrip.mjs
Normal 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;
|
||||
}
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
26
src/media/read-byte-stream-with-limit.test.ts
Normal file
26
src/media/read-byte-stream-with-limit.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
77
src/media/read-byte-stream-with-limit.ts
Normal file
77
src/media/read-byte-stream-with-limit.ts
Normal 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);
|
||||
}
|
||||
@@ -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}$`),
|
||||
);
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("plugin-sdk exports", () => {
|
||||
"resolveStateDir",
|
||||
"writeConfigFile",
|
||||
"enqueueSystemEvent",
|
||||
"fetchRemoteMedia",
|
||||
"readRemoteMediaBuffer",
|
||||
"saveMediaBuffer",
|
||||
"formatAgentEnvelope",
|
||||
"buildPairingReply",
|
||||
|
||||
@@ -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";',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user