From 53d007bc878caf4676164bbfbac6966a2b9e0a6e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 13 May 2026 15:02:53 +0100 Subject: [PATCH] refactor(media): centralize bounded remote downloads Co-authored-by: samzong --- CHANGELOG.md | 1 + docs/plugins/sdk-runtime.md | 13 + docs/plugins/sdk-subpaths.md | 4 +- .../discord/src/monitor/message-media.ts | 38 +- .../discord/src/monitor/message-utils.test.ts | 87 ++-- extensions/feishu/src/bot-content.ts | 73 +-- extensions/feishu/src/bot.test.ts | 1 + extensions/feishu/src/docx.test.ts | 8 +- extensions/feishu/src/docx.ts | 4 +- extensions/feishu/src/media.test.ts | 75 +++ extensions/feishu/src/media.ts | 216 ++++++++- extensions/googlechat/runtime-api.ts | 5 +- extensions/googlechat/src/actions.test.ts | 8 +- extensions/googlechat/src/actions.ts | 2 +- extensions/googlechat/src/api.ts | 25 +- extensions/googlechat/src/channel.adapters.ts | 4 +- .../googlechat/src/channel.deps.runtime.ts | 2 +- extensions/googlechat/src/channel.test.ts | 26 +- .../googlechat/src/monitor-reply-delivery.ts | 2 +- .../src/monitor.reply-delivery.test.ts | 2 +- extensions/line/src/download.test.ts | 68 ++- extensions/line/src/download.ts | 71 +-- extensions/matrix/src/test-runtime.ts | 6 +- .../src/mattermost/monitor-resources.test.ts | 17 +- .../src/mattermost/monitor-resources.ts | 27 +- .../monitor.inbound-system-event.test.ts | 2 +- .../mattermost/src/mattermost/monitor.ts | 4 +- .../msteams/src/attachments.graph.test.ts | 79 ++- extensions/msteams/src/attachments.test.ts | 71 ++- .../src/attachments/bot-framework.test.ts | 25 +- .../msteams/src/attachments/bot-framework.ts | 82 ++-- .../msteams/src/attachments/download.ts | 2 +- extensions/msteams/src/attachments/graph.ts | 19 +- .../src/attachments/remote-media.test.ts | 72 ++- .../msteams/src/attachments/remote-media.ts | 74 +-- extensions/qqbot/src/bridge/bootstrap.ts | 10 +- .../qqbot/src/engine/utils/image-size.ts | 2 +- extensions/signal/src/client-container.ts | 34 +- extensions/slack/src/monitor/media.runtime.ts | 6 +- extensions/slack/src/monitor/media.test.ts | 120 +++-- extensions/slack/src/monitor/media.ts | 74 +-- .../telegram/src/bot.media.e2e-harness.ts | 39 +- .../telegram/src/bot.media.test-utils.ts | 16 +- .../bot/delivery.resolve-media-retry.test.ts | 88 ++-- .../src/bot/delivery.resolve-media.runtime.ts | 10 +- .../src/bot/delivery.resolve-media.ts | 23 +- .../telegram/src/telegram-media.runtime.ts | 3 +- extensions/tlon/src/monitor/media.test.ts | 35 +- extensions/tlon/src/monitor/media.ts | 19 +- extensions/whatsapp/src/inbound.media.test.ts | 33 +- .../whatsapp/src/inbound/media.node.test.ts | 56 ++- extensions/whatsapp/src/inbound/media.ts | 36 +- extensions/whatsapp/src/inbound/monitor.ts | 21 +- .../zalo/src/monitor.image.polling.test.ts | 11 +- extensions/zalo/src/monitor.ts | 8 +- extensions/zalo/src/monitor.webhook.test.ts | 9 +- .../test-support/lifecycle-test-support.ts | 28 +- package.json | 1 + scripts/check-changed.mjs | 2 + .../check-media-download-helper-roundtrip.mjs | 65 +++ scripts/check.mjs | 1 + .../apply.echo-transcript.test.ts | 6 +- src/media-understanding/apply.test.ts | 12 +- src/media-understanding/attachments.cache.ts | 4 +- .../media-understanding-url-fallback.test.ts | 24 +- src/media/fetch.test.ts | 294 ++++++++++-- src/media/fetch.ts | 450 ++++++++++++++++-- src/media/read-byte-stream-with-limit.test.ts | 26 + src/media/read-byte-stream-with-limit.ts | 77 +++ src/media/store.test.ts | 94 +++- src/media/store.ts | 113 ++++- src/media/web-media.ts | 4 +- src/plugin-sdk/channel-test-helpers.ts | 6 +- src/plugin-sdk/media-runtime.ts | 1 + src/plugin-sdk/media-store.ts | 8 +- src/plugin-sdk/response-limit-runtime.ts | 1 + .../test-helpers/plugin-runtime-mock.ts | 36 +- .../contracts/plugin-sdk-index.test.ts | 2 +- .../plugin-sdk-runtime-api-guardrails.test.ts | 2 +- src/plugins/runtime/runtime-channel.ts | 10 +- src/plugins/runtime/types-channel.ts | 4 + 81 files changed, 2321 insertions(+), 818 deletions(-) create mode 100644 scripts/check-media-download-helper-roundtrip.mjs create mode 100644 src/media/read-byte-stream-with-limit.test.ts create mode 100644 src/media/read-byte-stream-with-limit.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ce385501ff..284203ad570 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index f0e0147e25e..fd172d991e4 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -542,6 +542,19 @@ two-party event loops that do not go through the shared channel-turn kernel. 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 diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 8c3fca0322e..aef26790d0c 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -302,9 +302,9 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`, | 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 | diff --git a/extensions/discord/src/monitor/message-media.ts b/extensions/discord/src/monitor/message-media.ts index 331a5940241..776c26b0f7b 100644 --- a/extensions/discord/src/monitor/message-media.ts +++ b/extensions/discord/src/monitor/message-media.ts @@ -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 | 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((_, 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, diff --git a/extensions/discord/src/monitor/message-utils.test.ts b/extensions/discord/src/monitor/message-utils.test.ts index be1da869b93..57982726f51 100644 --- a/extensions/discord/src/monitor/message-utils.test.ts +++ b/extensions/discord/src/monitor/message-utils.test.ts @@ -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 { - 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: "" | ""; }) { - 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", }); diff --git a/extensions/feishu/src/bot-content.ts b/extensions/feishu/src/bot-content.ts index 9d7cea86ec7..a04415e87f0 100644 --- a/extensions/feishu/src/bot-content.ts +++ b/extensions/feishu/src/bot-content.ts @@ -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> + | { 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, diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 4475c7b46d6..428171769c9 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -335,6 +335,7 @@ vi.mock("./send.js", () => ({ vi.mock("./media.js", () => ({ downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu, + saveMessageResourceFeishu: mockDownloadMessageResourceFeishu, })); vi.mock("./audio-preflight.runtime.js", () => ({ diff --git a/extensions/feishu/src/docx.test.ts b/extensions/feishu/src/docx.test.ts index baecb6eb0f5..898a69c8d9a 100644 --- a/extensions/feishu/src/docx.test.ts +++ b/extensions/feishu/src/docx.test.ts @@ -5,7 +5,7 @@ import { createToolFactoryHarness, type ToolLike } from "./tool-factory-test-har const createFeishuClientMock = vi.hoisted(() => vi.fn()); const resolveFeishuToolAccountMock = vi.hoisted(() => vi.fn()); -const fetchRemoteMediaMock = vi.hoisted(() => vi.fn()); +const readRemoteMediaBufferMock = vi.hoisted(() => vi.fn()); const loadWebMediaMock = vi.hoisted(() => vi.fn()); const convertMock = vi.hoisted(() => vi.fn()); const documentCreateMock = vi.hoisted(() => vi.fn()); @@ -40,7 +40,7 @@ vi.spyOn(runtimeModule, "getFeishuRuntime").mockImplementation( ({ channel: { media: { - fetchRemoteMedia: fetchRemoteMediaMock, + readRemoteMediaBuffer: readRemoteMediaBufferMock, saveMediaBuffer: vi.fn(), }, }, @@ -389,7 +389,7 @@ describe("feishu_doc image fetch hardening", () => { it("skips image upload when markdown image URL is blocked", async () => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - fetchRemoteMediaMock.mockRejectedValueOnce( + readRemoteMediaBufferMock.mockRejectedValueOnce( new Error("Blocked: resolves to private/internal IP address"), ); @@ -401,7 +401,7 @@ describe("feishu_doc image fetch hardening", () => { content: "![x](https://x.test/image.png)", }); - expect(fetchRemoteMediaMock).toHaveBeenCalled(); + expect(readRemoteMediaBufferMock).toHaveBeenCalled(); expect(driveUploadAllMock).not.toHaveBeenCalled(); expect(blockPatchMock).not.toHaveBeenCalled(); expect(result.details.images_processed).toBe(0); diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index 0056b88c138..1cd7c8c43d3 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -510,7 +510,7 @@ async function uploadImageToDocx( } async function downloadImage(url: string, maxBytes: number): Promise { - 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 { diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index bf47cf82480..4d0b2e713f5 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -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 }); + } + }); }); diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index e894483e0fc..571075dc0ad 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -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; client: ReturnType; @@ -242,25 +250,29 @@ function extractFeishuDownloadMetadata(response: FeishuDownloadResponse): { return { contentType, fileName }; } -async function readReadableBuffer(stream: Readable): Promise { - 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 { - 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; - 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 { + 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; + }; + 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; + 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 { - 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 { 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; + messageId: string; + fileKey: string; + type: FeishuMessageResourceDownloadType; + maxBytes: number; + originalFilename?: string; +}): Promise { + 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 { - 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 { + 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; diff --git a/extensions/googlechat/runtime-api.ts b/extensions/googlechat/runtime-api.ts index 1518fa65ae9..da46bcc03bd 100644 --- a/extensions/googlechat/runtime-api.ts +++ b/extensions/googlechat/runtime-api.ts @@ -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"; diff --git a/extensions/googlechat/src/actions.test.ts b/extensions/googlechat/src/actions.test.ts index 1895e47dc0a..e18f774c206 100644 --- a/extensions/googlechat/src/actions.test.ts +++ b/extensions/googlechat/src/actions.test.ts @@ -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(), }, }, }); diff --git a/extensions/googlechat/src/actions.ts b/extensions/googlechat/src/actions.ts index 90dfe0dc52b..21650fe502b 100644 --- a/extensions/googlechat/src/actions.ts +++ b/extensions/googlechat/src/actions.ts @@ -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, }) diff --git a/extensions/googlechat/src/api.ts b/extensions/googlechat/src/api.ts index c301a556358..d0ba940368e 100644 --- a/extensions/googlechat/src/api.ts +++ b/extensions/googlechat/src/api.ts @@ -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 }; }, diff --git a/extensions/googlechat/src/channel.adapters.ts b/extensions/googlechat/src/channel.adapters.ts index 83acd5e9a4a..6a68727c7d0 100644 --- a/extensions/googlechat/src/channel.adapters.ts +++ b/extensions/googlechat/src/channel.adapters.ts @@ -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, }) diff --git a/extensions/googlechat/src/channel.deps.runtime.ts b/extensions/googlechat/src/channel.deps.runtime.ts index 1011f869d21..cf00d2be50c 100644 --- a/extensions/googlechat/src/channel.deps.runtime.ts +++ b/extensions/googlechat/src/channel.deps.runtime.ts @@ -2,7 +2,7 @@ export { buildChannelConfigSchema, chunkTextForOutbound, DEFAULT_ACCOUNT_ID, - fetchRemoteMedia, + readRemoteMediaBuffer, GoogleChatConfigSchema, loadOutboundMediaFromUrl, missingTargetError, diff --git a/extensions/googlechat/src/channel.test.ts b/extensions/googlechat/src/channel.test.ts index 64967259875..789c256a913 100644 --- a/extensions/googlechat/src/channel.test.ts +++ b/extensions/googlechat/src/channel.test.ts @@ -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, 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; }; diff --git a/extensions/googlechat/src/monitor-reply-delivery.ts b/extensions/googlechat/src/monitor-reply-delivery.ts index 0257316cc48..835fbb4dd07 100644 --- a/extensions/googlechat/src/monitor-reply-delivery.ts +++ b/extensions/googlechat/src/monitor-reply-delivery.ts @@ -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, }); diff --git a/extensions/googlechat/src/monitor.reply-delivery.test.ts b/extensions/googlechat/src/monitor.reply-delivery.test.ts index 4a8317e1ecb..0a7ac390f65 100644 --- a/extensions/googlechat/src/monitor.reply-delivery.test.ts +++ b/extensions/googlechat/src/monitor.reply-delivery.test.ts @@ -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; diff --git a/extensions/line/src/download.test.ts b/extensions/line/src/download.test.ts index aefc7d21773..805279c8d11 100644 --- a/extensions/line/src/download.test.ts +++ b/extensions/line/src/download.test.ts @@ -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 { } } -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, 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); }); diff --git a/extensions/line/src/download.ts b/extensions/line/src/download.ts index d372ffaafbc..cc476e6102e 100644 --- a/extensions/line/src/download.ts +++ b/extensions/line/src/download.ts @@ -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) { - 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, + 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"; -} diff --git a/extensions/matrix/src/test-runtime.ts b/extensions/matrix/src/test-runtime.ts index 4cbe73f0357..af20589daab 100644 --- a/extensions/matrix/src/test-runtime.ts +++ b/extensions/matrix/src/test-runtime.ts @@ -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"], }, }); } diff --git a/extensions/mattermost/src/mattermost/monitor-resources.test.ts b/extensions/mattermost/src/mattermost/monitor-resources.test.ts index e661c6a3596..5e53ce5540e 100644 --- a/extensions/mattermost/src/mattermost/monitor-resources.test.ts +++ b/extensions/mattermost/src/mattermost/monitor-resources.test.ts @@ -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", }); diff --git a/extensions/mattermost/src/mattermost/monitor-resources.ts b/extensions/mattermost/src/mattermost/monitor-resources.ts index 5b34a90f9e2..cdca5337731 100644 --- a/extensions/mattermost/src/mattermost/monitor-resources.ts +++ b/extensions/mattermost/src/mattermost/monitor-resources.ts @@ -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(); @@ -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, diff --git a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts index 1a49d5ba80b..e9f726ce612 100644 --- a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts @@ -236,7 +236,7 @@ function createRuntimeCore(cfg: OpenClawConfig) { resolveRequireMention: () => false, }, media: { - fetchRemoteMedia: vi.fn(), + readRemoteMediaBuffer: vi.fn(), saveMediaBuffer: vi.fn(), }, mentions: { diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 01d80f8befe..14ff0eaebd8 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -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, }); diff --git a/extensions/msteams/src/attachments.graph.test.ts b/extensions/msteams/src/attachments.graph.test.ts index cba3be2d9fb..9722f33d976 100644 --- a/extensions/msteams/src/attachments.graph.test.ts +++ b/extensions/msteams/src/attachments.graph.test.ts @@ -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; + }) => { + 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); }); diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index 073f84b3da8..d270f09fe60 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -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); }); diff --git a/extensions/msteams/src/attachments/bot-framework.test.ts b/extensions/msteams/src/attachments/bot-framework.test.ts index cd3f20ee58f..185fbfcdcdc 100644 --- a/extensions/msteams/src/attachments/bot-framework.test.ts +++ b/extensions/msteams/src/attachments/bot-framework.test.ts @@ -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[0]); diff --git a/extensions/msteams/src/attachments/bot-framework.ts b/extensions/msteams/src/attachments/bot-framework.ts index 7ec8ce43b34..a1ebe475d27 100644 --- a/extensions/msteams/src/attachments/bot-framework.ts +++ b/extensions/msteams/src/attachments/bot-framework.ts @@ -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 { +}): 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 }), + }; } /** diff --git a/extensions/msteams/src/attachments/download.ts b/extensions/msteams/src/attachments/download.ts index 40d07c7cb86..34960d9751e 100644 --- a/extensions/msteams/src/attachments/download.ts +++ b/extensions/msteams/src/attachments/download.ts @@ -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, diff --git a/extensions/msteams/src/attachments/graph.ts b/extensions/msteams/src/attachments/graph.ts index 92b0d3dd8cb..6a51fcdfd34 100644 --- a/extensions/msteams/src/attachments/graph.ts +++ b/extensions/msteams/src/attachments/graph.ts @@ -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; } diff --git a/extensions/msteams/src/attachments/remote-media.test.ts b/extensions/msteams/src/attachments/remote-media.test.ts index edb44631a38..4bfa83d1d73 100644 --- a/extensions/msteams/src/attachments/remote-media.test.ts +++ b/extensions/msteams/src/attachments/remote-media.test.ts @@ -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): 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); }); }); }); diff --git a/extensions/msteams/src/attachments/remote-media.ts b/extensions/msteams/src/attachments/remote-media.ts index 71516e2993f..1b6b3858a8a 100644 --- a/extensions/msteams/src/attachments/remote-media.ts +++ b/extensions/msteams/src/attachments/remote-media.ts @@ -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; -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 { + contentTypeHint?: string; + originalFilename?: string; +}): Promise { 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 { - 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, diff --git a/extensions/qqbot/src/bridge/bootstrap.ts b/extensions/qqbot/src/bridge/bootstrap.ts index 2820d1867b4..eb0b9462329 100644 --- a/extensions/qqbot/src/bridge/bootstrap.ts +++ b/extensions/qqbot/src/bridge/bootstrap.ts @@ -41,7 +41,7 @@ import { getBridgeLogger } from "./logger.js"; function createBuiltinAdapter(): PlatformAdapter { return { async validateRemoteUrl(_url: string, _options?: { allowPrivate?: boolean }): Promise { - // Built-in version delegates SSRF validation to fetchRemoteMedia's ssrfPolicy. + // Built-in version delegates SSRF validation to readRemoteMediaBuffer's ssrfPolicy. }, async resolveSecret(value): Promise { @@ -52,8 +52,8 @@ function createBuiltinAdapter(): PlatformAdapter { }, async downloadFile(url: string, destDir: string, filename?: string): Promise { - 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 { - 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, diff --git a/extensions/qqbot/src/engine/utils/image-size.ts b/extensions/qqbot/src/engine/utils/image-size.ts index f3c6a56a8bd..76eaf559b7e 100644 --- a/extensions/qqbot/src/engine/utils/image-size.ts +++ b/extensions/qqbot/src/engine/utils/image-size.ts @@ -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( diff --git a/extensions/signal/src/client-container.ts b/extensions/signal/src/client-container.ts index 633384370ad..5a72ad60c45 100644 --- a/extensions/signal/src/client-container.ts +++ b/extensions/signal/src/client-container.ts @@ -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"), + }); } /** diff --git a/extensions/slack/src/monitor/media.runtime.ts b/extensions/slack/src/monitor/media.runtime.ts index 457db077373..ca85bd61162 100644 --- a/extensions/slack/src/monitor/media.runtime.ts +++ b/extensions/slack/src/monitor/media.runtime.ts @@ -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"; diff --git a/extensions/slack/src/monitor/media.test.ts b/extensions/slack/src/monitor/media.test.ts index a3749dc82a0..a9efb7e6a29 100644 --- a/extensions/slack/src/monitor/media.test.ts +++ b/extensions/slack/src/monitor/media.test.ts @@ -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[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>; 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[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((_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("login", { 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); diff --git a/extensions/slack/src/monitor/media.ts b/extensions/slack/src/monitor/media.ts index 87ce948cab5..069bde82003 100644 --- a/extensions/slack/src/monitor/media.ts +++ b/extensions/slack/src/monitor/media.ts @@ -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[0]; +type SlackSaveRemoteMediaOptions = Parameters[0]; function mergeAbortSignals(signals: Array): AbortSignal | undefined { const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal)); @@ -178,12 +174,12 @@ function mergeAbortSignals(signals: Array): AbortSignal return controller.signal; } -async function fetchSlackMedia(params: { - options: SlackFetchRemoteMediaOptions; +async function saveSlackMedia(params: { + options: SlackSaveRemoteMediaOptions; readIdleTimeoutMs?: number; totalTimeoutMs?: number; abortSignal?: AbortSignal; -}): ReturnType { +}): ReturnType { 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 | 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((_, 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(" { + 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 { 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 } diff --git a/extensions/telegram/src/bot.media.e2e-harness.ts b/extensions/telegram/src/bot.media.e2e-harness.ts index 05197bdb1b3..399a464449d 100644 --- a/extensions/telegram/src/bot.media.e2e-harness.ts +++ b/extensions/telegram/src/bot.media.e2e-harness.ts @@ -11,7 +11,8 @@ type TelegramBotRuntimeForTest = NonNullable< type DispatchReplyWithBufferedBlockDispatcherFn = typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithBufferedBlockDispatcher; type DispatchReplyHarnessParams = Parameters[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[0], -): ReturnType { +async function defaultReadRemoteMediaBuffer( + params: Parameters[0], +): ReturnType { 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>; + } as Awaited>; } -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) => - fetchRemoteMediaSpy(...args), + readRemoteMediaBuffer: (...args: Parameters) => + readRemoteMediaBufferSpy(...args), getAgentScopedMediaLocalRoots: vi.fn(() => []), saveMediaBuffer: (...args: Parameters) => saveMediaBufferSpy(...args), + saveRemoteMedia: async (...args: Parameters) => { + 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 () => { diff --git a/extensions/telegram/src/bot.media.test-utils.ts b/extensions/telegram/src/bot.media.test-utils.ts index 01d7b3facd5..e50ae2a49af 100644 --- a/extensions/telegram/src/bot.media.test-utils.ts +++ b/extensions/telegram/src/bot.media.test-utils.ts @@ -21,16 +21,16 @@ let createTelegramBotRef: typeof import("./bot.js").createTelegramBot; let replySpyRef: ReturnType; 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< diff --git a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index 8c1ae8d7585..24c9f744b32 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -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, fields: Record { - const call = (fetchRemoteMedia.mock.calls as unknown[][])[callIndex]; +function requireReadRemoteMediaBufferParams(callIndex = 0): Record { + 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, callIndex = 0) { - expectRecordFields(requireFetchRemoteMediaParams(callIndex), fields); +function expectReadRemoteMediaBufferFields(fields: Record, callIndex = 0) { + expectRecordFields(requireReadRemoteMediaBufferParams(callIndex), fields); } function expectFetchSsrfPolicyFields(fields: Record, 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"); diff --git a/extensions/telegram/src/bot/delivery.resolve-media.runtime.ts b/extensions/telegram/src/bot/delivery.resolve-media.runtime.ts index 827574d5bfb..d45a8eae7f4 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media.runtime.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media.runtime.ts @@ -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, }; diff --git a/extensions/telegram/src/bot/delivery.resolve-media.ts b/extensions/telegram/src/bot/delivery.resolve-media.ts index 4895c5c1aa8..18793fec414 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media.ts @@ -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: { diff --git a/extensions/telegram/src/telegram-media.runtime.ts b/extensions/telegram/src/telegram-media.runtime.ts index c647dabd7c3..1541e598579 100644 --- a/extensions/telegram/src/telegram-media.runtime.ts +++ b/extensions/telegram/src/telegram-media.runtime.ts @@ -1,5 +1,6 @@ export { - fetchRemoteMedia, + readRemoteMediaBuffer, MediaFetchError, saveMediaBuffer, + saveRemoteMedia, } from "openclaw/plugin-sdk/media-runtime"; diff --git a/extensions/tlon/src/monitor/media.test.ts b/extensions/tlon/src/monitor/media.test.ts index 2ac7430b70c..bbde4fa8a3c 100644 --- a/extensions/tlon/src/monitor/media.test.ts +++ b/extensions/tlon/src/monitor/media.test.ts @@ -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(); }); }); diff --git a/extensions/tlon/src/monitor/media.ts b/extensions/tlon/src/monitor/media.ts index aa1e6ee5322..ec7061361c7 100644 --- a/extensions/tlon/src/monitor/media.ts +++ b/extensions/tlon/src/monitor/media.ts @@ -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) || diff --git a/extensions/whatsapp/src/inbound.media.test.ts b/extensions/whatsapp/src/inbound.media.test.ts index d60b39cf723..b695e9e83fd 100644 --- a/extensions/whatsapp/src/inbound.media.test.ts +++ b/extensions/whatsapp/src/inbound.media.test.ts @@ -14,7 +14,7 @@ type MockMessageInput = Parameters[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) => { - saveMediaBufferSpy(...args); - return actual.saveMediaBuffer(...args); + saveMediaStream: vi.fn(async (...args: Parameters) => { + saveMediaStreamSpy(...args); + return actual.saveMediaStream(...args); }), }; }); @@ -95,6 +95,7 @@ process.env.HOME = HOME; vi.mock("baileys", async () => { const actual = await vi.importActual("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) { 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(""); 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(); diff --git a/extensions/whatsapp/src/inbound/media.node.test.ts b/extensions/whatsapp/src/inbound/media.node.test.ts index 702e15ede26..1ba806caa47 100644 --- a/extensions/whatsapp/src/inbound/media.node.test.ts +++ b/extensions/whatsapp/src/inbound/media.node.test.ts @@ -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[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, 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, + 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"); + }); }); diff --git a/extensions/whatsapp/src/inbound/media.ts b/extensions/whatsapp/src/inbound/media.ts index 07a90a1739f..1ed6438af46 100644 --- a/extensions/whatsapp/src/inbound/media.ts +++ b/extensions/whatsapp/src/inbound/media.ts @@ -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>, -): 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, + 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>, -): 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, ); } diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index 69a0bcf1a22..9ae2c2ab9da 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -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>, ) => { 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) { diff --git a/extensions/zalo/src/monitor.image.polling.test.ts b/extensions/zalo/src/monitor.image.polling.test.ts index 11bf24b9f6d..1bda5e529cb 100644 --- a/extensions/zalo/src/monitor.image.polling.test.ts +++ b/extensions/zalo/src/monitor.image.polling.test.ts @@ -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(); diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 95a62a57779..045466ee3f7 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -378,13 +378,7 @@ async function handleImageMessage(params: ZaloImageMessageParams): Promise 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) { diff --git a/extensions/zalo/src/monitor.webhook.test.ts b/extensions/zalo/src/monitor.webhook.test.ts index ce527e06d9a..e36d949d454 100644 --- a/extensions/zalo/src/monitor.webhook.test.ts +++ b/extensions/zalo/src/monitor.webhook.test.ts @@ -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, diff --git a/extensions/zalo/src/test-support/lifecycle-test-support.ts b/extensions/zalo/src/test-support/lifecycle-test-support.ts index dd71131d126..2e2ac7ecf41 100644 --- a/extensions/zalo/src/test-support/lifecycle-test-support.ts +++ b/extensions/zalo/src/test-support/lifecycle-test-support.ts @@ -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; + readRemoteMediaBufferMock: ReturnType; + saveRemoteMediaMock?: ReturnType; saveMediaBufferMock: ReturnType; finalizeInboundContextMock: ReturnType; recordInboundSessionMock: ReturnType; @@ -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, diff --git a/package.json b/package.json index ed97d798a0b..710141e8c79 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index 294b5a61d94..09fb38061c3 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.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"]); } diff --git a/scripts/check-media-download-helper-roundtrip.mjs b/scripts/check-media-download-helper-roundtrip.mjs new file mode 100644 index 00000000000..2a9a4959d4f --- /dev/null +++ b/scripts/check-media-download-helper-roundtrip.mjs @@ -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; +} diff --git a/scripts/check.mjs b/scripts/check.mjs index 35743425ff4..c5537d85f81 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -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"] }, diff --git a/src/media-understanding/apply.echo-transcript.test.ts b/src/media-understanding/apply.echo-transcript.test.ts index 1f2747a5898..37196368bbb 100644 --- a/src/media-understanding/apply.echo-transcript.test.ts +++ b/src/media-understanding/apply.echo-transcript.test.ts @@ -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(); diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index ea03ac6ef93..84d2ed624a3 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -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", diff --git a/src/media-understanding/attachments.cache.ts b/src/media-understanding/attachments.cache.ts index d5f1ceae852..a34d6d638aa 100644 --- a/src/media-understanding/attachments.cache.ts +++ b/src/media-understanding/attachments.cache.ts @@ -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, diff --git a/src/media-understanding/media-understanding-url-fallback.test.ts b/src/media-understanding/media-understanding-url-fallback.test.ts index 4f8e93b89b8..e6c1739886d 100644 --- a/src/media-understanding/media-understanding-url-fallback.test.ts +++ b/src/media-understanding/media-understanding-url-fallback.test.ts @@ -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("../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, diff --git a/src/media/fetch.test.ts b/src/media/fetch.test.ts index c580efd641f..3c19bda7c75 100644 --- a/src/media/fetch.test.ts +++ b/src/media/fetch.test.ts @@ -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[0]["lookupFn"]>; -let fetchRemoteMedia: FetchRemoteMedia; +type ReadRemoteMediaBuffer = FetchModule["readRemoteMediaBuffer"]; +type SaveRemoteMedia = FetchModule["saveRemoteMedia"]; +type LookupFn = NonNullable[0]["lookupFn"]>; +let readRemoteMediaBuffer: ReadRemoteMediaBuffer; +let saveRemoteMedia: SaveRemoteMedia; let defaultFetchMediaMaxBytes: number; +let tempHome: TempHomeEnv; function makeStream(chunks: Uint8Array[]) { return new ReadableStream({ @@ -54,11 +59,11 @@ function requireFetchGuardRequest(): unknown { } async function expectRemoteMediaMaxBytesError(params: { - fetchImpl: Parameters[0]["fetchImpl"]; + fetchImpl: Parameters[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[0]["fetchImpl"]; + fetchImpl: Parameters[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[0]["fetchImpl"]; + fetchImpl: Parameters[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[0], +async function expectReadRemoteMediaBufferResolvesToError( + params: Parameters[0], ): Promise { - 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[0]["fetchImpl"]; + fetchImpl: Parameters[0]["fetchImpl"]; readIdleTimeoutMs: number; expectedError: Record; }) { 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[0]["fetchImpl"], + fetchImpl: Parameters[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[0], "lookupFn"> & { lookupFn?: LookupFn }, +function createReadRemoteMediaBufferParams( + params: Omit[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({ + 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); + }); }); diff --git a/src/media/fetch.ts b/src/media/fetch.ts index 668d57f41de..ebc4d9d4f82 100644 --- a/src/media/fetch.ts +++ b/src/media/fetch.ts @@ -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) | 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 { +async function fetchGuardedMediaResponse( + options: FetchMediaOptions, +): Promise { const { url, fetchImpl, requestInit, - filePathHint, - maxBytes, maxRedirects, - readIdleTimeoutMs, ssrfPolicy, lookupFn, dispatcherPolicy, @@ -124,9 +165,6 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise Promise) | null = null; const attempts = dispatcherAttempts && dispatcherAttempts.length > 0 ? dispatcherAttempts @@ -180,9 +218,12 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise { + 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, + chunkTimeoutMs: number, +): Promise>> { + let timeoutId: ReturnType | 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, + readIdleTimeoutMs?: number, +): AsyncIterable { + 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 { + 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( + options: FetchMediaOptions, + fn: () => Promise, +): Promise { + 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 { + 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 { + return await withMediaFetchRetry(options, () => saveRemoteMediaOnce(options)); +} + +async function saveRemoteMediaOnce(options: SaveRemoteMediaOptions): Promise { + 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 { + 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 { + 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 { + 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(); + }); +}); diff --git a/src/media/read-byte-stream-with-limit.ts b/src/media/read-byte-stream-with-limit.ts new file mode 100644 index 00000000000..07e5d66d989 --- /dev/null +++ b/src/media/read-byte-stream-with-limit.ts @@ -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, + opts: ReadByteStreamWithLimitOptions, +): Promise { + 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); +} diff --git a/src/media/store.test.ts b/src/media/store.test.ts index 5e4b8961e26..4ad4134f271 100644 --- a/src/media/store.test.ts +++ b/src/media/store.test.ts @@ -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>) => { - expect(path.basename(saved.path)).toMatch(/^report---.+\.custom$/); + const escapedExtension = testCase.expectedExtension.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&", + ); + expect(path.basename(saved.path)).toMatch( + new RegExp(`^report---.+${escapedExtension}$`), + ); }, } : {}), diff --git a/src/media/store.ts b/src/media/store.ts index d2d5be0f35f..b344958b614 100644 --- a/src/media/store.ts +++ b/src/media/store.ts @@ -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; + 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 { 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, + contentType?: string, + subdir = "inbound", + maxBytes = MAX_BYTES, + originalFilename?: string, + detectionFilePathHint?: string, +): Promise { + 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. * diff --git a/src/media/web-media.ts b/src/media/web-media.ts index 2a094894fdc..6f79fd4ce96 100644 --- a/src/media/web-media.ts +++ b/src/media/web-media.ts @@ -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, diff --git a/src/plugin-sdk/channel-test-helpers.ts b/src/plugin-sdk/channel-test-helpers.ts index 042a83fe342..a2ea5f20254 100644 --- a/src/plugin-sdk/channel-test-helpers.ts +++ b/src/plugin-sdk/channel-test-helpers.ts @@ -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, diff --git a/src/plugin-sdk/media-runtime.ts b/src/plugin-sdk/media-runtime.ts index 35dcf3a8685..c267c7917a4 100644 --- a/src/plugin-sdk/media-runtime.ts +++ b/src/plugin-sdk/media-runtime.ts @@ -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"; diff --git a/src/plugin-sdk/media-store.ts b/src/plugin-sdk/media-store.ts index 8b46f0f11ff..76b933b3a8a 100644 --- a/src/plugin-sdk/media-store.ts +++ b/src/plugin-sdk/media-store.ts @@ -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"; diff --git a/src/plugin-sdk/response-limit-runtime.ts b/src/plugin-sdk/response-limit-runtime.ts index 303a8d132c6..569fa905ce7 100644 --- a/src/plugin-sdk/response-limit-runtime.ts +++ b/src/plugin-sdk/response-limit-runtime.ts @@ -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"; diff --git a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts index f298e712182..ea32c20cc16 100644 --- a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts +++ b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts @@ -69,6 +69,33 @@ function createDeprecatedRuntimeConfigError(name: "loadConfig" | "writeConfigFil ); } +export type PluginRuntimeMediaMock = PluginRuntime["channel"]["media"]; + +export function createPluginRuntimeMediaMock( + overrides: Partial = {}, +): 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 { const taskFlow = { bindSession: vi.fn( @@ -524,14 +551,7 @@ export function createPluginRuntimeMock(overrides: DeepPartial = 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", diff --git a/src/plugins/contracts/plugin-sdk-index.test.ts b/src/plugins/contracts/plugin-sdk-index.test.ts index ff369a66b3e..855029de8d8 100644 --- a/src/plugins/contracts/plugin-sdk-index.test.ts +++ b/src/plugins/contracts/plugin-sdk-index.test.ts @@ -65,7 +65,7 @@ describe("plugin-sdk exports", () => { "resolveStateDir", "writeConfigFile", "enqueueSystemEvent", - "fetchRemoteMedia", + "readRemoteMediaBuffer", "saveMediaBuffer", "formatAgentEnvelope", "buildPairingReply", diff --git a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts index 9201fd38a28..b725bcf59f8 100644 --- a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts @@ -88,7 +88,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { '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";', diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts index cb401762265..80f3528d6c8 100644 --- a/src/plugins/runtime/runtime-channel.ts +++ b/src/plugins/runtime/runtime-channel.ts @@ -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: { diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index 822225533c8..1fd97e1c247 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -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: {