mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
refactor(media): centralize bounded remote downloads
Co-authored-by: samzong <samzong.lu@gmail.com>
This commit is contained in:
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### 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)
|
- 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.
|
- 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.
|
- Docker: pin setup-time container paths so stale host `.env` OpenClaw paths cannot leak into Linux containers. Fixes #80381. (#81105) Thanks @brokemac79.
|
||||||
|
|||||||
@@ -542,6 +542,19 @@ two-party event loops that do not go through the shared channel-turn kernel.
|
|||||||
<Accordion title="api.runtime.channel">
|
<Accordion title="api.runtime.channel">
|
||||||
Channel-specific runtime helpers (available when a channel plugin is loaded).
|
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:
|
`api.runtime.channel.mentions` is the shared inbound mention-policy surface for bundled channel plugins that use runtime injection:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
@@ -302,9 +302,9 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
|
|||||||
<Accordion title="Capability and testing subpaths">
|
<Accordion title="Capability and testing subpaths">
|
||||||
| Subpath | Key exports |
|
| 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-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-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/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 |
|
| `plugin-sdk/text-chunking` | Text and markdown chunking/render helpers, markdown table conversion, directive-tag stripping, and safe-text utilities |
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10";
|
import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10";
|
||||||
import { getFileExtension } from "openclaw/plugin-sdk/media-mime";
|
import { getFileExtension } from "openclaw/plugin-sdk/media-mime";
|
||||||
import {
|
import { saveRemoteMedia, type FetchLike } from "openclaw/plugin-sdk/media-runtime";
|
||||||
fetchRemoteMedia,
|
|
||||||
saveMediaBuffer,
|
|
||||||
type FetchLike,
|
|
||||||
} from "openclaw/plugin-sdk/media-runtime";
|
|
||||||
import { buildMediaPayload } from "openclaw/plugin-sdk/reply-payload";
|
import { buildMediaPayload } from "openclaw/plugin-sdk/reply-payload";
|
||||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||||
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||||
@@ -215,19 +211,23 @@ async function fetchDiscordMedia(params: {
|
|||||||
readIdleTimeoutMs?: number;
|
readIdleTimeoutMs?: number;
|
||||||
totalTimeoutMs?: number;
|
totalTimeoutMs?: number;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
|
fallbackContentType?: string;
|
||||||
|
originalFilename?: string;
|
||||||
}) {
|
}) {
|
||||||
const timeoutAbortController = params.totalTimeoutMs ? new AbortController() : undefined;
|
const timeoutAbortController = params.totalTimeoutMs ? new AbortController() : undefined;
|
||||||
const signal = mergeAbortSignals([params.abortSignal, timeoutAbortController?.signal]);
|
const signal = mergeAbortSignals([params.abortSignal, timeoutAbortController?.signal]);
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const fetchPromise = fetchRemoteMedia({
|
const savePromise = saveRemoteMedia({
|
||||||
url: params.url,
|
url: params.url,
|
||||||
filePathHint: params.filePathHint,
|
filePathHint: params.filePathHint,
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
fetchImpl: params.fetchImpl,
|
fetchImpl: params.fetchImpl,
|
||||||
ssrfPolicy: params.ssrfPolicy,
|
ssrfPolicy: params.ssrfPolicy,
|
||||||
readIdleTimeoutMs: params.readIdleTimeoutMs,
|
readIdleTimeoutMs: params.readIdleTimeoutMs,
|
||||||
|
fallbackContentType: params.fallbackContentType,
|
||||||
|
originalFilename: params.originalFilename,
|
||||||
...(signal ? { requestInit: { signal } } : {}),
|
...(signal ? { requestInit: { signal } } : {}),
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
if (timedOut) {
|
if (timedOut) {
|
||||||
@@ -238,7 +238,7 @@ async function fetchDiscordMedia(params: {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!params.totalTimeoutMs) {
|
if (!params.totalTimeoutMs) {
|
||||||
return await fetchPromise;
|
return await savePromise;
|
||||||
}
|
}
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
timeoutHandle = setTimeout(() => {
|
timeoutHandle = setTimeout(() => {
|
||||||
@@ -248,7 +248,7 @@ async function fetchDiscordMedia(params: {
|
|||||||
}, params.totalTimeoutMs);
|
}, params.totalTimeoutMs);
|
||||||
timeoutHandle.unref?.();
|
timeoutHandle.unref?.();
|
||||||
});
|
});
|
||||||
return await Promise.race([fetchPromise, timeoutPromise]);
|
return await Promise.race([savePromise, timeoutPromise]);
|
||||||
} finally {
|
} finally {
|
||||||
if (timeoutHandle) {
|
if (timeoutHandle) {
|
||||||
clearTimeout(timeoutHandle);
|
clearTimeout(timeoutHandle);
|
||||||
@@ -280,7 +280,7 @@ async function appendResolvedMediaFromAttachments(params: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const fetched = await fetchDiscordMedia({
|
const saved = await fetchDiscordMedia({
|
||||||
url: attachmentUrl,
|
url: attachmentUrl,
|
||||||
filePathHint: attachment.filename ?? attachmentUrl,
|
filePathHint: attachment.filename ?? attachmentUrl,
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
@@ -289,14 +289,9 @@ async function appendResolvedMediaFromAttachments(params: {
|
|||||||
readIdleTimeoutMs: params.readIdleTimeoutMs,
|
readIdleTimeoutMs: params.readIdleTimeoutMs,
|
||||||
totalTimeoutMs: params.totalTimeoutMs,
|
totalTimeoutMs: params.totalTimeoutMs,
|
||||||
abortSignal: params.abortSignal,
|
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({
|
params.out.push({
|
||||||
path: saved.path,
|
path: saved.path,
|
||||||
contentType: saved.contentType,
|
contentType: saved.contentType,
|
||||||
@@ -388,7 +383,7 @@ async function appendResolvedMediaFromStickers(params: {
|
|||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
const fetched = await fetchDiscordMedia({
|
const saved = await fetchDiscordMedia({
|
||||||
url: candidate.url,
|
url: candidate.url,
|
||||||
filePathHint: candidate.fileName,
|
filePathHint: candidate.fileName,
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
@@ -397,14 +392,9 @@ async function appendResolvedMediaFromStickers(params: {
|
|||||||
readIdleTimeoutMs: params.readIdleTimeoutMs,
|
readIdleTimeoutMs: params.readIdleTimeoutMs,
|
||||||
totalTimeoutMs: params.totalTimeoutMs,
|
totalTimeoutMs: params.totalTimeoutMs,
|
||||||
abortSignal: params.abortSignal,
|
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({
|
params.out.push({
|
||||||
path: saved.path,
|
path: saved.path,
|
||||||
contentType: saved.contentType,
|
contentType: saved.contentType,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { ChannelType, type Client, type Message } from "../internal/discord.js";
|
import { ChannelType, type Client, type Message } from "../internal/discord.js";
|
||||||
|
|
||||||
const fetchRemoteMedia = vi.fn();
|
const readRemoteMediaBuffer = vi.fn();
|
||||||
const saveMediaBuffer = vi.fn();
|
const saveMediaBuffer = vi.fn();
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
|
vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
|
||||||
@@ -16,7 +16,21 @@ vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...actual,
|
...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),
|
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -82,7 +96,10 @@ function callArg(mock: unknown, callIndex: number, argIndex: number, label: stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fetchParams(): Record<string, unknown> {
|
function fetchParams(): Record<string, unknown> {
|
||||||
return requireRecord(callArg(fetchRemoteMedia, 0, 0, "fetch media params"), "fetch media params");
|
return requireRecord(
|
||||||
|
callArg(readRemoteMediaBuffer, 0, 0, "fetch media params"),
|
||||||
|
"fetch media params",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectDiscordCdnSsrFPolicy(policy: unknown) {
|
function expectDiscordCdnSsrFPolicy(policy: unknown) {
|
||||||
@@ -101,7 +118,7 @@ function expectSinglePngDownload(params: {
|
|||||||
expectedPath: string;
|
expectedPath: string;
|
||||||
placeholder: "<media:image>" | "<media:sticker>";
|
placeholder: "<media:image>" | "<media:sticker>";
|
||||||
}) {
|
}) {
|
||||||
expect(fetchRemoteMedia).toHaveBeenCalledTimes(1);
|
expect(readRemoteMediaBuffer).toHaveBeenCalledTimes(1);
|
||||||
const call = fetchParams();
|
const call = fetchParams();
|
||||||
expect(call.url).toBe(params.expectedUrl);
|
expect(call.url).toBe(params.expectedUrl);
|
||||||
expect(call.filePathHint).toBe(params.filePathHint);
|
expect(call.filePathHint).toBe(params.filePathHint);
|
||||||
@@ -220,7 +237,7 @@ describe("resolveDiscordMessageChannelId", () => {
|
|||||||
|
|
||||||
describe("resolveForwardedMediaList", () => {
|
describe("resolveForwardedMediaList", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchRemoteMedia.mockClear();
|
readRemoteMediaBuffer.mockClear();
|
||||||
saveMediaBuffer.mockClear();
|
saveMediaBuffer.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,7 +248,7 @@ describe("resolveForwardedMediaList", () => {
|
|||||||
filename: "image.png",
|
filename: "image.png",
|
||||||
content_type: "image/png",
|
content_type: "image/png",
|
||||||
};
|
};
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("image"),
|
buffer: Buffer.from("image"),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
@@ -266,7 +283,7 @@ describe("resolveForwardedMediaList", () => {
|
|||||||
filename: "proxy.png",
|
filename: "proxy.png",
|
||||||
content_type: "image/png",
|
content_type: "image/png",
|
||||||
};
|
};
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("image"),
|
buffer: Buffer.from("image"),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
@@ -295,7 +312,7 @@ describe("resolveForwardedMediaList", () => {
|
|||||||
filename: "fallback.png",
|
filename: "fallback.png",
|
||||||
content_type: "image/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(
|
const result = await resolveForwardedMediaList(
|
||||||
asMessage({
|
asMessage({
|
||||||
@@ -315,7 +332,7 @@ describe("resolveForwardedMediaList", () => {
|
|||||||
name: "wave",
|
name: "wave",
|
||||||
format_type: StickerFormatType.PNG,
|
format_type: StickerFormatType.PNG,
|
||||||
};
|
};
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("sticker"),
|
buffer: Buffer.from("sticker"),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
@@ -346,7 +363,7 @@ describe("resolveForwardedMediaList", () => {
|
|||||||
const result = await resolveForwardedMediaList(asMessage({}), 512);
|
const result = await resolveForwardedMediaList(asMessage({}), 512);
|
||||||
|
|
||||||
expect(result).toStrictEqual([]);
|
expect(result).toStrictEqual([]);
|
||||||
expect(fetchRemoteMedia).not.toHaveBeenCalled();
|
expect(readRemoteMediaBuffer).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("downloads forwarded referenced attachments when snapshots are absent", async () => {
|
it("downloads forwarded referenced attachments when snapshots are absent", async () => {
|
||||||
@@ -356,7 +373,7 @@ describe("resolveForwardedMediaList", () => {
|
|||||||
filename: "ref-image.png",
|
filename: "ref-image.png",
|
||||||
content_type: "image/png",
|
content_type: "image/png",
|
||||||
};
|
};
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("image"),
|
buffer: Buffer.from("image"),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
@@ -392,7 +409,7 @@ describe("resolveForwardedMediaList", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toStrictEqual([]);
|
expect(result).toStrictEqual([]);
|
||||||
expect(fetchRemoteMedia).not.toHaveBeenCalled();
|
expect(readRemoteMediaBuffer).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes readIdleTimeoutMs to forwarded attachment downloads", async () => {
|
it("passes readIdleTimeoutMs to forwarded attachment downloads", async () => {
|
||||||
@@ -402,7 +419,7 @@ describe("resolveForwardedMediaList", () => {
|
|||||||
filename: "forwarded-timeout.png",
|
filename: "forwarded-timeout.png",
|
||||||
content_type: "image/png",
|
content_type: "image/png",
|
||||||
};
|
};
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("image"),
|
buffer: Buffer.from("image"),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
@@ -430,7 +447,7 @@ describe("resolveForwardedMediaList", () => {
|
|||||||
name: "timeout-forwarded",
|
name: "timeout-forwarded",
|
||||||
format_type: StickerFormatType.PNG,
|
format_type: StickerFormatType.PNG,
|
||||||
};
|
};
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("sticker"),
|
buffer: Buffer.from("sticker"),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
@@ -455,7 +472,7 @@ describe("resolveForwardedMediaList", () => {
|
|||||||
|
|
||||||
describe("resolveMediaList", () => {
|
describe("resolveMediaList", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchRemoteMedia.mockClear();
|
readRemoteMediaBuffer.mockClear();
|
||||||
saveMediaBuffer.mockClear();
|
saveMediaBuffer.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -465,7 +482,7 @@ describe("resolveMediaList", () => {
|
|||||||
name: "hello",
|
name: "hello",
|
||||||
format_type: StickerFormatType.PNG,
|
format_type: StickerFormatType.PNG,
|
||||||
};
|
};
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("sticker"),
|
buffer: Buffer.from("sticker"),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
@@ -497,7 +514,7 @@ describe("resolveMediaList", () => {
|
|||||||
name: "proxy-sticker",
|
name: "proxy-sticker",
|
||||||
format_type: StickerFormatType.PNG,
|
format_type: StickerFormatType.PNG,
|
||||||
};
|
};
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("sticker"),
|
buffer: Buffer.from("sticker"),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
@@ -524,7 +541,7 @@ describe("resolveMediaList", () => {
|
|||||||
filename: "main-fallback.png",
|
filename: "main-fallback.png",
|
||||||
content_type: "image/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(
|
const result = await resolveMediaList(
|
||||||
asMessage({
|
asMessage({
|
||||||
@@ -550,7 +567,7 @@ describe("resolveMediaList", () => {
|
|||||||
512,
|
512,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(fetchRemoteMedia).not.toHaveBeenCalled();
|
expect(readRemoteMediaBuffer).not.toHaveBeenCalled();
|
||||||
expect(saveMediaBuffer).not.toHaveBeenCalled();
|
expect(saveMediaBuffer).not.toHaveBeenCalled();
|
||||||
expect(result).toStrictEqual([]);
|
expect(result).toStrictEqual([]);
|
||||||
});
|
});
|
||||||
@@ -561,7 +578,7 @@ describe("resolveMediaList", () => {
|
|||||||
url: "https://cdn.discordapp.com/attachments/1/voice.ogg",
|
url: "https://cdn.discordapp.com/attachments/1/voice.ogg",
|
||||||
filename: "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(
|
const result = await resolveMediaList(
|
||||||
asMessage({
|
asMessage({
|
||||||
@@ -587,7 +604,7 @@ describe("resolveMediaList", () => {
|
|||||||
duration_secs: 1.5,
|
duration_secs: 1.5,
|
||||||
waveform: "AAAA",
|
waveform: "AAAA",
|
||||||
};
|
};
|
||||||
fetchRemoteMedia.mockRejectedValueOnce(new Error("blocked by ssrf guard"));
|
readRemoteMediaBuffer.mockRejectedValueOnce(new Error("blocked by ssrf guard"));
|
||||||
|
|
||||||
const result = await resolveMediaList(
|
const result = await resolveMediaList(
|
||||||
asMessage({
|
asMessage({
|
||||||
@@ -612,7 +629,7 @@ describe("resolveMediaList", () => {
|
|||||||
filename: "photo.png",
|
filename: "photo.png",
|
||||||
content_type: "image/png",
|
content_type: "image/png",
|
||||||
};
|
};
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("image"),
|
buffer: Buffer.from("image"),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
@@ -625,7 +642,7 @@ describe("resolveMediaList", () => {
|
|||||||
512,
|
512,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(fetchRemoteMedia).toHaveBeenCalledTimes(1);
|
expect(readRemoteMediaBuffer).toHaveBeenCalledTimes(1);
|
||||||
expect(saveMediaBuffer).toHaveBeenCalledTimes(1);
|
expect(saveMediaBuffer).toHaveBeenCalledTimes(1);
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{
|
{
|
||||||
@@ -650,7 +667,7 @@ describe("resolveMediaList", () => {
|
|||||||
content_type: "application/pdf",
|
content_type: "application/pdf",
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchRemoteMedia
|
readRemoteMediaBuffer
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("image"),
|
buffer: Buffer.from("image"),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
@@ -688,7 +705,7 @@ describe("resolveMediaList", () => {
|
|||||||
name: "fallback",
|
name: "fallback",
|
||||||
format_type: StickerFormatType.PNG,
|
format_type: StickerFormatType.PNG,
|
||||||
};
|
};
|
||||||
fetchRemoteMedia.mockRejectedValueOnce(new Error("blocked by ssrf guard"));
|
readRemoteMediaBuffer.mockRejectedValueOnce(new Error("blocked by ssrf guard"));
|
||||||
|
|
||||||
const result = await resolveMediaList(
|
const result = await resolveMediaList(
|
||||||
asMessage({
|
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 = {
|
const attachment = {
|
||||||
id: "att-timeout",
|
id: "att-timeout",
|
||||||
url: "https://cdn.discordapp.com/attachments/1/timeout.png",
|
url: "https://cdn.discordapp.com/attachments/1/timeout.png",
|
||||||
filename: "timeout.png",
|
filename: "timeout.png",
|
||||||
content_type: "image/png",
|
content_type: "image/png",
|
||||||
};
|
};
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("image"),
|
buffer: Buffer.from("image"),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
@@ -734,13 +751,13 @@ describe("resolveMediaList", () => {
|
|||||||
expect(fetchParams().readIdleTimeoutMs).toBe(60_000);
|
expect(fetchParams().readIdleTimeoutMs).toBe(60_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes readIdleTimeoutMs to fetchRemoteMedia for stickers", async () => {
|
it("passes readIdleTimeoutMs to readRemoteMediaBuffer for stickers", async () => {
|
||||||
const sticker = {
|
const sticker = {
|
||||||
id: "sticker-timeout",
|
id: "sticker-timeout",
|
||||||
name: "timeout",
|
name: "timeout",
|
||||||
format_type: StickerFormatType.PNG,
|
format_type: StickerFormatType.PNG,
|
||||||
};
|
};
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("sticker"),
|
buffer: Buffer.from("sticker"),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
@@ -768,7 +785,7 @@ describe("resolveMediaList", () => {
|
|||||||
content_type: "image/png",
|
content_type: "image/png",
|
||||||
};
|
};
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
fetchRemoteMedia.mockImplementation(
|
readRemoteMediaBuffer.mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
new Promise(() => {
|
new Promise(() => {
|
||||||
// never resolves
|
// 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 = {
|
const attachment = {
|
||||||
id: "att-abort",
|
id: "att-abort",
|
||||||
url: "https://cdn.discordapp.com/attachments/1/abort.png",
|
url: "https://cdn.discordapp.com/attachments/1/abort.png",
|
||||||
@@ -806,7 +823,7 @@ describe("resolveMediaList", () => {
|
|||||||
content_type: "image/png",
|
content_type: "image/png",
|
||||||
};
|
};
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
fetchRemoteMedia.mockImplementationOnce(
|
readRemoteMediaBuffer.mockImplementationOnce(
|
||||||
(params: { requestInit?: { signal?: AbortSignal } }) =>
|
(params: { requestInit?: { signal?: AbortSignal } }) =>
|
||||||
new Promise((_, reject) => {
|
new Promise((_, reject) => {
|
||||||
const signal = params.requestInit?.signal;
|
const signal = params.requestInit?.signal;
|
||||||
@@ -842,12 +859,12 @@ describe("resolveMediaList", () => {
|
|||||||
|
|
||||||
describe("Discord media SSRF policy", () => {
|
describe("Discord media SSRF policy", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchRemoteMedia.mockClear();
|
readRemoteMediaBuffer.mockClear();
|
||||||
saveMediaBuffer.mockClear();
|
saveMediaBuffer.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes Discord CDN hostname allowlist with RFC2544 enabled", async () => {
|
it("passes Discord CDN hostname allowlist with RFC2544 enabled", async () => {
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("img"),
|
buffer: Buffer.from("img"),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
@@ -867,7 +884,7 @@ describe("Discord media SSRF policy", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("merges provided ssrfPolicy with Discord CDN defaults", async () => {
|
it("merges provided ssrfPolicy with Discord CDN defaults", async () => {
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("img"),
|
buffer: Buffer.from("img"),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ClawdbotConfig } from "../runtime-api.js";
|
import type { ClawdbotConfig } from "../runtime-api.js";
|
||||||
import { buildFeishuConversationId } from "./conversation-id.js";
|
import { buildFeishuConversationId } from "./conversation-id.js";
|
||||||
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
||||||
import { downloadMessageResourceFeishu } from "./media.js";
|
import { saveMessageResourceFeishu } from "./media.js";
|
||||||
import { isFeishuBroadcastMention } from "./mention.js";
|
import { isFeishuBroadcastMention } from "./mention.js";
|
||||||
import { parsePostContent } from "./post.js";
|
import { parsePostContent } from "./post.js";
|
||||||
import { getFeishuRuntime } from "./runtime.js";
|
import { getFeishuRuntime } from "./runtime.js";
|
||||||
@@ -329,6 +329,28 @@ export function toMessageResourceType(messageType: string): "image" | "file" {
|
|||||||
return messageType === "image" ? "image" : "file";
|
return messageType === "image" ? "image" : "file";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveSavedFeishuMedia(params: {
|
||||||
|
result:
|
||||||
|
| Awaited<ReturnType<typeof saveMessageResourceFeishu>>
|
||||||
|
| { buffer: Buffer; contentType?: string; fileName?: string };
|
||||||
|
maxBytes: number;
|
||||||
|
originalFilename?: string;
|
||||||
|
}) {
|
||||||
|
if ("saved" in params.result) {
|
||||||
|
return params.result.saved;
|
||||||
|
}
|
||||||
|
const core = getFeishuRuntime();
|
||||||
|
const contentType =
|
||||||
|
params.result.contentType ?? (await core.media.detectMime({ buffer: params.result.buffer }));
|
||||||
|
return await core.channel.media.saveMediaBuffer(
|
||||||
|
params.result.buffer,
|
||||||
|
contentType,
|
||||||
|
"inbound",
|
||||||
|
params.maxBytes,
|
||||||
|
params.result.fileName ?? params.originalFilename,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function inferPlaceholder(messageType: string): string {
|
function inferPlaceholder(messageType: string): string {
|
||||||
switch (messageType) {
|
switch (messageType) {
|
||||||
case "image":
|
case "image":
|
||||||
@@ -363,8 +385,6 @@ export async function resolveFeishuMediaList(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const out: FeishuMediaInfo[] = [];
|
const out: FeishuMediaInfo[] = [];
|
||||||
const core = getFeishuRuntime();
|
|
||||||
|
|
||||||
if (messageType === "post") {
|
if (messageType === "post") {
|
||||||
const { imageKeys, mediaKeys } = parsePostContent(content);
|
const { imageKeys, mediaKeys } = parsePostContent(content);
|
||||||
if (imageKeys.length === 0 && mediaKeys.length === 0) {
|
if (imageKeys.length === 0 && mediaKeys.length === 0) {
|
||||||
@@ -379,21 +399,15 @@ export async function resolveFeishuMediaList(params: {
|
|||||||
|
|
||||||
for (const imageKey of imageKeys) {
|
for (const imageKey of imageKeys) {
|
||||||
try {
|
try {
|
||||||
const result = await downloadMessageResourceFeishu({
|
const result = await saveMessageResourceFeishu({
|
||||||
cfg,
|
cfg,
|
||||||
messageId,
|
messageId,
|
||||||
fileKey: imageKey,
|
fileKey: imageKey,
|
||||||
type: "image",
|
type: "image",
|
||||||
accountId,
|
accountId,
|
||||||
});
|
|
||||||
const contentType =
|
|
||||||
result.contentType ?? (await core.media.detectMime({ buffer: result.buffer }));
|
|
||||||
const saved = await core.channel.media.saveMediaBuffer(
|
|
||||||
result.buffer,
|
|
||||||
contentType,
|
|
||||||
"inbound",
|
|
||||||
maxBytes,
|
maxBytes,
|
||||||
);
|
});
|
||||||
|
const saved = await resolveSavedFeishuMedia({ result, maxBytes });
|
||||||
out.push({
|
out.push({
|
||||||
path: saved.path,
|
path: saved.path,
|
||||||
contentType: saved.contentType,
|
contentType: saved.contentType,
|
||||||
@@ -407,21 +421,20 @@ export async function resolveFeishuMediaList(params: {
|
|||||||
|
|
||||||
for (const media of mediaKeys) {
|
for (const media of mediaKeys) {
|
||||||
try {
|
try {
|
||||||
const result = await downloadMessageResourceFeishu({
|
const result = await saveMessageResourceFeishu({
|
||||||
cfg,
|
cfg,
|
||||||
messageId,
|
messageId,
|
||||||
fileKey: media.fileKey,
|
fileKey: media.fileKey,
|
||||||
type: "file",
|
type: "file",
|
||||||
accountId,
|
accountId,
|
||||||
});
|
|
||||||
const contentType =
|
|
||||||
result.contentType ?? (await core.media.detectMime({ buffer: result.buffer }));
|
|
||||||
const saved = await core.channel.media.saveMediaBuffer(
|
|
||||||
result.buffer,
|
|
||||||
contentType,
|
|
||||||
"inbound",
|
|
||||||
maxBytes,
|
maxBytes,
|
||||||
);
|
originalFilename: media.fileName,
|
||||||
|
});
|
||||||
|
const saved = await resolveSavedFeishuMedia({
|
||||||
|
result,
|
||||||
|
maxBytes,
|
||||||
|
originalFilename: media.fileName,
|
||||||
|
});
|
||||||
out.push({
|
out.push({
|
||||||
path: saved.path,
|
path: saved.path,
|
||||||
contentType: saved.contentType,
|
contentType: saved.contentType,
|
||||||
@@ -445,22 +458,20 @@ export async function resolveFeishuMediaList(params: {
|
|||||||
if (!fileKey) {
|
if (!fileKey) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const result = await downloadMessageResourceFeishu({
|
const result = await saveMessageResourceFeishu({
|
||||||
cfg,
|
cfg,
|
||||||
messageId,
|
messageId,
|
||||||
fileKey,
|
fileKey,
|
||||||
type: toMessageResourceType(messageType),
|
type: toMessageResourceType(messageType),
|
||||||
accountId,
|
accountId,
|
||||||
});
|
|
||||||
const contentType =
|
|
||||||
result.contentType ?? (await core.media.detectMime({ buffer: result.buffer }));
|
|
||||||
const saved = await core.channel.media.saveMediaBuffer(
|
|
||||||
result.buffer,
|
|
||||||
contentType,
|
|
||||||
"inbound",
|
|
||||||
maxBytes,
|
maxBytes,
|
||||||
result.fileName || mediaKeys.fileName,
|
originalFilename: mediaKeys.fileName,
|
||||||
);
|
});
|
||||||
|
const saved = await resolveSavedFeishuMedia({
|
||||||
|
result,
|
||||||
|
maxBytes,
|
||||||
|
originalFilename: mediaKeys.fileName,
|
||||||
|
});
|
||||||
out.push({
|
out.push({
|
||||||
path: saved.path,
|
path: saved.path,
|
||||||
contentType: saved.contentType,
|
contentType: saved.contentType,
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ vi.mock("./send.js", () => ({
|
|||||||
|
|
||||||
vi.mock("./media.js", () => ({
|
vi.mock("./media.js", () => ({
|
||||||
downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu,
|
downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu,
|
||||||
|
saveMessageResourceFeishu: mockDownloadMessageResourceFeishu,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./audio-preflight.runtime.js", () => ({
|
vi.mock("./audio-preflight.runtime.js", () => ({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createToolFactoryHarness, type ToolLike } from "./tool-factory-test-har
|
|||||||
|
|
||||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||||
const resolveFeishuToolAccountMock = 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 loadWebMediaMock = vi.hoisted(() => vi.fn());
|
||||||
const convertMock = vi.hoisted(() => vi.fn());
|
const convertMock = vi.hoisted(() => vi.fn());
|
||||||
const documentCreateMock = vi.hoisted(() => vi.fn());
|
const documentCreateMock = vi.hoisted(() => vi.fn());
|
||||||
@@ -40,7 +40,7 @@ vi.spyOn(runtimeModule, "getFeishuRuntime").mockImplementation(
|
|||||||
({
|
({
|
||||||
channel: {
|
channel: {
|
||||||
media: {
|
media: {
|
||||||
fetchRemoteMedia: fetchRemoteMediaMock,
|
readRemoteMediaBuffer: readRemoteMediaBufferMock,
|
||||||
saveMediaBuffer: vi.fn(),
|
saveMediaBuffer: vi.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -389,7 +389,7 @@ describe("feishu_doc image fetch hardening", () => {
|
|||||||
|
|
||||||
it("skips image upload when markdown image URL is blocked", async () => {
|
it("skips image upload when markdown image URL is blocked", async () => {
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
fetchRemoteMediaMock.mockRejectedValueOnce(
|
readRemoteMediaBufferMock.mockRejectedValueOnce(
|
||||||
new Error("Blocked: resolves to private/internal IP address"),
|
new Error("Blocked: resolves to private/internal IP address"),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -401,7 +401,7 @@ describe("feishu_doc image fetch hardening", () => {
|
|||||||
content: "",
|
content: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetchRemoteMediaMock).toHaveBeenCalled();
|
expect(readRemoteMediaBufferMock).toHaveBeenCalled();
|
||||||
expect(driveUploadAllMock).not.toHaveBeenCalled();
|
expect(driveUploadAllMock).not.toHaveBeenCalled();
|
||||||
expect(blockPatchMock).not.toHaveBeenCalled();
|
expect(blockPatchMock).not.toHaveBeenCalled();
|
||||||
expect(result.details.images_processed).toBe(0);
|
expect(result.details.images_processed).toBe(0);
|
||||||
|
|||||||
@@ -510,7 +510,7 @@ async function uploadImageToDocx(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadImage(url: string, maxBytes: number): Promise<Buffer> {
|
async function downloadImage(url: string, maxBytes: number): Promise<Buffer> {
|
||||||
const fetched = await getFeishuRuntime().channel.media.fetchRemoteMedia({ url, maxBytes });
|
const fetched = await getFeishuRuntime().channel.media.readRemoteMediaBuffer({ url, maxBytes });
|
||||||
return fetched.buffer;
|
return fetched.buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,7 +635,7 @@ async function resolveUploadInput(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url) {
|
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 urlPath = new URL(url).pathname;
|
||||||
const guessed = urlPath.split("/").pop() || "upload.bin";
|
const guessed = urlPath.split("/").pop() || "upload.bin";
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { realpathSync } from "node:fs";
|
import { realpathSync } from "node:fs";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { ClawdbotConfig } from "../runtime-api.js";
|
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 downloadImageFeishu: typeof import("./media.js").downloadImageFeishu;
|
||||||
let downloadMessageResourceFeishu: typeof import("./media.js").downloadMessageResourceFeishu;
|
let downloadMessageResourceFeishu: typeof import("./media.js").downloadMessageResourceFeishu;
|
||||||
|
let saveMessageResourceFeishu: typeof import("./media.js").saveMessageResourceFeishu;
|
||||||
let sanitizeFileNameForUpload: typeof import("./media.js").sanitizeFileNameForUpload;
|
let sanitizeFileNameForUpload: typeof import("./media.js").sanitizeFileNameForUpload;
|
||||||
let sendMediaFeishu: typeof import("./media.js").sendMediaFeishu;
|
let sendMediaFeishu: typeof import("./media.js").sendMediaFeishu;
|
||||||
let shouldSuppressFeishuTextForVoiceMedia: typeof import("./media.js").shouldSuppressFeishuTextForVoiceMedia;
|
let shouldSuppressFeishuTextForVoiceMedia: typeof import("./media.js").shouldSuppressFeishuTextForVoiceMedia;
|
||||||
@@ -114,6 +117,7 @@ describe("sendMediaFeishu msg_type routing", () => {
|
|||||||
({
|
({
|
||||||
downloadImageFeishu,
|
downloadImageFeishu,
|
||||||
downloadMessageResourceFeishu,
|
downloadMessageResourceFeishu,
|
||||||
|
saveMessageResourceFeishu,
|
||||||
sanitizeFileNameForUpload,
|
sanitizeFileNameForUpload,
|
||||||
sendMediaFeishu,
|
sendMediaFeishu,
|
||||||
shouldSuppressFeishuTextForVoiceMedia,
|
shouldSuppressFeishuTextForVoiceMedia,
|
||||||
@@ -545,6 +549,40 @@ describe("sendMediaFeishu msg_type routing", () => {
|
|||||||
expectPathIsolatedToTmpRoot(capturedPath, fileKey);
|
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 () => {
|
it("rejects invalid image keys before calling feishu api", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
downloadImageFeishu({
|
downloadImageFeishu({
|
||||||
@@ -876,4 +914,41 @@ describe("downloadMessageResourceFeishu", () => {
|
|||||||
|
|
||||||
expect(result.fileName).toBe(latin1LookingFileName);
|
expect(result.fileName).toBe(latin1LookingFileName);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("saves message resource streams directly to the media store", async () => {
|
||||||
|
const originalHome = process.env.HOME;
|
||||||
|
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-media-"));
|
||||||
|
try {
|
||||||
|
process.env.HOME = tempHome;
|
||||||
|
messageResourceGetMock.mockResolvedValueOnce({
|
||||||
|
getReadableStream: () => Readable.from([Buffer.from([0xff, 0xd8, 0xff, 0x00])]),
|
||||||
|
headers: {
|
||||||
|
"content-type": "image/jpeg",
|
||||||
|
"content-disposition": `attachment; filename="photo.jpg"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await saveMessageResourceFeishu({
|
||||||
|
cfg: emptyConfig,
|
||||||
|
messageId: "om_stream_msg",
|
||||||
|
fileKey: "img_key_stream",
|
||||||
|
type: "image",
|
||||||
|
maxBytes: 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.saved.path).toContain(`${path.sep}.openclaw${path.sep}media${path.sep}inbound`);
|
||||||
|
expect(result.saved.id).toMatch(/^photo---[a-f0-9-]{36}\.jpg$/);
|
||||||
|
expect(result.saved.size).toBe(4);
|
||||||
|
await expect(fs.readFile(result.saved.path)).resolves.toEqual(
|
||||||
|
Buffer.from([0xff, 0xd8, 0xff, 0x00]),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (originalHome === undefined) {
|
||||||
|
delete process.env.HOME;
|
||||||
|
} else {
|
||||||
|
process.env.HOME = originalHome;
|
||||||
|
}
|
||||||
|
await fs.rm(tempHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type * as Lark from "@larksuiteoapi/node-sdk";
|
|||||||
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message";
|
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message";
|
||||||
import { mediaKindFromMime } from "openclaw/plugin-sdk/media-mime";
|
import { mediaKindFromMime } from "openclaw/plugin-sdk/media-mime";
|
||||||
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
|
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 { readRegularFile, writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +58,12 @@ export type DownloadMessageResourceResult = {
|
|||||||
fileName?: string;
|
fileName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SaveMessageResourceResult = {
|
||||||
|
saved: SavedMedia;
|
||||||
|
contentType?: string;
|
||||||
|
fileName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accountId?: string }): {
|
function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accountId?: string }): {
|
||||||
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
|
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
|
||||||
client: ReturnType<typeof createFeishuClient>;
|
client: ReturnType<typeof createFeishuClient>;
|
||||||
@@ -242,25 +250,29 @@ function extractFeishuDownloadMetadata(response: FeishuDownloadResponse): {
|
|||||||
return { contentType, fileName };
|
return { contentType, fileName };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readReadableBuffer(stream: Readable): Promise<Buffer> {
|
function mediaLimitError(maxBytes: number): Error {
|
||||||
const chunks: Buffer[] = [];
|
return new Error(`Media exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`);
|
||||||
for await (const chunk of stream) {
|
}
|
||||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
||||||
|
function assertBufferWithinLimit(buffer: Buffer, maxBytes: number): Buffer {
|
||||||
|
if (buffer.byteLength > maxBytes) {
|
||||||
|
throw mediaLimitError(maxBytes);
|
||||||
}
|
}
|
||||||
return Buffer.concat(chunks);
|
return buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readFeishuResponseBuffer(params: {
|
async function readFeishuResponseBuffer(params: {
|
||||||
response: FeishuDownloadResponse;
|
response: FeishuDownloadResponse;
|
||||||
tmpDirPrefix: string;
|
tmpDirPrefix: string;
|
||||||
errorPrefix: string;
|
errorPrefix: string;
|
||||||
|
maxBytes: number;
|
||||||
}): Promise<Buffer> {
|
}): Promise<Buffer> {
|
||||||
const { response } = params;
|
const { response, maxBytes } = params;
|
||||||
if (Buffer.isBuffer(response)) {
|
if (Buffer.isBuffer(response)) {
|
||||||
return response;
|
return assertBufferWithinLimit(response, maxBytes);
|
||||||
}
|
}
|
||||||
if (response instanceof ArrayBuffer) {
|
if (response instanceof ArrayBuffer) {
|
||||||
return Buffer.from(response);
|
return assertBufferWithinLimit(Buffer.from(response), maxBytes);
|
||||||
}
|
}
|
||||||
const responseWithOptionalFields = response as FeishuDownloadResponse & {
|
const responseWithOptionalFields = response as FeishuDownloadResponse & {
|
||||||
code?: number;
|
code?: number;
|
||||||
@@ -275,30 +287,121 @@ async function readFeishuResponseBuffer(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (responseWithOptionalFields.data && Buffer.isBuffer(responseWithOptionalFields.data)) {
|
if (responseWithOptionalFields.data && Buffer.isBuffer(responseWithOptionalFields.data)) {
|
||||||
return responseWithOptionalFields.data;
|
return assertBufferWithinLimit(responseWithOptionalFields.data, maxBytes);
|
||||||
}
|
}
|
||||||
if (responseWithOptionalFields.data instanceof ArrayBuffer) {
|
if (responseWithOptionalFields.data instanceof ArrayBuffer) {
|
||||||
return Buffer.from(responseWithOptionalFields.data);
|
return assertBufferWithinLimit(Buffer.from(responseWithOptionalFields.data), maxBytes);
|
||||||
}
|
}
|
||||||
if (typeof response.getReadableStream === "function") {
|
if (typeof response.getReadableStream === "function") {
|
||||||
return readReadableBuffer(response.getReadableStream());
|
return readByteStreamWithLimit(response.getReadableStream(), {
|
||||||
|
maxBytes,
|
||||||
|
onOverflow: () => mediaLimitError(maxBytes),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (typeof response.writeFile === "function") {
|
if (typeof response.writeFile === "function") {
|
||||||
return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
|
return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
|
||||||
await response.writeFile(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);
|
return await fs.promises.readFile(tmpPath);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (responseWithOptionalFields[Symbol.asyncIterator]) {
|
if (responseWithOptionalFields[Symbol.asyncIterator]) {
|
||||||
const asyncIterable = responseWithOptionalFields as AsyncIterable<Buffer | Uint8Array | string>;
|
const asyncIterable = responseWithOptionalFields as AsyncIterable<Buffer | Uint8Array | string>;
|
||||||
const chunks: Buffer[] = [];
|
return readByteStreamWithLimit(asyncIterable, {
|
||||||
for await (const chunk of asyncIterable) {
|
maxBytes,
|
||||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
onOverflow: () => mediaLimitError(maxBytes),
|
||||||
}
|
});
|
||||||
return Buffer.concat(chunks);
|
|
||||||
}
|
}
|
||||||
if (response instanceof Readable) {
|
if (response instanceof Readable) {
|
||||||
return readReadableBuffer(response);
|
return readByteStreamWithLimit(response, {
|
||||||
|
maxBytes,
|
||||||
|
onOverflow: () => mediaLimitError(maxBytes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(response as object);
|
||||||
|
throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${keys.join(", ")}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFeishuResponseMedia(params: {
|
||||||
|
response: FeishuDownloadResponse;
|
||||||
|
tmpDirPrefix: string;
|
||||||
|
errorPrefix: string;
|
||||||
|
maxBytes: number;
|
||||||
|
contentType?: string;
|
||||||
|
fileName?: string;
|
||||||
|
}): Promise<SavedMedia> {
|
||||||
|
const { response, maxBytes, contentType, fileName } = params;
|
||||||
|
if (Buffer.isBuffer(response)) {
|
||||||
|
return saveMediaBuffer(response, contentType, "inbound", maxBytes, fileName);
|
||||||
|
}
|
||||||
|
if (response instanceof ArrayBuffer) {
|
||||||
|
return saveMediaBuffer(Buffer.from(response), contentType, "inbound", maxBytes, fileName);
|
||||||
|
}
|
||||||
|
const responseWithOptionalFields = response as FeishuDownloadResponse & {
|
||||||
|
code?: number;
|
||||||
|
msg?: string;
|
||||||
|
data?: Buffer | ArrayBuffer;
|
||||||
|
[Symbol.asyncIterator]?: () => AsyncIterator<Buffer | Uint8Array | string>;
|
||||||
|
};
|
||||||
|
if (responseWithOptionalFields.code !== undefined && responseWithOptionalFields.code !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${params.errorPrefix}: ${responseWithOptionalFields.msg || `code ${responseWithOptionalFields.code}`}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseWithOptionalFields.data && Buffer.isBuffer(responseWithOptionalFields.data)) {
|
||||||
|
return saveMediaBuffer(
|
||||||
|
responseWithOptionalFields.data,
|
||||||
|
contentType,
|
||||||
|
"inbound",
|
||||||
|
maxBytes,
|
||||||
|
fileName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (responseWithOptionalFields.data instanceof ArrayBuffer) {
|
||||||
|
return saveMediaBuffer(
|
||||||
|
Buffer.from(responseWithOptionalFields.data),
|
||||||
|
contentType,
|
||||||
|
"inbound",
|
||||||
|
maxBytes,
|
||||||
|
fileName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof response.getReadableStream === "function") {
|
||||||
|
return saveMediaStream(
|
||||||
|
response.getReadableStream(),
|
||||||
|
contentType,
|
||||||
|
"inbound",
|
||||||
|
maxBytes,
|
||||||
|
fileName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof response.writeFile === "function") {
|
||||||
|
return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
|
||||||
|
await response.writeFile(tmpPath);
|
||||||
|
const stat = await fs.promises.stat(tmpPath);
|
||||||
|
if (stat.size > maxBytes) {
|
||||||
|
throw mediaLimitError(maxBytes);
|
||||||
|
}
|
||||||
|
return await saveMediaStream(
|
||||||
|
fs.createReadStream(tmpPath),
|
||||||
|
contentType,
|
||||||
|
"inbound",
|
||||||
|
maxBytes,
|
||||||
|
fileName,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (responseWithOptionalFields[Symbol.asyncIterator]) {
|
||||||
|
const asyncIterable = responseWithOptionalFields as AsyncIterable<Buffer | Uint8Array | string>;
|
||||||
|
return saveMediaStream(asyncIterable, contentType, "inbound", maxBytes, fileName);
|
||||||
|
}
|
||||||
|
if (response instanceof Readable) {
|
||||||
|
return saveMediaStream(response, contentType, "inbound", maxBytes, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = Object.keys(response as object);
|
const keys = Object.keys(response as object);
|
||||||
@@ -313,8 +416,9 @@ export async function downloadImageFeishu(params: {
|
|||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
imageKey: string;
|
imageKey: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
maxBytes?: number;
|
||||||
}): Promise<DownloadImageResult> {
|
}): Promise<DownloadImageResult> {
|
||||||
const { cfg, imageKey, accountId } = params;
|
const { cfg, imageKey, accountId, maxBytes = 30 * 1024 * 1024 } = params;
|
||||||
const normalizedImageKey = normalizeFeishuExternalKey(imageKey);
|
const normalizedImageKey = normalizeFeishuExternalKey(imageKey);
|
||||||
if (!normalizedImageKey) {
|
if (!normalizedImageKey) {
|
||||||
throw new Error("Feishu image download failed: invalid image_key");
|
throw new Error("Feishu image download failed: invalid image_key");
|
||||||
@@ -329,6 +433,7 @@ export async function downloadImageFeishu(params: {
|
|||||||
response,
|
response,
|
||||||
tmpDirPrefix: "openclaw-feishu-img-",
|
tmpDirPrefix: "openclaw-feishu-img-",
|
||||||
errorPrefix: "Feishu image download failed",
|
errorPrefix: "Feishu image download failed",
|
||||||
|
maxBytes,
|
||||||
});
|
});
|
||||||
const meta = extractFeishuDownloadMetadata(response);
|
const meta = extractFeishuDownloadMetadata(response);
|
||||||
return { buffer, contentType: meta.contentType };
|
return { buffer, contentType: meta.contentType };
|
||||||
@@ -339,6 +444,7 @@ async function downloadMessageResourceWithType(params: {
|
|||||||
messageId: string;
|
messageId: string;
|
||||||
fileKey: string;
|
fileKey: string;
|
||||||
type: FeishuMessageResourceDownloadType;
|
type: FeishuMessageResourceDownloadType;
|
||||||
|
maxBytes: number;
|
||||||
}): Promise<DownloadMessageResourceResult> {
|
}): Promise<DownloadMessageResourceResult> {
|
||||||
const response = await params.client.im.messageResource.get({
|
const response = await params.client.im.messageResource.get({
|
||||||
path: { message_id: params.messageId, file_key: params.fileKey },
|
path: { message_id: params.messageId, file_key: params.fileKey },
|
||||||
@@ -349,10 +455,35 @@ async function downloadMessageResourceWithType(params: {
|
|||||||
response,
|
response,
|
||||||
tmpDirPrefix: "openclaw-feishu-resource-",
|
tmpDirPrefix: "openclaw-feishu-resource-",
|
||||||
errorPrefix: "Feishu message resource download failed",
|
errorPrefix: "Feishu message resource download failed",
|
||||||
|
maxBytes: params.maxBytes,
|
||||||
});
|
});
|
||||||
return { buffer, ...extractFeishuDownloadMetadata(response) };
|
return { buffer, ...extractFeishuDownloadMetadata(response) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveMessageResourceWithType(params: {
|
||||||
|
client: ReturnType<typeof createFeishuClient>;
|
||||||
|
messageId: string;
|
||||||
|
fileKey: string;
|
||||||
|
type: FeishuMessageResourceDownloadType;
|
||||||
|
maxBytes: number;
|
||||||
|
originalFilename?: string;
|
||||||
|
}): Promise<SaveMessageResourceResult> {
|
||||||
|
const response = await params.client.im.messageResource.get({
|
||||||
|
path: { message_id: params.messageId, file_key: params.fileKey },
|
||||||
|
params: { type: params.type },
|
||||||
|
});
|
||||||
|
const meta = extractFeishuDownloadMetadata(response);
|
||||||
|
const saved = await saveFeishuResponseMedia({
|
||||||
|
response,
|
||||||
|
tmpDirPrefix: "openclaw-feishu-resource-",
|
||||||
|
errorPrefix: "Feishu message resource download failed",
|
||||||
|
maxBytes: params.maxBytes,
|
||||||
|
contentType: meta.contentType,
|
||||||
|
fileName: meta.fileName ?? params.originalFilename,
|
||||||
|
});
|
||||||
|
return { saved, ...meta };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download a message resource (file/image/audio/video) from Feishu.
|
* Download a message resource (file/image/audio/video) from Feishu.
|
||||||
* Used for downloading files, audio, and video from messages.
|
* Used for downloading files, audio, and video from messages.
|
||||||
@@ -363,8 +494,9 @@ export async function downloadMessageResourceFeishu(params: {
|
|||||||
fileKey: string;
|
fileKey: string;
|
||||||
type: "image" | "file";
|
type: "image" | "file";
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
maxBytes?: number;
|
||||||
}): Promise<DownloadMessageResourceResult> {
|
}): Promise<DownloadMessageResourceResult> {
|
||||||
const { cfg, messageId, fileKey, type, accountId } = params;
|
const { cfg, messageId, fileKey, type, accountId, maxBytes = 30 * 1024 * 1024 } = params;
|
||||||
const normalizedFileKey = normalizeFeishuExternalKey(fileKey);
|
const normalizedFileKey = normalizeFeishuExternalKey(fileKey);
|
||||||
if (!normalizedFileKey) {
|
if (!normalizedFileKey) {
|
||||||
throw new Error("Feishu message resource download failed: invalid file_key");
|
throw new Error("Feishu message resource download failed: invalid file_key");
|
||||||
@@ -377,6 +509,7 @@ export async function downloadMessageResourceFeishu(params: {
|
|||||||
messageId,
|
messageId,
|
||||||
fileKey: normalizedFileKey,
|
fileKey: normalizedFileKey,
|
||||||
type,
|
type,
|
||||||
|
maxBytes,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (type !== "file" || !isHttpStatusError(err, 502)) {
|
if (type !== "file" || !isHttpStatusError(err, 502)) {
|
||||||
@@ -388,6 +521,51 @@ export async function downloadMessageResourceFeishu(params: {
|
|||||||
messageId,
|
messageId,
|
||||||
fileKey: normalizedFileKey,
|
fileKey: normalizedFileKey,
|
||||||
type: "media",
|
type: "media",
|
||||||
|
maxBytes,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveMessageResourceFeishu(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
messageId: string;
|
||||||
|
fileKey: string;
|
||||||
|
type: "image" | "file";
|
||||||
|
accountId?: string;
|
||||||
|
maxBytes: number;
|
||||||
|
originalFilename?: string;
|
||||||
|
}): Promise<SaveMessageResourceResult> {
|
||||||
|
const { cfg, messageId, fileKey, type, accountId, maxBytes, originalFilename } = params;
|
||||||
|
const normalizedFileKey = normalizeFeishuExternalKey(fileKey);
|
||||||
|
if (!normalizedFileKey) {
|
||||||
|
throw new Error("Feishu message resource download failed: invalid file_key");
|
||||||
|
}
|
||||||
|
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await saveMessageResourceWithType({
|
||||||
|
client,
|
||||||
|
messageId,
|
||||||
|
fileKey: normalizedFileKey,
|
||||||
|
type,
|
||||||
|
maxBytes,
|
||||||
|
originalFilename,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (type !== "file" || !isHttpStatusError(err, 502)) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await saveMessageResourceWithType({
|
||||||
|
client,
|
||||||
|
messageId,
|
||||||
|
fileKey: normalizedFileKey,
|
||||||
|
type: "media",
|
||||||
|
maxBytes,
|
||||||
|
originalFilename,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ export {
|
|||||||
warnMissingProviderGroupPolicyFallbackOnce,
|
warnMissingProviderGroupPolicyFallbackOnce,
|
||||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||||
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
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 { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
|
||||||
export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||||
export { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
export { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ describe("googlechat message actions", () => {
|
|||||||
});
|
});
|
||||||
resolveGoogleChatAccount.mockReturnValue(account);
|
resolveGoogleChatAccount.mockReturnValue(account);
|
||||||
resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/AAA");
|
resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/AAA");
|
||||||
const fetchRemoteMedia = vi.fn(async () => ({
|
const readRemoteMediaBuffer = vi.fn(async () => ({
|
||||||
buffer: Buffer.from("remote-bytes"),
|
buffer: Buffer.from("remote-bytes"),
|
||||||
fileName: "remote.png",
|
fileName: "remote.png",
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
@@ -125,7 +125,7 @@ describe("googlechat message actions", () => {
|
|||||||
getGoogleChatRuntime.mockReturnValue({
|
getGoogleChatRuntime.mockReturnValue({
|
||||||
channel: {
|
channel: {
|
||||||
media: {
|
media: {
|
||||||
fetchRemoteMedia,
|
readRemoteMediaBuffer,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -155,7 +155,7 @@ describe("googlechat message actions", () => {
|
|||||||
account,
|
account,
|
||||||
target: "spaces/AAA",
|
target: "spaces/AAA",
|
||||||
});
|
});
|
||||||
expect(fetchRemoteMedia).toHaveBeenCalledWith({
|
expect(readRemoteMediaBuffer).toHaveBeenCalledWith({
|
||||||
url: "https://example.com/file.png",
|
url: "https://example.com/file.png",
|
||||||
maxBytes: 5 * 1024 * 1024,
|
maxBytes: 5 * 1024 * 1024,
|
||||||
});
|
});
|
||||||
@@ -188,7 +188,7 @@ describe("googlechat message actions", () => {
|
|||||||
getGoogleChatRuntime.mockReturnValue({
|
getGoogleChatRuntime.mockReturnValue({
|
||||||
channel: {
|
channel: {
|
||||||
media: {
|
media: {
|
||||||
fetchRemoteMedia: vi.fn(),
|
readRemoteMediaBuffer: vi.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ async function loadGoogleChatActionMedia(params: {
|
|||||||
}) {
|
}) {
|
||||||
const runtime = getGoogleChatRuntime();
|
const runtime = getGoogleChatRuntime();
|
||||||
return /^https?:\/\//i.test(params.mediaUrl)
|
return /^https?:\/\//i.test(params.mediaUrl)
|
||||||
? await runtime.channel.media.fetchRemoteMedia({
|
? await runtime.channel.media.readRemoteMediaBuffer({
|
||||||
url: params.mediaUrl,
|
url: params.mediaUrl,
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
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 { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||||
import { getGoogleChatAccessToken } from "./auth.js";
|
import { getGoogleChatAccessToken } from "./auth.js";
|
||||||
@@ -108,30 +109,14 @@ async function fetchBuffer(
|
|||||||
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!maxBytes || !res.body) {
|
if (!maxBytes) {
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
const contentType = res.headers.get("content-type") ?? undefined;
|
const contentType = res.headers.get("content-type") ?? undefined;
|
||||||
return { buffer, contentType };
|
return { buffer, contentType };
|
||||||
}
|
}
|
||||||
const reader = res.body.getReader();
|
const buffer = await readResponseWithLimit(res, maxBytes, {
|
||||||
const chunks: Buffer[] = [];
|
onOverflow: () => new Error(`Google Chat media exceeds max bytes (${maxBytes})`),
|
||||||
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 contentType = res.headers.get("content-type") ?? undefined;
|
const contentType = res.headers.get("content-type") ?? undefined;
|
||||||
return { buffer, contentType };
|
return { buffer, contentType };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
type ResolvedGoogleChatAccount,
|
type ResolvedGoogleChatAccount,
|
||||||
chunkTextForOutbound,
|
chunkTextForOutbound,
|
||||||
fetchRemoteMedia,
|
readRemoteMediaBuffer,
|
||||||
isGoogleChatUserTarget,
|
isGoogleChatUserTarget,
|
||||||
loadOutboundMediaFromUrl,
|
loadOutboundMediaFromUrl,
|
||||||
missingTargetError,
|
missingTargetError,
|
||||||
@@ -280,7 +280,7 @@ export const googlechatOutboundAdapter = {
|
|||||||
});
|
});
|
||||||
const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
|
const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
|
||||||
const loaded = /^https?:\/\//i.test(mediaUrl)
|
const loaded = /^https?:\/\//i.test(mediaUrl)
|
||||||
? await fetchRemoteMedia({
|
? await readRemoteMediaBuffer({
|
||||||
url: mediaUrl,
|
url: mediaUrl,
|
||||||
maxBytes: effectiveMaxBytes,
|
maxBytes: effectiveMaxBytes,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export {
|
|||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
chunkTextForOutbound,
|
chunkTextForOutbound,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
fetchRemoteMedia,
|
readRemoteMediaBuffer,
|
||||||
GoogleChatConfigSchema,
|
GoogleChatConfigSchema,
|
||||||
loadOutboundMediaFromUrl,
|
loadOutboundMediaFromUrl,
|
||||||
missingTargetError,
|
missingTargetError,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const uploadGoogleChatAttachmentMock = vi.hoisted(() => vi.fn());
|
|||||||
const sendGoogleChatMessageMock = vi.hoisted(() => vi.fn());
|
const sendGoogleChatMessageMock = vi.hoisted(() => vi.fn());
|
||||||
const resolveGoogleChatAccountMock = vi.hoisted(() => vi.fn());
|
const resolveGoogleChatAccountMock = vi.hoisted(() => vi.fn());
|
||||||
const resolveGoogleChatOutboundSpaceMock = 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 loadOutboundMediaFromUrlMock = vi.hoisted(() => vi.fn());
|
||||||
const probeGoogleChatMock = vi.hoisted(() => vi.fn());
|
const probeGoogleChatMock = vi.hoisted(() => vi.fn());
|
||||||
const startGoogleChatMonitorMock = vi.hoisted(() => vi.fn());
|
const startGoogleChatMonitorMock = vi.hoisted(() => vi.fn());
|
||||||
@@ -82,7 +82,7 @@ function mockGoogleChatMediaLoaders() {
|
|||||||
fileName: mediaUrl.split("/").pop() || "attachment",
|
fileName: mediaUrl.split("/").pop() || "attachment",
|
||||||
contentType: "application/octet-stream",
|
contentType: "application/octet-stream",
|
||||||
}));
|
}));
|
||||||
fetchRemoteMediaMock.mockImplementation(async () => ({
|
readRemoteMediaBufferMock.mockImplementation(async () => ({
|
||||||
buffer: Buffer.from("remote-bytes"),
|
buffer: Buffer.from("remote-bytes"),
|
||||||
fileName: "remote.png",
|
fileName: "remote.png",
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
@@ -127,7 +127,7 @@ vi.mock("./channel.deps.runtime.js", () => {
|
|||||||
return chunks;
|
return chunks;
|
||||||
},
|
},
|
||||||
createAccountStatusSink: () => () => {},
|
createAccountStatusSink: () => () => {},
|
||||||
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMediaMock(...args),
|
readRemoteMediaBuffer: (...args: unknown[]) => readRemoteMediaBufferMock(...args),
|
||||||
getChatChannelMeta: (id: string) => ({ id, name: id }),
|
getChatChannelMeta: (id: string) => ({ id, name: id }),
|
||||||
isGoogleChatSpaceTarget: (value: string) => value.toLowerCase().startsWith("spaces/"),
|
isGoogleChatSpaceTarget: (value: string) => value.toLowerCase().startsWith("spaces/"),
|
||||||
isGoogleChatUserTarget: (value: string) => value.toLowerCase().startsWith("users/"),
|
isGoogleChatUserTarget: (value: string) => value.toLowerCase().startsWith("users/"),
|
||||||
@@ -203,16 +203,16 @@ function setupRuntimeMediaMocks(params: { loadFileName: string; loadBytes: strin
|
|||||||
fileName: params.loadFileName,
|
fileName: params.loadFileName,
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
}));
|
}));
|
||||||
const fetchRemoteMedia = vi.fn(async () => ({
|
const readRemoteMediaBuffer = vi.fn(async () => ({
|
||||||
buffer: Buffer.from("remote-bytes"),
|
buffer: Buffer.from("remote-bytes"),
|
||||||
fileName: "remote.png",
|
fileName: "remote.png",
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
loadOutboundMediaFromUrlMock.mockImplementation(loadOutboundMediaFromUrl);
|
loadOutboundMediaFromUrlMock.mockImplementation(loadOutboundMediaFromUrl);
|
||||||
fetchRemoteMediaMock.mockImplementation(fetchRemoteMedia);
|
readRemoteMediaBufferMock.mockImplementation(readRemoteMediaBuffer);
|
||||||
|
|
||||||
return { loadOutboundMediaFromUrl, fetchRemoteMedia };
|
return { loadOutboundMediaFromUrl, readRemoteMediaBuffer };
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireMockArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0): unknown {
|
function requireMockArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0): unknown {
|
||||||
@@ -308,7 +308,7 @@ describe("googlechatPlugin outbound sendMedia", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("loads local media with mediaLocalRoots via runtime media loader", async () => {
|
it("loads local media with mediaLocalRoots via runtime media loader", async () => {
|
||||||
const { loadOutboundMediaFromUrl, fetchRemoteMedia } = setupRuntimeMediaMocks({
|
const { loadOutboundMediaFromUrl, readRemoteMediaBuffer } = setupRuntimeMediaMocks({
|
||||||
loadFileName: "image.png",
|
loadFileName: "image.png",
|
||||||
loadBytes: "image-bytes",
|
loadBytes: "image-bytes",
|
||||||
});
|
});
|
||||||
@@ -337,7 +337,7 @@ describe("googlechatPlugin outbound sendMedia", () => {
|
|||||||
];
|
];
|
||||||
expect(mediaUrl).toBe("/tmp/workspace/image.png");
|
expect(mediaUrl).toBe("/tmp/workspace/image.png");
|
||||||
expect(mediaOptions.mediaLocalRoots).toEqual(["/tmp/workspace"]);
|
expect(mediaOptions.mediaLocalRoots).toEqual(["/tmp/workspace"]);
|
||||||
expect(fetchRemoteMedia).not.toHaveBeenCalled();
|
expect(readRemoteMediaBuffer).not.toHaveBeenCalled();
|
||||||
const uploadRequest = requireMockArg(uploadGoogleChatAttachmentMock) as {
|
const uploadRequest = requireMockArg(uploadGoogleChatAttachmentMock) as {
|
||||||
space?: string;
|
space?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
@@ -357,8 +357,8 @@ describe("googlechatPlugin outbound sendMedia", () => {
|
|||||||
expect(result.receipt.primaryPlatformMessageId).toBe("spaces/AAA/messages/msg-1");
|
expect(result.receipt.primaryPlatformMessageId).toBe("spaces/AAA/messages/msg-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps remote URL media fetch on fetchRemoteMedia with maxBytes cap", async () => {
|
it("keeps remote URL media fetch on readRemoteMediaBuffer with maxBytes cap", async () => {
|
||||||
const { loadOutboundMediaFromUrl, fetchRemoteMedia } = setupRuntimeMediaMocks({
|
const { loadOutboundMediaFromUrl, readRemoteMediaBuffer } = setupRuntimeMediaMocks({
|
||||||
loadFileName: "unused.png",
|
loadFileName: "unused.png",
|
||||||
loadBytes: "should-not-be-used",
|
loadBytes: "should-not-be-used",
|
||||||
});
|
});
|
||||||
@@ -380,7 +380,7 @@ describe("googlechatPlugin outbound sendMedia", () => {
|
|||||||
accountId: "default",
|
accountId: "default",
|
||||||
});
|
});
|
||||||
|
|
||||||
const remoteRequest = requireMockArg(fetchRemoteMedia) as {
|
const remoteRequest = requireMockArg(readRemoteMediaBuffer) as {
|
||||||
url?: string;
|
url?: string;
|
||||||
maxBytes?: number;
|
maxBytes?: number;
|
||||||
};
|
};
|
||||||
@@ -602,7 +602,7 @@ describe("googlechatPlugin outbound cfg threading", () => {
|
|||||||
config: { mediaMaxMb: 20 },
|
config: { mediaMaxMb: 20 },
|
||||||
credentialSource: "inline" as const,
|
credentialSource: "inline" as const,
|
||||||
};
|
};
|
||||||
const { fetchRemoteMedia } = setupRuntimeMediaMocks({
|
const { readRemoteMediaBuffer } = setupRuntimeMediaMocks({
|
||||||
loadFileName: "unused.png",
|
loadFileName: "unused.png",
|
||||||
loadBytes: "should-not-be-used",
|
loadBytes: "should-not-be-used",
|
||||||
});
|
});
|
||||||
@@ -628,7 +628,7 @@ describe("googlechatPlugin outbound cfg threading", () => {
|
|||||||
cfg,
|
cfg,
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
});
|
});
|
||||||
const remoteRequest = requireMockArg(fetchRemoteMedia) as {
|
const remoteRequest = requireMockArg(readRemoteMediaBuffer) as {
|
||||||
url?: string;
|
url?: string;
|
||||||
maxBytes?: number;
|
maxBytes?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export async function deliverGoogleChatReply(params: {
|
|||||||
},
|
},
|
||||||
sendMedia: async ({ mediaUrl, caption }) => {
|
sendMedia: async ({ mediaUrl, caption }) => {
|
||||||
try {
|
try {
|
||||||
const loaded = await core.channel.media.fetchRemoteMedia({
|
const loaded = await core.channel.media.readRemoteMediaBuffer({
|
||||||
url: mediaUrl,
|
url: mediaUrl,
|
||||||
maxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
|
maxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function createCore(params?: {
|
|||||||
chunkMarkdownTextWithMode: vi.fn((text: string) => params?.chunks ?? [text]),
|
chunkMarkdownTextWithMode: vi.fn((text: string) => params?.chunks ?? [text]),
|
||||||
},
|
},
|
||||||
media: {
|
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;
|
} as unknown as GoogleChatCoreRuntime;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const getMessageContentMock = vi.hoisted(() => vi.fn());
|
const getMessageContentMock = vi.hoisted(() => vi.fn());
|
||||||
const saveMediaBufferMock = vi.hoisted(() => vi.fn());
|
const saveMediaStreamMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("@line/bot-sdk", () => ({
|
vi.mock("@line/bot-sdk", () => ({
|
||||||
messagingApi: {
|
messagingApi: {
|
||||||
@@ -28,7 +28,7 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk/media-store", () => ({
|
vi.mock("openclaw/plugin-sdk/media-store", () => ({
|
||||||
saveMediaBuffer: saveMediaBufferMock,
|
saveMediaStream: saveMediaStreamMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let downloadLineMedia: typeof import("./download.js").downloadLineMedia;
|
let downloadLineMedia: typeof import("./download.js").downloadLineMedia;
|
||||||
@@ -39,14 +39,24 @@ async function* chunks(parts: Buffer[]): AsyncGenerator<Buffer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveMediaBufferCall(): unknown[] {
|
function saveMediaStreamCall(): unknown[] {
|
||||||
const call = saveMediaBufferMock.mock.calls[0];
|
const call = saveMediaStreamMock.mock.calls.at(0);
|
||||||
if (!call) {
|
if (!call) {
|
||||||
throw new Error("Expected saveMediaBuffer call");
|
throw new Error("Expected saveMediaStream call");
|
||||||
}
|
}
|
||||||
return 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", () => {
|
describe("downloadLineMedia", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
({ downloadLineMedia } = await import("./download.js"));
|
({ downloadLineMedia } = await import("./download.js"));
|
||||||
@@ -62,12 +72,20 @@ describe("downloadLineMedia", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
getMessageContentMock.mockReset();
|
getMessageContentMock.mockReset();
|
||||||
saveMediaBufferMock.mockReset();
|
saveMediaStreamMock.mockReset();
|
||||||
saveMediaBufferMock.mockImplementation(
|
saveMediaStreamMock.mockImplementation(
|
||||||
async (_buffer: Buffer, contentType?: string, subdir?: string) => ({
|
async (stream: AsyncIterable<Buffer>, contentType?: string, subdir?: string) => {
|
||||||
path: `/home/user/.openclaw/media/${subdir ?? "unknown"}/saved-media`,
|
const chunks: Buffer[] = [];
|
||||||
contentType,
|
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");
|
const result = await downloadLineMedia("mid-jpeg", "token");
|
||||||
|
|
||||||
expect(saveMediaBufferMock).toHaveBeenCalledTimes(1);
|
expect(saveMediaStreamMock).toHaveBeenCalledTimes(1);
|
||||||
const call = saveMediaBufferCall();
|
const call = saveMediaStreamCall();
|
||||||
expect((call[0] as Buffer).equals(jpeg)).toBe(true);
|
expect(call[1]).toBeUndefined();
|
||||||
expect(call[1]).toBe("image/jpeg");
|
|
||||||
expect(call[2]).toBe("inbound");
|
expect(call[2]).toBe("inbound");
|
||||||
expect(call[3]).toBe(10 * 1024 * 1024);
|
expect(call[3]).toBe(10 * 1024 * 1024);
|
||||||
expect(result).toEqual({
|
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 messageId = "a/../../../../etc/passwd";
|
||||||
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
|
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
|
||||||
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
|
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
|
||||||
@@ -99,21 +116,22 @@ describe("downloadLineMedia", () => {
|
|||||||
|
|
||||||
expect(result.size).toBe(jpeg.length);
|
expect(result.size).toBe(jpeg.length);
|
||||||
expect(result.contentType).toBe("image/jpeg");
|
expect(result.contentType).toBe("image/jpeg");
|
||||||
for (const arg of saveMediaBufferCall()) {
|
for (const arg of saveMediaStreamCall()) {
|
||||||
if (typeof arg === "string") {
|
if (typeof arg === "string") {
|
||||||
expect(arg).not.toContain(messageId);
|
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)]));
|
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);
|
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([
|
const m4aHeader = Buffer.from([
|
||||||
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20,
|
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");
|
const result = await downloadLineMedia("mid-audio", "token");
|
||||||
|
|
||||||
expect(result.contentType).toBe("audio/mp4");
|
expect(result.contentType).toBe("audio/x-m4a");
|
||||||
expect(saveMediaBufferCall()[1]).toBe("audio/mp4");
|
expect(saveMediaStreamCall()[2]).toBe("inbound");
|
||||||
expect(saveMediaBufferCall()[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([
|
const mp4 = Buffer.from([
|
||||||
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d,
|
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");
|
const result = await downloadLineMedia("mid-mp4", "token");
|
||||||
|
|
||||||
expect(result.contentType).toBe("video/mp4");
|
expect(result.contentType).toBe("video/mp4");
|
||||||
expect(saveMediaBufferCall()[1]).toBe("video/mp4");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("propagates media store failures", async () => {
|
it("propagates media store failures", async () => {
|
||||||
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
|
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
|
||||||
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
|
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);
|
await expect(downloadLineMedia("mid-bad", "token")).rejects.toThrow(/Media exceeds/i);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { messagingApi } from "@line/bot-sdk";
|
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 { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||||
import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
||||||
|
|
||||||
interface DownloadResult {
|
interface DownloadResult {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -9,8 +8,6 @@ interface DownloadResult {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUDIO_BRANDS = new Set(["m4a ", "m4b ", "m4p ", "m4r ", "f4a ", "f4b "]);
|
|
||||||
|
|
||||||
export async function downloadLineMedia(
|
export async function downloadLineMedia(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
channelAccessToken: string,
|
channelAccessToken: string,
|
||||||
@@ -21,67 +18,17 @@ export async function downloadLineMedia(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const response = await client.getMessageContent(messageId);
|
const response = await client.getMessageContent(messageId);
|
||||||
const chunks: Buffer[] = [];
|
const saved = await saveMediaStream(
|
||||||
let totalSize = 0;
|
response as AsyncIterable<Buffer>,
|
||||||
|
undefined,
|
||||||
for await (const chunk of response as AsyncIterable<Buffer>) {
|
"inbound",
|
||||||
totalSize += chunk.length;
|
maxBytes,
|
||||||
if (totalSize > maxBytes) {
|
);
|
||||||
throw new Error(`Media exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`);
|
logVerbose(`line: persisted media ${messageId} to ${saved.path} (${saved.size} bytes)`);
|
||||||
}
|
|
||||||
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)`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: saved.path,
|
path: saved.path,
|
||||||
contentType: saved.contentType,
|
contentType: saved.contentType,
|
||||||
size: buffer.length,
|
size: saved.size,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectContentType(buffer: Buffer): string {
|
|
||||||
const hasFtypBox =
|
|
||||||
buffer.length >= 12 &&
|
|
||||||
buffer[4] === 0x66 &&
|
|
||||||
buffer[5] === 0x74 &&
|
|
||||||
buffer[6] === 0x79 &&
|
|
||||||
buffer[7] === 0x70;
|
|
||||||
|
|
||||||
if (buffer.length >= 2) {
|
|
||||||
if (buffer[0] === 0xff && buffer[1] === 0xd8) {
|
|
||||||
return "image/jpeg";
|
|
||||||
}
|
|
||||||
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
|
|
||||||
return "image/png";
|
|
||||||
}
|
|
||||||
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
|
|
||||||
return "image/gif";
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
buffer[0] === 0x52 &&
|
|
||||||
buffer[1] === 0x49 &&
|
|
||||||
buffer[2] === 0x46 &&
|
|
||||||
buffer[3] === 0x46 &&
|
|
||||||
buffer[8] === 0x57 &&
|
|
||||||
buffer[9] === 0x45 &&
|
|
||||||
buffer[10] === 0x42 &&
|
|
||||||
buffer[11] === 0x50
|
|
||||||
) {
|
|
||||||
return "image/webp";
|
|
||||||
}
|
|
||||||
if (hasFtypBox) {
|
|
||||||
const majorBrand = lowercasePreservingWhitespace(buffer.toString("ascii", 8, 12));
|
|
||||||
if (AUDIO_BRANDS.has(majorBrand)) {
|
|
||||||
return "audio/mp4";
|
|
||||||
}
|
|
||||||
return "video/mp4";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "application/octet-stream";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
implicitMentionKindWhen,
|
implicitMentionKindWhen,
|
||||||
resolveInboundMentionDecision,
|
resolveInboundMentionDecision,
|
||||||
} from "openclaw/plugin-sdk/channel-mention-gating";
|
} from "openclaw/plugin-sdk/channel-mention-gating";
|
||||||
|
import { createPluginRuntimeMediaMock } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import type { PluginRuntime } from "./runtime-api.js";
|
import type { PluginRuntime } from "./runtime-api.js";
|
||||||
import { setMatrixRuntime } from "./runtime.js";
|
import { setMatrixRuntime } from "./runtime.js";
|
||||||
@@ -75,10 +76,9 @@ export function installMatrixMonitorTestRuntime(
|
|||||||
implicitMentionKindWhen,
|
implicitMentionKindWhen,
|
||||||
resolveInboundMentionDecision,
|
resolveInboundMentionDecision,
|
||||||
},
|
},
|
||||||
media: {
|
media: createPluginRuntimeMediaMock({
|
||||||
fetchRemoteMedia: vi.fn(),
|
|
||||||
saveMediaBuffer: options.saveMediaBuffer ?? vi.fn(),
|
saveMediaBuffer: options.saveMediaBuffer ?? vi.fn(),
|
||||||
},
|
}) as unknown as PluginRuntime["channel"]["media"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,11 +33,7 @@ describe("mattermost monitor resources", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("downloads media, preserves auth headers, and infers media kind", async () => {
|
it("downloads media, preserves auth headers, and infers media kind", async () => {
|
||||||
const fetchRemoteMedia = vi.fn(async () => ({
|
const saveRemoteMedia = vi.fn(async () => ({
|
||||||
buffer: new Uint8Array([1, 2, 3]),
|
|
||||||
contentType: "image/png",
|
|
||||||
}));
|
|
||||||
const saveMediaBuffer = vi.fn(async () => ({
|
|
||||||
path: "/tmp/file.png",
|
path: "/tmp/file.png",
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
}));
|
}));
|
||||||
@@ -52,8 +48,7 @@ describe("mattermost monitor resources", () => {
|
|||||||
} as never,
|
} as never,
|
||||||
logger: {},
|
logger: {},
|
||||||
mediaMaxBytes: 1024,
|
mediaMaxBytes: 1024,
|
||||||
fetchRemoteMedia,
|
saveRemoteMedia,
|
||||||
saveMediaBuffer,
|
|
||||||
mediaKindFromMime: () => "image",
|
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",
|
url: "https://chat.example.com/api/v4/files/file-1",
|
||||||
requestInit: {
|
requestInit: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -89,8 +84,7 @@ describe("mattermost monitor resources", () => {
|
|||||||
client: {} as never,
|
client: {} as never,
|
||||||
logger: {},
|
logger: {},
|
||||||
mediaMaxBytes: 1024,
|
mediaMaxBytes: 1024,
|
||||||
fetchRemoteMedia: vi.fn(),
|
saveRemoteMedia: vi.fn(),
|
||||||
saveMediaBuffer: vi.fn(),
|
|
||||||
mediaKindFromMime: () => "document",
|
mediaKindFromMime: () => "document",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,8 +129,7 @@ describe("mattermost monitor resources", () => {
|
|||||||
client,
|
client,
|
||||||
logger: {},
|
logger: {},
|
||||||
mediaMaxBytes: 1024,
|
mediaMaxBytes: 1024,
|
||||||
fetchRemoteMedia: vi.fn(),
|
saveRemoteMedia: vi.fn(),
|
||||||
saveMediaBuffer: vi.fn(),
|
|
||||||
mediaKindFromMime: () => "document",
|
mediaKindFromMime: () => "document",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,20 +20,13 @@ export type MattermostMediaInfo = {
|
|||||||
const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
|
const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
|
||||||
const USER_CACHE_TTL_MS = 10 * 60_000;
|
const USER_CACHE_TTL_MS = 10 * 60_000;
|
||||||
|
|
||||||
type FetchRemoteMedia = (params: {
|
type SaveRemoteMedia = (params: {
|
||||||
url: string;
|
url: string;
|
||||||
requestInit?: RequestInit;
|
requestInit?: RequestInit;
|
||||||
filePathHint?: string;
|
filePathHint?: string;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
ssrfPolicy?: { allowedHostnames?: string[] };
|
ssrfPolicy?: { allowedHostnames?: string[] };
|
||||||
}) => Promise<{ buffer: Uint8Array; contentType?: string | null }>;
|
}) => Promise<{ path: string; contentType?: string | null }>;
|
||||||
|
|
||||||
type SaveMediaBuffer = (
|
|
||||||
buffer: Uint8Array,
|
|
||||||
contentType: string | undefined,
|
|
||||||
direction: "inbound" | "outbound",
|
|
||||||
maxBytes: number,
|
|
||||||
) => Promise<{ path: string; contentType?: string | null }>;
|
|
||||||
|
|
||||||
export function createMattermostMonitorResources(params: {
|
export function createMattermostMonitorResources(params: {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@@ -41,8 +34,7 @@ export function createMattermostMonitorResources(params: {
|
|||||||
client: MattermostClient;
|
client: MattermostClient;
|
||||||
logger: { debug?: (...args: unknown[]) => void };
|
logger: { debug?: (...args: unknown[]) => void };
|
||||||
mediaMaxBytes: number;
|
mediaMaxBytes: number;
|
||||||
fetchRemoteMedia: FetchRemoteMedia;
|
saveRemoteMedia: SaveRemoteMedia;
|
||||||
saveMediaBuffer: SaveMediaBuffer;
|
|
||||||
mediaKindFromMime: (contentType?: string) => MattermostMediaKind | null | undefined;
|
mediaKindFromMime: (contentType?: string) => MattermostMediaKind | null | undefined;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
@@ -51,8 +43,7 @@ export function createMattermostMonitorResources(params: {
|
|||||||
client,
|
client,
|
||||||
logger,
|
logger,
|
||||||
mediaMaxBytes,
|
mediaMaxBytes,
|
||||||
fetchRemoteMedia,
|
saveRemoteMedia,
|
||||||
saveMediaBuffer,
|
|
||||||
mediaKindFromMime,
|
mediaKindFromMime,
|
||||||
} = params;
|
} = params;
|
||||||
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
|
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
|
||||||
@@ -68,7 +59,7 @@ export function createMattermostMonitorResources(params: {
|
|||||||
const out: MattermostMediaInfo[] = [];
|
const out: MattermostMediaInfo[] = [];
|
||||||
for (const fileId of ids) {
|
for (const fileId of ids) {
|
||||||
try {
|
try {
|
||||||
const fetched = await fetchRemoteMedia({
|
const saved = await saveRemoteMedia({
|
||||||
url: `${client.apiBaseUrl}/files/${fileId}`,
|
url: `${client.apiBaseUrl}/files/${fileId}`,
|
||||||
requestInit: {
|
requestInit: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -79,13 +70,7 @@ export function createMattermostMonitorResources(params: {
|
|||||||
maxBytes: mediaMaxBytes,
|
maxBytes: mediaMaxBytes,
|
||||||
ssrfPolicy: { allowedHostnames: [new URL(client.baseUrl).hostname] },
|
ssrfPolicy: { allowedHostnames: [new URL(client.baseUrl).hostname] },
|
||||||
});
|
});
|
||||||
const saved = await saveMediaBuffer(
|
const contentType = saved.contentType ?? undefined;
|
||||||
Buffer.from(fetched.buffer),
|
|
||||||
fetched.contentType ?? undefined,
|
|
||||||
"inbound",
|
|
||||||
mediaMaxBytes,
|
|
||||||
);
|
|
||||||
const contentType = saved.contentType ?? fetched.contentType ?? undefined;
|
|
||||||
out.push({
|
out.push({
|
||||||
path: saved.path,
|
path: saved.path,
|
||||||
contentType,
|
contentType,
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ function createRuntimeCore(cfg: OpenClawConfig) {
|
|||||||
resolveRequireMention: () => false,
|
resolveRequireMention: () => false,
|
||||||
},
|
},
|
||||||
media: {
|
media: {
|
||||||
fetchRemoteMedia: vi.fn(),
|
readRemoteMediaBuffer: vi.fn(),
|
||||||
saveMediaBuffer: vi.fn(),
|
saveMediaBuffer: vi.fn(),
|
||||||
},
|
},
|
||||||
mentions: {
|
mentions: {
|
||||||
|
|||||||
@@ -823,9 +823,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
debug: (message) => logger.debug?.(String(message)),
|
debug: (message) => logger.debug?.(String(message)),
|
||||||
},
|
},
|
||||||
mediaMaxBytes,
|
mediaMaxBytes,
|
||||||
fetchRemoteMedia: (params) => core.channel.media.fetchRemoteMedia(params),
|
saveRemoteMedia: (params) => core.channel.media.saveRemoteMedia(params),
|
||||||
saveMediaBuffer: (buffer, contentType, direction, maxBytes) =>
|
|
||||||
core.channel.media.saveMediaBuffer(Buffer.from(buffer), contentType, direction, maxBytes),
|
|
||||||
mediaKindFromMime: (contentType) => core.media.mediaKindFromMime(contentType) as MediaKind,
|
mediaKindFromMime: (contentType) => core.media.mediaKindFromMime(contentType) as MediaKind,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,13 +18,21 @@ const CONTENT_TYPE_APPLICATION_PDF = "application/pdf";
|
|||||||
const PNG_BUFFER = Buffer.from("png");
|
const PNG_BUFFER = Buffer.from("png");
|
||||||
|
|
||||||
const detectMimeMock = vi.fn(async () => CONTENT_TYPE_IMAGE_PNG);
|
const detectMimeMock = vi.fn(async () => CONTENT_TYPE_IMAGE_PNG);
|
||||||
const saveMediaBufferMock = vi.fn(async () => ({
|
const saveMediaBufferMock = vi.fn(
|
||||||
id: "saved.png",
|
async (
|
||||||
path: "/tmp/saved.png",
|
_buffer: Buffer,
|
||||||
size: Buffer.byteLength(PNG_BUFFER),
|
contentType?: string,
|
||||||
contentType: CONTENT_TYPE_IMAGE_PNG,
|
_subdir?: string,
|
||||||
}));
|
_maxBytes?: number,
|
||||||
const fetchRemoteMediaMock = vi.fn(
|
_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: {
|
async (params: {
|
||||||
url: string;
|
url: string;
|
||||||
maxBytes?: number;
|
maxBytes?: number;
|
||||||
@@ -36,6 +44,43 @@ const fetchRemoteMediaMock = vi.fn(
|
|||||||
return readRemoteMediaResponse(res, params);
|
return readRemoteMediaResponse(res, params);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const saveRemoteMediaMock = vi.fn(
|
||||||
|
async (params: {
|
||||||
|
url: string;
|
||||||
|
maxBytes?: number;
|
||||||
|
filePathHint?: string;
|
||||||
|
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
}) => {
|
||||||
|
const fetched = await readRemoteMediaBufferMock(params);
|
||||||
|
return await saveMediaBufferMock(
|
||||||
|
fetched.buffer,
|
||||||
|
fetched.contentType,
|
||||||
|
"inbound",
|
||||||
|
params.maxBytes,
|
||||||
|
params.filePathHint,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const saveResponseMediaMock = vi.fn(
|
||||||
|
async (
|
||||||
|
res: Response,
|
||||||
|
options: {
|
||||||
|
maxBytes?: number;
|
||||||
|
fallbackContentType?: string;
|
||||||
|
subdir?: string;
|
||||||
|
originalFilename?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
|
return await saveMediaBufferMock(
|
||||||
|
buffer,
|
||||||
|
options.fallbackContentType,
|
||||||
|
options.subdir ?? "inbound",
|
||||||
|
options.maxBytes,
|
||||||
|
options.originalFilename,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const runtimeStub = {
|
const runtimeStub = {
|
||||||
media: {
|
media: {
|
||||||
@@ -43,7 +88,9 @@ const runtimeStub = {
|
|||||||
},
|
},
|
||||||
channel: {
|
channel: {
|
||||||
media: {
|
media: {
|
||||||
fetchRemoteMedia: fetchRemoteMediaMock,
|
readRemoteMediaBuffer: readRemoteMediaBufferMock,
|
||||||
|
saveRemoteMedia: saveRemoteMediaMock,
|
||||||
|
saveResponseMedia: saveResponseMediaMock,
|
||||||
saveMediaBuffer: saveMediaBufferMock,
|
saveMediaBuffer: saveMediaBufferMock,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -222,6 +269,18 @@ const GRAPH_MEDIA_SUCCESS_CASES: GraphMediaSuccessCase[] = [
|
|||||||
expectMediaBufferSaved();
|
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", {
|
withLabel("merges SharePoint reference attachments with hosted content", {
|
||||||
buildOptions: () => {
|
buildOptions: () => {
|
||||||
return {
|
return {
|
||||||
@@ -242,7 +301,9 @@ describe("msteams graph attachments", () => {
|
|||||||
ssrfMock?.mockRestore();
|
ssrfMock?.mockRestore();
|
||||||
ssrfMock = mockPinnedHostnameResolution();
|
ssrfMock = mockPinnedHostnameResolution();
|
||||||
detectMimeMock.mockClear();
|
detectMimeMock.mockClear();
|
||||||
fetchRemoteMediaMock.mockClear();
|
readRemoteMediaBufferMock.mockClear();
|
||||||
|
saveRemoteMediaMock.mockClear();
|
||||||
|
saveResponseMediaMock.mockClear();
|
||||||
saveMediaBufferMock.mockClear();
|
saveMediaBufferMock.mockClear();
|
||||||
setMSTeamsRuntime(runtimeStub);
|
setMSTeamsRuntime(runtimeStub);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,25 @@ import { downloadMSTeamsAttachments } from "./attachments/download.js";
|
|||||||
import { resolveRequestUrl } from "./attachments/shared.js";
|
import { resolveRequestUrl } from "./attachments/shared.js";
|
||||||
import { setMSTeamsRuntime } from "./runtime.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 GRAPH_HOST = "graph.microsoft.com";
|
||||||
const _SHAREPOINT_HOST = "contoso.sharepoint.com";
|
const _SHAREPOINT_HOST = "contoso.sharepoint.com";
|
||||||
const AZUREEDGE_HOST = "azureedge.net";
|
const AZUREEDGE_HOST = "azureedge.net";
|
||||||
@@ -41,12 +60,20 @@ type RemoteMediaFetchParams = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const detectMimeMock = vi.fn(async () => CONTENT_TYPE_IMAGE_PNG);
|
const detectMimeMock = vi.fn(async () => CONTENT_TYPE_IMAGE_PNG);
|
||||||
const saveMediaBufferMock = vi.fn(async () => ({
|
const saveMediaBufferMock = vi.fn(
|
||||||
id: "saved.png",
|
async (
|
||||||
path: SAVED_PNG_PATH,
|
_buffer: Buffer,
|
||||||
size: Buffer.byteLength(PNG_BUFFER),
|
contentType?: string,
|
||||||
contentType: CONTENT_TYPE_IMAGE_PNG,
|
_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 {
|
function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean {
|
||||||
if (pattern.startsWith("*.")) {
|
if (pattern.startsWith("*.")) {
|
||||||
const suffix = pattern.slice(2);
|
const suffix = pattern.slice(2);
|
||||||
@@ -65,7 +92,7 @@ function isUrlAllowedBySsrfPolicy(url: string, policy?: SsrFPolicy): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRemoteMediaWithRedirects(
|
async function readRemoteMediaBufferWithRedirects(
|
||||||
params: RemoteMediaFetchParams,
|
params: RemoteMediaFetchParams,
|
||||||
requestInit?: RequestInit,
|
requestInit?: RequestInit,
|
||||||
) {
|
) {
|
||||||
@@ -89,8 +116,18 @@ async function fetchRemoteMediaWithRedirects(
|
|||||||
throw new Error("too many redirects");
|
throw new Error("too many redirects");
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
|
const readRemoteMediaBufferMock = vi.fn(async (params: RemoteMediaFetchParams) => {
|
||||||
return await fetchRemoteMediaWithRedirects(params);
|
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 = {
|
const runtimeStub = {
|
||||||
@@ -99,7 +136,9 @@ const runtimeStub = {
|
|||||||
},
|
},
|
||||||
channel: {
|
channel: {
|
||||||
media: {
|
media: {
|
||||||
fetchRemoteMedia: fetchRemoteMediaMock,
|
readRemoteMediaBuffer: readRemoteMediaBufferMock,
|
||||||
|
saveRemoteMedia: saveRemoteMediaMock,
|
||||||
|
saveResponseMedia: saveResponseMediaMock,
|
||||||
saveMediaBuffer: saveMediaBufferMock,
|
saveMediaBuffer: saveMediaBufferMock,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -261,7 +300,9 @@ const expectSingleMedia = (media: DownloadedMedia, expected: DownloadedMediaExpe
|
|||||||
expectFirstMedia(media, expected);
|
expectFirstMedia(media, expected);
|
||||||
};
|
};
|
||||||
const expectMediaBufferSaved = () => {
|
const expectMediaBufferSaved = () => {
|
||||||
expect(saveMediaBufferMock).toHaveBeenCalled();
|
expect(
|
||||||
|
saveResponseMediaMock.mock.calls.length + saveMediaBufferMock.mock.calls.length,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
};
|
};
|
||||||
const expectFirstMedia = (media: DownloadedMedia, expected: DownloadedMediaExpectation) => {
|
const expectFirstMedia = (media: DownloadedMedia, expected: DownloadedMediaExpectation) => {
|
||||||
const first = media[0];
|
const first = media[0];
|
||||||
@@ -383,7 +424,9 @@ describe("msteams attachments", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
detectMimeMock.mockClear();
|
detectMimeMock.mockClear();
|
||||||
saveMediaBufferMock.mockClear();
|
saveMediaBufferMock.mockClear();
|
||||||
fetchRemoteMediaMock.mockClear();
|
readRemoteMediaBufferMock.mockClear();
|
||||||
|
saveRemoteMediaMock.mockClear();
|
||||||
|
saveResponseMediaMock.mockClear();
|
||||||
setMSTeamsRuntime(runtimeStub);
|
setMSTeamsRuntime(runtimeStub);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -425,8 +468,8 @@ describe("msteams attachments", () => {
|
|||||||
return createNotFoundResponse();
|
return createNotFoundResponse();
|
||||||
});
|
});
|
||||||
|
|
||||||
fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
|
readRemoteMediaBufferMock.mockImplementationOnce(async (params) => {
|
||||||
return await fetchRemoteMediaWithRedirects(params, {
|
return await readRemoteMediaBufferWithRedirects(params, {
|
||||||
dispatcher: {},
|
dispatcher: {},
|
||||||
} as RequestInit);
|
} as RequestInit);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,7 +50,30 @@ function installRuntime(): MockRuntime {
|
|||||||
});
|
});
|
||||||
return { path: state.savePath, contentType: state.savedContentType };
|
return { path: state.savePath, contentType: state.savedContentType };
|
||||||
},
|
},
|
||||||
fetchRemoteMedia: async () => ({ buffer: Buffer.alloc(0), contentType: undefined }),
|
readRemoteMediaBuffer: async () => ({ buffer: Buffer.alloc(0), contentType: undefined }),
|
||||||
|
saveRemoteMedia: async () => ({
|
||||||
|
path: state.savePath,
|
||||||
|
contentType: state.savedContentType,
|
||||||
|
}),
|
||||||
|
saveResponseMedia: async (
|
||||||
|
response: Response,
|
||||||
|
options: {
|
||||||
|
fallbackContentType?: string;
|
||||||
|
subdir?: string;
|
||||||
|
maxBytes?: number;
|
||||||
|
originalFilename?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
state.saveCalls.push({
|
||||||
|
buffer,
|
||||||
|
contentType: options.fallbackContentType,
|
||||||
|
direction: options.subdir ?? "inbound",
|
||||||
|
maxBytes: options.maxBytes ?? 0,
|
||||||
|
originalFilename: options.originalFilename,
|
||||||
|
});
|
||||||
|
return { path: state.savePath, contentType: state.savedContentType };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as Parameters<typeof setMSTeamsRuntime>[0]);
|
} as unknown as Parameters<typeof setMSTeamsRuntime>[0]);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Buffer } from "node:buffer";
|
|
||||||
import { getMSTeamsRuntime } from "../runtime.js";
|
import { getMSTeamsRuntime } from "../runtime.js";
|
||||||
import { ensureUserAgentHeader } from "../user-agent.js";
|
import { ensureUserAgentHeader } from "../user-agent.js";
|
||||||
import {
|
import {
|
||||||
@@ -104,17 +103,20 @@ async function fetchBotFrameworkAttachmentInfo(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchBotFrameworkAttachmentView(params: {
|
async function saveBotFrameworkAttachmentView(params: {
|
||||||
serviceUrl: string;
|
serviceUrl: string;
|
||||||
attachmentId: string;
|
attachmentId: string;
|
||||||
viewId: string;
|
viewId: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
|
fileNameHint?: string;
|
||||||
|
contentTypeHint?: string;
|
||||||
|
preserveFilenames?: boolean;
|
||||||
policy: MSTeamsAttachmentFetchPolicy;
|
policy: MSTeamsAttachmentFetchPolicy;
|
||||||
fetchFn?: typeof fetch;
|
fetchFn?: typeof fetch;
|
||||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||||
logger?: MSTeamsAttachmentDownloadLogger;
|
logger?: MSTeamsAttachmentDownloadLogger;
|
||||||
}): Promise<Buffer | undefined> {
|
}): Promise<{ path: string; contentType?: string } | undefined> {
|
||||||
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}/views/${encodeURIComponent(params.viewId)}`;
|
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}/views/${encodeURIComponent(params.viewId)}`;
|
||||||
// See `fetchBotFrameworkAttachmentInfo` for why this uses
|
// See `fetchBotFrameworkAttachmentInfo` for why this uses
|
||||||
// `safeFetchWithPolicy` instead of `fetchWithSsrFGuard` on Node 24+ (#63396).
|
// `safeFetchWithPolicy` instead of `fetchWithSsrFGuard` on Node 24+ (#63396).
|
||||||
@@ -146,14 +148,16 @@ async function fetchBotFrameworkAttachmentView(params: {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
return await getMSTeamsRuntime().channel.media.saveResponseMedia(response, {
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
sourceUrl: url,
|
||||||
if (buffer.byteLength > params.maxBytes) {
|
filePathHint: params.fileNameHint,
|
||||||
return undefined;
|
maxBytes: params.maxBytes,
|
||||||
}
|
fallbackContentType: params.contentTypeHint,
|
||||||
return buffer;
|
subdir: "inbound",
|
||||||
|
originalFilename: params.preserveFilenames ? params.fileNameHint : undefined,
|
||||||
|
});
|
||||||
} catch (err) {
|
} 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),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
});
|
});
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -238,21 +242,6 @@ export async function downloadMSTeamsBotFrameworkAttachment(params: {
|
|||||||
return undefined;
|
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 =
|
const fileNameHint =
|
||||||
(typeof params.fileNameHint === "string" && params.fileNameHint) ||
|
(typeof params.fileNameHint === "string" && params.fileNameHint) ||
|
||||||
(typeof info.name === "string" && info.name) ||
|
(typeof info.name === "string" && info.name) ||
|
||||||
@@ -262,32 +251,29 @@ export async function downloadMSTeamsBotFrameworkAttachment(params: {
|
|||||||
(typeof info.type === "string" && info.type) ||
|
(typeof info.type === "string" && info.type) ||
|
||||||
undefined;
|
undefined;
|
||||||
|
|
||||||
const mime = await getMSTeamsRuntime().media.detectMime({
|
const saved = await saveBotFrameworkAttachmentView({
|
||||||
buffer,
|
serviceUrl: params.serviceUrl,
|
||||||
headerMime: contentTypeHint,
|
attachmentId: params.attachmentId,
|
||||||
filePath: fileNameHint,
|
viewId,
|
||||||
|
accessToken,
|
||||||
|
maxBytes: params.maxBytes,
|
||||||
|
fileNameHint,
|
||||||
|
contentTypeHint,
|
||||||
|
preserveFilenames: params.preserveFilenames,
|
||||||
|
policy,
|
||||||
|
fetchFn: params.fetchFn,
|
||||||
|
resolveFn: params.resolveFn,
|
||||||
|
logger: params.logger,
|
||||||
});
|
});
|
||||||
|
if (!saved) {
|
||||||
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),
|
|
||||||
});
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: saved.path,
|
||||||
|
contentType: saved.contentType,
|
||||||
|
placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: fileNameHint }),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ export async function downloadMSTeamsAttachments(params: {
|
|||||||
preserveFilenames: params.preserveFilenames,
|
preserveFilenames: params.preserveFilenames,
|
||||||
ssrfPolicy,
|
ssrfPolicy,
|
||||||
// `fetchImpl` below already validates each hop against the hostname
|
// `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;
|
// strict SSRF dispatcher (incompatible with Node 24+ / undici v7;
|
||||||
// see issue #63396).
|
// see issue #63396).
|
||||||
useDirectFetch: true,
|
useDirectFetch: true,
|
||||||
|
|||||||
@@ -225,13 +225,17 @@ async function downloadGraphHostedContent(params: {
|
|||||||
if (!valRes.ok) {
|
if (!valRes.ok) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Check Content-Length before buffering to avoid RSS spikes on large files.
|
const saved = await getMSTeamsRuntime().channel.media.saveResponseMedia(valRes, {
|
||||||
const cl = valRes.headers.get("content-length");
|
sourceUrl: valueUrl,
|
||||||
if (cl && Number(cl) > params.maxBytes) {
|
maxBytes: params.maxBytes,
|
||||||
continue;
|
fallbackContentType: item.contentType ?? undefined,
|
||||||
}
|
subdir: "inbound",
|
||||||
const ab = await valRes.arrayBuffer();
|
});
|
||||||
buffer = Buffer.from(ab);
|
out.push({
|
||||||
|
path: saved.path,
|
||||||
|
contentType: saved.contentType,
|
||||||
|
placeholder: inferPlaceholder({ contentType: saved.contentType }),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await release();
|
await release();
|
||||||
}
|
}
|
||||||
@@ -241,6 +245,7 @@ async function downloadGraphHostedContent(params: {
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
continue;
|
||||||
} else {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
// Mock the runtime so we can assert whether the strict-dispatcher path
|
// 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).
|
// 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 runtimeDetectMimeMock = vi.fn(async () => "image/png");
|
||||||
const runtimeSaveMediaBufferMock = vi.fn(async (_buf: Buffer, contentType?: string) => ({
|
const runtimeSaveMediaBufferMock = vi.fn(async (_buf: Buffer, contentType?: string) => ({
|
||||||
id: "saved",
|
id: "saved",
|
||||||
@@ -11,13 +26,35 @@ const runtimeSaveMediaBufferMock = vi.fn(async (_buf: Buffer, contentType?: stri
|
|||||||
size: 42,
|
size: 42,
|
||||||
contentType: contentType ?? "image/png",
|
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", () => ({
|
vi.mock("../runtime.js", () => ({
|
||||||
getMSTeamsRuntime: () => ({
|
getMSTeamsRuntime: () => ({
|
||||||
media: { detectMime: runtimeDetectMimeMock },
|
media: { detectMime: runtimeDetectMimeMock },
|
||||||
channel: {
|
channel: {
|
||||||
media: {
|
media: {
|
||||||
fetchRemoteMedia: runtimeFetchRemoteMediaMock,
|
saveRemoteMedia: runtimeSaveRemoteMediaMock,
|
||||||
saveMediaBuffer: runtimeSaveMediaBufferMock,
|
saveMediaBuffer: runtimeSaveMediaBufferMock,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -42,13 +79,14 @@ function requireFirstFetchUrl(mock: ReturnType<typeof vi.fn>): unknown {
|
|||||||
|
|
||||||
describe("downloadAndStoreMSTeamsRemoteMedia", () => {
|
describe("downloadAndStoreMSTeamsRemoteMedia", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
runtimeFetchRemoteMediaMock.mockReset();
|
runtimeSaveRemoteMediaMock.mockClear();
|
||||||
|
saveResponseMediaMock.mockClear();
|
||||||
runtimeDetectMimeMock.mockClear();
|
runtimeDetectMimeMock.mockClear();
|
||||||
runtimeSaveMediaBufferMock.mockClear();
|
runtimeSaveMediaBufferMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("useDirectFetch: true (Node 24+ / undici v7 path for issue #63396)", () => {
|
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
|
// `fetchImpl` here simulates the "pre-validated hostname" contract from
|
||||||
// `safeFetchWithPolicy`: the caller has already enforced the allowlist,
|
// `safeFetchWithPolicy`: the caller has already enforced the allowlist,
|
||||||
// so the strict SSRF dispatcher is not needed.
|
// so the strict SSRF dispatcher is not needed.
|
||||||
@@ -67,7 +105,7 @@ describe("downloadAndStoreMSTeamsRemoteMedia", () => {
|
|||||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||||
const calledUrl = requireFirstFetchUrl(fetchImpl);
|
const calledUrl = requireFirstFetchUrl(fetchImpl);
|
||||||
expect(calledUrl).toBe("https://graph.microsoft.com/v1.0/shares/abc/driveItem/content");
|
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");
|
expect(result.path).toBe("/tmp/saved.png");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,7 +121,7 @@ describe("downloadAndStoreMSTeamsRemoteMedia", () => {
|
|||||||
fetchImpl,
|
fetchImpl,
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(/HTTP 403/);
|
).rejects.toThrow(/HTTP 403/);
|
||||||
expect(runtimeFetchRemoteMediaMock).not.toHaveBeenCalled();
|
expect(runtimeSaveRemoteMediaMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a response whose Content-Length exceeds maxBytes", async () => {
|
it("rejects a response whose Content-Length exceeds maxBytes", async () => {
|
||||||
@@ -103,14 +141,16 @@ describe("downloadAndStoreMSTeamsRemoteMedia", () => {
|
|||||||
fetchImpl,
|
fetchImpl,
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(/exceeds maxBytes/);
|
).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
|
// Non-SharePoint caller, no pre-validated fetchImpl: make sure the strict
|
||||||
// SSRF dispatcher path is still used.
|
// SSRF dispatcher path is still used.
|
||||||
runtimeFetchRemoteMediaMock.mockResolvedValueOnce({
|
runtimeSaveRemoteMediaMock.mockResolvedValueOnce({
|
||||||
buffer: PNG_BYTES,
|
id: "saved",
|
||||||
|
path: "/tmp/saved.png",
|
||||||
|
size: 42,
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
fileName: "file.png",
|
fileName: "file.png",
|
||||||
});
|
});
|
||||||
@@ -121,12 +161,14 @@ describe("downloadAndStoreMSTeamsRemoteMedia", () => {
|
|||||||
maxBytes: 1024,
|
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 () => {
|
it("does not use the direct path when useDirectFetch is true but fetchImpl is missing", async () => {
|
||||||
runtimeFetchRemoteMediaMock.mockResolvedValueOnce({
|
runtimeSaveRemoteMediaMock.mockResolvedValueOnce({
|
||||||
buffer: PNG_BYTES,
|
id: "saved",
|
||||||
|
path: "/tmp/saved.png",
|
||||||
|
size: 42,
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,7 +181,7 @@ describe("downloadAndStoreMSTeamsRemoteMedia", () => {
|
|||||||
|
|
||||||
// Without a fetchImpl to delegate to, we must fall back to the runtime
|
// Without a fetchImpl to delegate to, we must fall back to the runtime
|
||||||
// path rather than crashing.
|
// path rather than crashing.
|
||||||
expect(runtimeFetchRemoteMediaMock).toHaveBeenCalledTimes(1);
|
expect(runtimeSaveRemoteMediaMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/media-runtime";
|
import { saveResponseMedia, type SavedRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
|
||||||
import type { SsrFPolicy } from "../../runtime-api.js";
|
import type { SsrFPolicy } from "../../runtime-api.js";
|
||||||
import { getMSTeamsRuntime } from "../runtime.js";
|
import { getMSTeamsRuntime } from "../runtime.js";
|
||||||
import { inferPlaceholder } from "./shared.js";
|
import { inferPlaceholder } from "./shared.js";
|
||||||
@@ -6,17 +6,12 @@ import type { MSTeamsInboundMedia } from "./types.js";
|
|||||||
|
|
||||||
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
type FetchedRemoteMedia = {
|
|
||||||
buffer: Buffer;
|
|
||||||
contentType?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Direct fetch path used when the caller's `fetchImpl` has already validated
|
* Direct fetch path used when the caller's `fetchImpl` has already validated
|
||||||
* the URL against a hostname allowlist (for example `safeFetchWithPolicy`).
|
* the URL against a hostname allowlist (for example `safeFetchWithPolicy`).
|
||||||
*
|
*
|
||||||
* Bypasses the strict SSRF dispatcher on `fetchRemoteMedia` because:
|
* Bypasses the strict SSRF dispatcher on `readRemoteMediaBuffer` because:
|
||||||
* 1. The pinned undici dispatcher used by `fetchRemoteMedia` is incompatible
|
* 1. The pinned undici dispatcher used by `readRemoteMediaBuffer` is incompatible
|
||||||
* with Node 24+'s built-in undici v7 (fails with "invalid onRequestStart
|
* with Node 24+'s built-in undici v7 (fails with "invalid onRequestStart
|
||||||
* method"), which silently breaks SharePoint/OneDrive downloads. See
|
* method"), which silently breaks SharePoint/OneDrive downloads. See
|
||||||
* issue #63396.
|
* issue #63396.
|
||||||
@@ -24,36 +19,22 @@ type FetchedRemoteMedia = {
|
|||||||
* (`safeFetch` validates every redirect hop against the hostname
|
* (`safeFetch` validates every redirect hop against the hostname
|
||||||
* allowlist before following).
|
* allowlist before following).
|
||||||
*/
|
*/
|
||||||
async function fetchRemoteMediaDirect(params: {
|
async function saveRemoteMediaDirect(params: {
|
||||||
url: string;
|
url: string;
|
||||||
|
filePathHint: string;
|
||||||
fetchImpl: FetchLike;
|
fetchImpl: FetchLike;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
}): Promise<FetchedRemoteMedia> {
|
contentTypeHint?: string;
|
||||||
|
originalFilename?: string;
|
||||||
|
}): Promise<SavedRemoteMedia> {
|
||||||
const response = await params.fetchImpl(params.url, { redirect: "follow" });
|
const response = await params.fetchImpl(params.url, { redirect: "follow" });
|
||||||
if (!response.ok) {
|
return await saveResponseMedia(response, {
|
||||||
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
sourceUrl: params.url,
|
||||||
throw new Error(`HTTP ${response.status}${statusText}`);
|
filePathHint: params.filePathHint,
|
||||||
}
|
maxBytes: params.maxBytes,
|
||||||
|
fallbackContentType: params.contentTypeHint,
|
||||||
// Enforce the max-bytes cap before buffering the full body so a rogue
|
originalFilename: params.originalFilename,
|
||||||
// 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 {
|
|
||||||
buffer,
|
|
||||||
contentType: response.headers.get("content-type") ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadAndStoreMSTeamsRemoteMedia(params: {
|
export async function downloadAndStoreMSTeamsRemoteMedia(params: {
|
||||||
@@ -66,42 +47,35 @@ export async function downloadAndStoreMSTeamsRemoteMedia(params: {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
preserveFilenames?: boolean;
|
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+
|
* SSRF dispatcher. Required for SharePoint/OneDrive downloads on Node 24+
|
||||||
* (see issue #63396). Only safe when the supplied `fetchImpl` has already
|
* (see issue #63396). Only safe when the supplied `fetchImpl` has already
|
||||||
* validated the URL against a hostname allowlist.
|
* validated the URL against a hostname allowlist.
|
||||||
*/
|
*/
|
||||||
useDirectFetch?: boolean;
|
useDirectFetch?: boolean;
|
||||||
}): Promise<MSTeamsInboundMedia> {
|
}): Promise<MSTeamsInboundMedia> {
|
||||||
let fetched: FetchedRemoteMedia;
|
const originalFilename = params.preserveFilenames ? params.filePathHint : undefined;
|
||||||
|
let saved: SavedRemoteMedia;
|
||||||
if (params.useDirectFetch && params.fetchImpl) {
|
if (params.useDirectFetch && params.fetchImpl) {
|
||||||
fetched = await fetchRemoteMediaDirect({
|
saved = await saveRemoteMediaDirect({
|
||||||
url: params.url,
|
url: params.url,
|
||||||
|
filePathHint: params.filePathHint,
|
||||||
fetchImpl: params.fetchImpl,
|
fetchImpl: params.fetchImpl,
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
|
contentTypeHint: params.contentTypeHint,
|
||||||
|
originalFilename,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
fetched = await getMSTeamsRuntime().channel.media.fetchRemoteMedia({
|
saved = await getMSTeamsRuntime().channel.media.saveRemoteMedia({
|
||||||
url: params.url,
|
url: params.url,
|
||||||
fetchImpl: params.fetchImpl,
|
fetchImpl: params.fetchImpl,
|
||||||
filePathHint: params.filePathHint,
|
filePathHint: params.filePathHint,
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
ssrfPolicy: params.ssrfPolicy,
|
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 {
|
return {
|
||||||
path: saved.path,
|
path: saved.path,
|
||||||
contentType: saved.contentType,
|
contentType: saved.contentType,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import { getBridgeLogger } from "./logger.js";
|
|||||||
function createBuiltinAdapter(): PlatformAdapter {
|
function createBuiltinAdapter(): PlatformAdapter {
|
||||||
return {
|
return {
|
||||||
async validateRemoteUrl(_url: string, _options?: { allowPrivate?: boolean }): Promise<void> {
|
async validateRemoteUrl(_url: string, _options?: { allowPrivate?: boolean }): Promise<void> {
|
||||||
// Built-in version delegates SSRF validation to fetchRemoteMedia's ssrfPolicy.
|
// Built-in version delegates SSRF validation to readRemoteMediaBuffer's ssrfPolicy.
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolveSecret(value): Promise<string | undefined> {
|
async resolveSecret(value): Promise<string | undefined> {
|
||||||
@@ -52,8 +52,8 @@ function createBuiltinAdapter(): PlatformAdapter {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async downloadFile(url: string, destDir: string, filename?: string): Promise<string> {
|
async downloadFile(url: string, destDir: string, filename?: string): Promise<string> {
|
||||||
const { fetchRemoteMedia } = await import("openclaw/plugin-sdk/media-runtime");
|
const { readRemoteMediaBuffer } = await import("openclaw/plugin-sdk/media-runtime");
|
||||||
const result = await fetchRemoteMedia({ url, filePathHint: filename });
|
const result = await readRemoteMediaBuffer({ url, filePathHint: filename });
|
||||||
const fs = await import("node:fs");
|
const fs = await import("node:fs");
|
||||||
const path = await import("node:path");
|
const path = await import("node:path");
|
||||||
if (!fs.existsSync(destDir)) {
|
if (!fs.existsSync(destDir)) {
|
||||||
@@ -65,8 +65,8 @@ function createBuiltinAdapter(): PlatformAdapter {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchMedia(options: FetchMediaOptions): Promise<FetchMediaResult> {
|
async fetchMedia(options: FetchMediaOptions): Promise<FetchMediaResult> {
|
||||||
const { fetchRemoteMedia } = await import("openclaw/plugin-sdk/media-runtime");
|
const { readRemoteMediaBuffer } = await import("openclaw/plugin-sdk/media-runtime");
|
||||||
const result = await fetchRemoteMedia({
|
const result = await readRemoteMediaBuffer({
|
||||||
url: options.url,
|
url: options.url,
|
||||||
filePathHint: options.filePathHint,
|
filePathHint: options.filePathHint,
|
||||||
maxBytes: options.maxBytes,
|
maxBytes: options.maxBytes,
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ const IMAGE_PROBE_SSRF_POLICY: SsrfPolicyConfig = {};
|
|||||||
/**
|
/**
|
||||||
* Fetch image dimensions from a public URL using only the first 64 KB.
|
* 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.
|
* private/reserved/loopback/link-local/metadata destinations.
|
||||||
*/
|
*/
|
||||||
export async function getImageSizeFromUrl(
|
export async function getImageSizeFromUrl(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import fs from "node:fs/promises";
|
|||||||
import nodePath from "node:path";
|
import nodePath from "node:path";
|
||||||
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
|
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
|
||||||
import { detectMime } from "openclaw/plugin-sdk/media-runtime";
|
import { detectMime } from "openclaw/plugin-sdk/media-runtime";
|
||||||
|
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
|
|
||||||
export type ContainerRpcOptions = {
|
export type ContainerRpcOptions = {
|
||||||
@@ -105,36 +106,9 @@ async function readCappedResponseBuffer(res: Response, maxResponseBytes: number)
|
|||||||
if (contentLength !== undefined && contentLength > maxResponseBytes) {
|
if (contentLength !== undefined && contentLength > maxResponseBytes) {
|
||||||
throw new Error("Signal REST attachment exceeded size limit");
|
throw new Error("Signal REST attachment exceeded size limit");
|
||||||
}
|
}
|
||||||
|
return await readResponseWithLimit(res, maxResponseBytes, {
|
||||||
const reader = res.body?.getReader();
|
onOverflow: () => new Error("Signal REST attachment exceeded size limit"),
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
export { fetchWithRuntimeDispatcher } from "openclaw/plugin-sdk/runtime-fetch";
|
export { fetchWithRuntimeDispatcher } from "openclaw/plugin-sdk/runtime-fetch";
|
||||||
export type { FetchLike, SavedMedia } from "openclaw/plugin-sdk/media-runtime";
|
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";
|
export { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function expectSlackMediaResult(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchRemoteMediaMock = vi.hoisted(() =>
|
const readRemoteMediaBufferMock = vi.hoisted(() =>
|
||||||
vi.fn(
|
vi.fn(
|
||||||
async (params: {
|
async (params: {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -65,21 +65,46 @@ const fetchRemoteMediaMock = vi.hoisted(() =>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const saveMediaBufferMock = vi.hoisted(() =>
|
const saveMediaBufferMock = vi.hoisted(() =>
|
||||||
vi.fn(async (_buffer: Buffer, contentType?: string) => ({
|
vi.fn(
|
||||||
id: "saved-media-id",
|
async (
|
||||||
path: "/tmp/test.bin",
|
_buffer: Buffer,
|
||||||
size: _buffer.byteLength,
|
contentType?: string,
|
||||||
contentType,
|
_subdir?: string,
|
||||||
})),
|
_maxBytes?: number,
|
||||||
|
_originalFilename?: string,
|
||||||
|
) => ({
|
||||||
|
id: "saved-media-id",
|
||||||
|
path: "/tmp/test.bin",
|
||||||
|
size: _buffer.byteLength,
|
||||||
|
contentType,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const saveRemoteMediaMock = vi.hoisted(() =>
|
||||||
|
vi.fn(async (params: Parameters<typeof readRemoteMediaBufferMock>[0]) => {
|
||||||
|
const fetched = await readRemoteMediaBufferMock(params);
|
||||||
|
const saved = await saveMediaBufferMock(
|
||||||
|
fetched.buffer,
|
||||||
|
fetched.contentType,
|
||||||
|
"inbound",
|
||||||
|
params.maxBytes,
|
||||||
|
params.filePathHint,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...saved,
|
||||||
|
fileName: fetched.fileName,
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const fetchWithRuntimeDispatcherMock = vi.hoisted(() => vi.fn());
|
const fetchWithRuntimeDispatcherMock = vi.hoisted(() => vi.fn());
|
||||||
const logVerboseMock = vi.hoisted(() => vi.fn());
|
const logVerboseMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("./media.runtime.js", () => ({
|
vi.mock("./media.runtime.js", () => ({
|
||||||
fetchRemoteMedia: fetchRemoteMediaMock,
|
readRemoteMediaBuffer: readRemoteMediaBufferMock,
|
||||||
fetchWithRuntimeDispatcher: fetchWithRuntimeDispatcherMock,
|
fetchWithRuntimeDispatcher: fetchWithRuntimeDispatcherMock,
|
||||||
logVerbose: logVerboseMock,
|
logVerbose: logVerboseMock,
|
||||||
saveMediaBuffer: saveMediaBufferMock,
|
saveMediaBuffer: saveMediaBufferMock,
|
||||||
|
saveRemoteMedia: saveRemoteMediaMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./thread.runtime.js", () => ({
|
vi.mock("./thread.runtime.js", () => ({
|
||||||
@@ -98,10 +123,41 @@ const originalFetch = globalThis.fetch;
|
|||||||
let mockFetch: ReturnType<typeof vi.fn<FetchMock>>;
|
let mockFetch: ReturnType<typeof vi.fn<FetchMock>>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchRemoteMediaMock.mockClear();
|
readRemoteMediaBufferMock.mockClear();
|
||||||
fetchWithRuntimeDispatcherMock.mockClear();
|
fetchWithRuntimeDispatcherMock.mockClear();
|
||||||
logVerboseMock.mockClear();
|
logVerboseMock.mockClear();
|
||||||
saveMediaBufferMock.mockClear();
|
saveMediaBufferMock.mockReset();
|
||||||
|
saveMediaBufferMock.mockImplementation(
|
||||||
|
async (
|
||||||
|
_buffer: Buffer,
|
||||||
|
contentType?: string,
|
||||||
|
_subdir?: string,
|
||||||
|
_maxBytes?: number,
|
||||||
|
_originalFilename?: string,
|
||||||
|
) => ({
|
||||||
|
id: "saved-media-id",
|
||||||
|
path: "/tmp/test.bin",
|
||||||
|
size: _buffer.byteLength,
|
||||||
|
contentType,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
saveRemoteMediaMock.mockReset();
|
||||||
|
saveRemoteMediaMock.mockImplementation(
|
||||||
|
async (params: Parameters<typeof readRemoteMediaBufferMock>[0]) => {
|
||||||
|
const fetched = await readRemoteMediaBufferMock(params);
|
||||||
|
const saved = await saveMediaBufferMock(
|
||||||
|
fetched.buffer,
|
||||||
|
fetched.contentType,
|
||||||
|
"inbound",
|
||||||
|
params.maxBytes,
|
||||||
|
params.filePathHint,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...saved,
|
||||||
|
fileName: fetched.fileName,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const createSavedMedia = (filePath: string, contentType: string): SavedMedia => ({
|
const createSavedMedia = (filePath: string, contentType: string): SavedMedia => ({
|
||||||
@@ -422,8 +478,8 @@ describe("resolveSlackMedia", () => {
|
|||||||
|
|
||||||
expectSlackMediaResult(result);
|
expectSlackMediaResult(result);
|
||||||
const fetchOptions = requireRecord(
|
const fetchOptions = requireRecord(
|
||||||
requireMockCall(fetchRemoteMediaMock, 0, "fetchRemoteMedia")[0],
|
requireMockCall(readRemoteMediaBufferMock, 0, "readRemoteMediaBuffer")[0],
|
||||||
"fetchRemoteMedia options",
|
"readRemoteMediaBuffer options",
|
||||||
) as { readIdleTimeoutMs?: number; requestInit?: RequestInit };
|
) as { readIdleTimeoutMs?: number; requestInit?: RequestInit };
|
||||||
expect(fetchOptions.readIdleTimeoutMs).toBe(SLACK_MEDIA_READ_IDLE_TIMEOUT_MS);
|
expect(fetchOptions.readIdleTimeoutMs).toBe(SLACK_MEDIA_READ_IDLE_TIMEOUT_MS);
|
||||||
expect(fetchOptions.requestInit?.signal).toBeInstanceOf(AbortSignal);
|
expect(fetchOptions.requestInit?.signal).toBeInstanceOf(AbortSignal);
|
||||||
@@ -436,7 +492,7 @@ describe("resolveSlackMedia", () => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
let abortSignal: AbortSignal | undefined;
|
let abortSignal: AbortSignal | undefined;
|
||||||
fetchRemoteMediaMock.mockImplementationOnce(
|
readRemoteMediaBufferMock.mockImplementationOnce(
|
||||||
(params) =>
|
(params) =>
|
||||||
new Promise<never>((_resolve, reject) => {
|
new Promise<never>((_resolve, reject) => {
|
||||||
abortSignal = params.requestInit?.signal ?? undefined;
|
abortSignal = params.requestInit?.signal ?? undefined;
|
||||||
@@ -600,7 +656,6 @@ describe("resolveSlackMedia", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects HTML auth pages for non-HTML files", async () => {
|
it("rejects HTML auth pages for non-HTML files", async () => {
|
||||||
const saveMediaBufferMock = vi.spyOn(mediaRuntime, "saveMediaBuffer");
|
|
||||||
mockFetch.mockResolvedValueOnce(
|
mockFetch.mockResolvedValueOnce(
|
||||||
new Response("<!DOCTYPE html><html><body>login</body></html>", {
|
new Response("<!DOCTYPE html><html><body>login</body></html>", {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -615,7 +670,7 @@ describe("resolveSlackMedia", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
expect(saveMediaBufferMock).not.toHaveBeenCalled();
|
expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows expected HTML uploads", async () => {
|
it("allows expected HTML uploads", async () => {
|
||||||
@@ -649,9 +704,13 @@ describe("resolveSlackMedia", () => {
|
|||||||
// saveMediaBuffer re-detects MIME from buffer bytes, so it may return
|
// saveMediaBuffer re-detects MIME from buffer bytes, so it may return
|
||||||
// video/mp4 for MP4 containers. Verify resolveSlackMedia preserves
|
// video/mp4 for MP4 containers. Verify resolveSlackMedia preserves
|
||||||
// the overridden audio/* type in its return value despite this.
|
// the overridden audio/* type in its return value despite this.
|
||||||
const saveMediaBufferMock = vi
|
saveRemoteMediaMock.mockResolvedValueOnce({
|
||||||
.spyOn(mediaRuntime, "saveMediaBuffer")
|
id: "saved-media-id",
|
||||||
.mockResolvedValue(createSavedMedia("/tmp/voice.mp4", "video/mp4"));
|
path: "/tmp/voice.mp4",
|
||||||
|
size: 128,
|
||||||
|
contentType: "video/mp4",
|
||||||
|
fileName: "voice.mp4",
|
||||||
|
});
|
||||||
|
|
||||||
const mockResponse = new Response(Buffer.from("audio data"), {
|
const mockResponse = new Response(Buffer.from("audio data"), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -674,10 +733,13 @@ describe("resolveSlackMedia", () => {
|
|||||||
|
|
||||||
const media = expectSlackMediaResult(result);
|
const media = expectSlackMediaResult(result);
|
||||||
expect(media).toHaveLength(1);
|
expect(media).toHaveLength(1);
|
||||||
// saveMediaBuffer should receive the overridden audio/mp4
|
expect(
|
||||||
expectSaveMediaBufferCall(saveMediaBufferMock, "audio/mp4", 16 * 1024 * 1024);
|
requireRecord(requireMockCall(saveRemoteMediaMock, 0, "saveRemoteMedia")[0], "save params"),
|
||||||
|
).toMatchObject({
|
||||||
|
fallbackContentType: "audio/mp4",
|
||||||
|
});
|
||||||
// Returned contentType must be the overridden value, not the
|
// 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");
|
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" } }),
|
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({
|
await resolveSlackMedia({
|
||||||
files: [{ url_private: "https://files.slack.com/test.jpg", name: "test.jpg" }],
|
files: [{ url_private: "https://files.slack.com/test.jpg", name: "test.jpg" }],
|
||||||
@@ -882,8 +944,10 @@ describe("Slack media SSRF policy", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const policy = requireRecord(
|
const policy = requireRecord(
|
||||||
requireRecord(requireMockCall(spy, 0, "fetchRemoteMedia")[0], "fetchRemoteMedia params")
|
requireRecord(
|
||||||
.ssrfPolicy,
|
requireMockCall(spy, 0, "readRemoteMediaBuffer")[0],
|
||||||
|
"readRemoteMediaBuffer params",
|
||||||
|
).ssrfPolicy,
|
||||||
"ssrfPolicy",
|
"ssrfPolicy",
|
||||||
);
|
);
|
||||||
expect(policy.allowRfc2544BenchmarkRange).toBe(true);
|
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" } }),
|
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({
|
await resolveSlackAttachmentContent({
|
||||||
attachments: [{ is_share: true, image_url: "https://files.slack.com/forwarded.jpg" }],
|
attachments: [{ is_share: true, image_url: "https://files.slack.com/forwarded.jpg" }],
|
||||||
@@ -910,8 +974,10 @@ describe("Slack media SSRF policy", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const policy = requireRecord(
|
const policy = requireRecord(
|
||||||
requireRecord(requireMockCall(spy, 0, "fetchRemoteMedia")[0], "fetchRemoteMedia params")
|
requireRecord(
|
||||||
.ssrfPolicy,
|
requireMockCall(spy, 0, "readRemoteMediaBuffer")[0],
|
||||||
|
"readRemoteMediaBuffer params",
|
||||||
|
).ssrfPolicy,
|
||||||
"ssrfPolicy",
|
"ssrfPolicy",
|
||||||
);
|
);
|
||||||
expect(policy.allowRfc2544BenchmarkRange).toBe(true);
|
expect(policy.allowRfc2544BenchmarkRange).toBe(true);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
import type { WebClient as SlackWebClient } from "@slack/web-api";
|
import type { WebClient as SlackWebClient } from "@slack/web-api";
|
||||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||||
import { normalizeHostname } from "openclaw/plugin-sdk/host-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";
|
import type { SlackAttachment, SlackFile } from "../types.js";
|
||||||
export { MAX_SLACK_MEDIA_FILES, type SlackMediaResult } from "./media-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 { MAX_SLACK_MEDIA_FILES, type SlackMediaResult } from "./media-types.js";
|
||||||
import {
|
import { type FetchLike, fetchWithRuntimeDispatcher, saveRemoteMedia } from "./media.runtime.js";
|
||||||
type FetchLike,
|
|
||||||
fetchRemoteMedia,
|
|
||||||
fetchWithRuntimeDispatcher,
|
|
||||||
saveMediaBuffer,
|
|
||||||
} from "./media.runtime.js";
|
|
||||||
import { logVerbose } from "./thread.runtime.js";
|
import { logVerbose } from "./thread.runtime.js";
|
||||||
export {
|
export {
|
||||||
resetSlackThreadStarterCacheForTest,
|
resetSlackThreadStarterCacheForTest,
|
||||||
@@ -146,7 +142,7 @@ const SLACK_MEDIA_SSRF_POLICY = {
|
|||||||
};
|
};
|
||||||
export const SLACK_MEDIA_READ_IDLE_TIMEOUT_MS = 60_000;
|
export const SLACK_MEDIA_READ_IDLE_TIMEOUT_MS = 60_000;
|
||||||
export const SLACK_MEDIA_TOTAL_TIMEOUT_MS = 120_000;
|
export const SLACK_MEDIA_TOTAL_TIMEOUT_MS = 120_000;
|
||||||
type SlackFetchRemoteMediaOptions = Parameters<typeof fetchRemoteMedia>[0];
|
type SlackSaveRemoteMediaOptions = Parameters<typeof saveRemoteMedia>[0];
|
||||||
|
|
||||||
function mergeAbortSignals(signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
|
function mergeAbortSignals(signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
|
||||||
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal));
|
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal));
|
||||||
@@ -178,12 +174,12 @@ function mergeAbortSignals(signals: Array<AbortSignal | undefined>): AbortSignal
|
|||||||
return controller.signal;
|
return controller.signal;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSlackMedia(params: {
|
async function saveSlackMedia(params: {
|
||||||
options: SlackFetchRemoteMediaOptions;
|
options: SlackSaveRemoteMediaOptions;
|
||||||
readIdleTimeoutMs?: number;
|
readIdleTimeoutMs?: number;
|
||||||
totalTimeoutMs?: number;
|
totalTimeoutMs?: number;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
}): ReturnType<typeof fetchRemoteMedia> {
|
}): ReturnType<typeof saveRemoteMedia> {
|
||||||
const timeoutAbortController = params.totalTimeoutMs ? new AbortController() : undefined;
|
const timeoutAbortController = params.totalTimeoutMs ? new AbortController() : undefined;
|
||||||
const signal = mergeAbortSignals([
|
const signal = mergeAbortSignals([
|
||||||
params.abortSignal,
|
params.abortSignal,
|
||||||
@@ -193,7 +189,7 @@ async function fetchSlackMedia(params: {
|
|||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const fetchPromise = fetchRemoteMedia({
|
const savePromise = saveRemoteMedia({
|
||||||
...params.options,
|
...params.options,
|
||||||
readIdleTimeoutMs: params.readIdleTimeoutMs ?? SLACK_MEDIA_READ_IDLE_TIMEOUT_MS,
|
readIdleTimeoutMs: params.readIdleTimeoutMs ?? SLACK_MEDIA_READ_IDLE_TIMEOUT_MS,
|
||||||
...(signal
|
...(signal
|
||||||
@@ -213,7 +209,7 @@ async function fetchSlackMedia(params: {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!params.totalTimeoutMs) {
|
if (!params.totalTimeoutMs) {
|
||||||
return await fetchPromise;
|
return await savePromise;
|
||||||
}
|
}
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
timeoutHandle = setTimeout(() => {
|
timeoutHandle = setTimeout(() => {
|
||||||
@@ -223,7 +219,7 @@ async function fetchSlackMedia(params: {
|
|||||||
}, params.totalTimeoutMs);
|
}, params.totalTimeoutMs);
|
||||||
timeoutHandle.unref?.();
|
timeoutHandle.unref?.();
|
||||||
});
|
});
|
||||||
return await Promise.race([fetchPromise, timeoutPromise]);
|
return await Promise.race([savePromise, timeoutPromise]);
|
||||||
} finally {
|
} finally {
|
||||||
if (timeoutHandle) {
|
if (timeoutHandle) {
|
||||||
clearTimeout(timeoutHandle);
|
clearTimeout(timeoutHandle);
|
||||||
@@ -255,6 +251,20 @@ function looksLikeHtmlBuffer(buffer: Buffer): boolean {
|
|||||||
return head.startsWith("<!doctype html") || head.startsWith("<html");
|
return head.startsWith("<!doctype html") || head.startsWith("<html");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function looksLikeHtmlFile(filePath: string): Promise<boolean> {
|
||||||
|
const handle = await fs.open(filePath, "r").catch(() => null);
|
||||||
|
if (!handle) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(512);
|
||||||
|
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, 0);
|
||||||
|
return looksLikeHtmlBuffer(buffer.subarray(0, bytesRead));
|
||||||
|
} finally {
|
||||||
|
await handle.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_SLACK_MEDIA_CONCURRENCY = 3;
|
const MAX_SLACK_MEDIA_CONCURRENCY = 3;
|
||||||
const MAX_SLACK_FORWARDED_ATTACHMENTS = 8;
|
const MAX_SLACK_FORWARDED_ATTACHMENTS = 8;
|
||||||
|
|
||||||
@@ -294,12 +304,13 @@ async function downloadSlackMediaFile(params: {
|
|||||||
}): Promise<SlackMediaResult | null> {
|
}): Promise<SlackMediaResult | null> {
|
||||||
const { url: slackUrl, requestInit } = createSlackMediaRequest(params.url, params.token);
|
const { url: slackUrl, requestInit } = createSlackMediaRequest(params.url, params.token);
|
||||||
const fetchImpl = createSlackMediaFetch();
|
const fetchImpl = createSlackMediaFetch();
|
||||||
const fetched = await fetchSlackMedia({
|
const saved = await saveSlackMedia({
|
||||||
options: {
|
options: {
|
||||||
url: slackUrl,
|
url: slackUrl,
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
requestInit,
|
requestInit,
|
||||||
filePathHint: params.file.name,
|
filePathHint: params.file.name,
|
||||||
|
fallbackContentType: resolveSlackMediaMimetype(params.file, params.file.mimetype),
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
ssrfPolicy: SLACK_MEDIA_SSRF_POLICY,
|
ssrfPolicy: SLACK_MEDIA_SSRF_POLICY,
|
||||||
},
|
},
|
||||||
@@ -307,9 +318,6 @@ async function downloadSlackMediaFile(params: {
|
|||||||
totalTimeoutMs: params.totalTimeoutMs ?? SLACK_MEDIA_TOTAL_TIMEOUT_MS,
|
totalTimeoutMs: params.totalTimeoutMs ?? SLACK_MEDIA_TOTAL_TIMEOUT_MS,
|
||||||
abortSignal: params.abortSignal,
|
abortSignal: params.abortSignal,
|
||||||
});
|
});
|
||||||
if (fetched.buffer.byteLength > params.maxBytes) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard against auth/login HTML pages returned instead of binary media.
|
// Guard against auth/login HTML pages returned instead of binary media.
|
||||||
// Allow user-provided HTML files through.
|
// Allow user-provided HTML files through.
|
||||||
@@ -318,15 +326,15 @@ async function downloadSlackMediaFile(params: {
|
|||||||
const isExpectedHtml =
|
const isExpectedHtml =
|
||||||
fileMime === "text/html" || fileName.endsWith(".html") || fileName.endsWith(".htm");
|
fileMime === "text/html" || fileName.endsWith(".html") || fileName.endsWith(".htm");
|
||||||
if (!isExpectedHtml) {
|
if (!isExpectedHtml) {
|
||||||
const detectedMime = normalizeOptionalLowercaseString(fetched.contentType?.split(";")[0]);
|
const detectedMime = normalizeOptionalLowercaseString(saved.contentType?.split(";")[0]);
|
||||||
if (detectedMime === "text/html" || looksLikeHtmlBuffer(fetched.buffer)) {
|
if (detectedMime === "text/html" || (await looksLikeHtmlFile(saved.path))) {
|
||||||
|
await fs.rm(saved.path, { force: true }).catch(() => undefined);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveMime = resolveSlackMediaMimetype(params.file, fetched.contentType);
|
const effectiveMime = resolveSlackMediaMimetype(params.file, saved.contentType);
|
||||||
const saved = await saveMediaBuffer(fetched.buffer, effectiveMime, "inbound", params.maxBytes);
|
const label = saved.fileName ?? params.file.name;
|
||||||
const label = fetched.fileName ?? params.file.name;
|
|
||||||
const contentType = effectiveMime ?? saved.contentType;
|
const contentType = effectiveMime ?? saved.contentType;
|
||||||
return {
|
return {
|
||||||
path: saved.path,
|
path: saved.path,
|
||||||
@@ -479,7 +487,7 @@ export async function resolveSlackAttachmentContent(params: {
|
|||||||
try {
|
try {
|
||||||
const { url: slackUrl, requestInit } = createSlackMediaRequest(imageUrl, params.token);
|
const { url: slackUrl, requestInit } = createSlackMediaRequest(imageUrl, params.token);
|
||||||
const fetchImpl = createSlackMediaFetch();
|
const fetchImpl = createSlackMediaFetch();
|
||||||
const fetched = await fetchSlackMedia({
|
const saved = await saveSlackMedia({
|
||||||
options: {
|
options: {
|
||||||
url: slackUrl,
|
url: slackUrl,
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
@@ -491,20 +499,12 @@ export async function resolveSlackAttachmentContent(params: {
|
|||||||
totalTimeoutMs: params.totalTimeoutMs ?? SLACK_MEDIA_TOTAL_TIMEOUT_MS,
|
totalTimeoutMs: params.totalTimeoutMs ?? SLACK_MEDIA_TOTAL_TIMEOUT_MS,
|
||||||
abortSignal: params.abortSignal,
|
abortSignal: params.abortSignal,
|
||||||
});
|
});
|
||||||
if (fetched.buffer.byteLength <= params.maxBytes) {
|
const label = saved.fileName ?? "forwarded image";
|
||||||
const saved = await saveMediaBuffer(
|
allMedia.push({
|
||||||
fetched.buffer,
|
path: saved.path,
|
||||||
fetched.contentType,
|
contentType: saved.contentType,
|
||||||
"inbound",
|
placeholder: `[Forwarded image: ${label}]`,
|
||||||
params.maxBytes,
|
});
|
||||||
);
|
|
||||||
const label = fetched.fileName ?? "forwarded image";
|
|
||||||
allMedia.push({
|
|
||||||
path: saved.path,
|
|
||||||
contentType: fetched.contentType ?? saved.contentType,
|
|
||||||
placeholder: `[Forwarded image: ${label}]`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Skip images that fail to download
|
// Skip images that fail to download
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ type TelegramBotRuntimeForTest = NonNullable<
|
|||||||
type DispatchReplyWithBufferedBlockDispatcherFn =
|
type DispatchReplyWithBufferedBlockDispatcherFn =
|
||||||
typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithBufferedBlockDispatcher;
|
typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithBufferedBlockDispatcher;
|
||||||
type DispatchReplyHarnessParams = Parameters<DispatchReplyWithBufferedBlockDispatcherFn>[0];
|
type DispatchReplyHarnessParams = Parameters<DispatchReplyWithBufferedBlockDispatcherFn>[0];
|
||||||
type FetchRemoteMediaFn = typeof import("openclaw/plugin-sdk/media-runtime").fetchRemoteMedia;
|
type ReadRemoteMediaBufferFn =
|
||||||
|
typeof import("openclaw/plugin-sdk/media-runtime").readRemoteMediaBuffer;
|
||||||
|
|
||||||
const useSpy: Mock = vi.fn();
|
const useSpy: Mock = vi.fn();
|
||||||
const middlewareUseSpy: Mock = vi.fn();
|
const middlewareUseSpy: Mock = vi.fn();
|
||||||
@@ -30,9 +31,9 @@ function resetUndiciFetchMock() {
|
|||||||
undiciFetchSpy.mockImplementation(defaultUndiciFetch);
|
undiciFetchSpy.mockImplementation(defaultUndiciFetch);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function defaultFetchRemoteMedia(
|
async function defaultReadRemoteMediaBuffer(
|
||||||
params: Parameters<FetchRemoteMediaFn>[0],
|
params: Parameters<ReadRemoteMediaBufferFn>[0],
|
||||||
): ReturnType<FetchRemoteMediaFn> {
|
): ReturnType<ReadRemoteMediaBufferFn> {
|
||||||
if (!params.fetchImpl) {
|
if (!params.fetchImpl) {
|
||||||
throw new Error(`Missing fetchImpl for ${params.url}`);
|
throw new Error(`Missing fetchImpl for ${params.url}`);
|
||||||
}
|
}
|
||||||
@@ -47,14 +48,14 @@ async function defaultFetchRemoteMedia(
|
|||||||
buffer: Buffer.from(arrayBuffer),
|
buffer: Buffer.from(arrayBuffer),
|
||||||
contentType: response.headers.get("content-type") ?? undefined,
|
contentType: response.headers.get("content-type") ?? undefined,
|
||||||
fileName: params.filePathHint ? path.basename(params.filePathHint) : undefined,
|
fileName: params.filePathHint ? path.basename(params.filePathHint) : undefined,
|
||||||
} as Awaited<ReturnType<FetchRemoteMediaFn>>;
|
} as Awaited<ReturnType<ReadRemoteMediaBufferFn>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchRemoteMediaSpy: Mock = vi.fn(defaultFetchRemoteMedia);
|
export const readRemoteMediaBufferSpy: Mock = vi.fn(defaultReadRemoteMediaBuffer);
|
||||||
|
|
||||||
export function resetFetchRemoteMediaMock() {
|
export function resetReadRemoteMediaBufferMock() {
|
||||||
fetchRemoteMediaSpy.mockReset();
|
readRemoteMediaBufferSpy.mockReset();
|
||||||
fetchRemoteMediaSpy.mockImplementation(defaultFetchRemoteMedia);
|
readRemoteMediaBufferSpy.mockImplementation(defaultReadRemoteMediaBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function defaultSaveMediaBuffer(buffer: Buffer, contentType?: string) {
|
async function defaultSaveMediaBuffer(buffer: Buffer, contentType?: string) {
|
||||||
@@ -173,7 +174,7 @@ beforeEach(() => {
|
|||||||
resetInboundDedupe();
|
resetInboundDedupe();
|
||||||
resetSaveMediaBufferMock();
|
resetSaveMediaBufferMock();
|
||||||
resetUndiciFetchMock();
|
resetUndiciFetchMock();
|
||||||
resetFetchRemoteMediaMock();
|
resetReadRemoteMediaBufferMock();
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.doMock("./bot.runtime.js", () => ({
|
vi.doMock("./bot.runtime.js", () => ({
|
||||||
@@ -198,10 +199,24 @@ vi.mock("undici", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./telegram-media.runtime.js", () => ({
|
vi.mock("./telegram-media.runtime.js", () => ({
|
||||||
fetchRemoteMedia: (...args: Parameters<typeof fetchRemoteMediaSpy>) =>
|
readRemoteMediaBuffer: (...args: Parameters<typeof readRemoteMediaBufferSpy>) =>
|
||||||
fetchRemoteMediaSpy(...args),
|
readRemoteMediaBufferSpy(...args),
|
||||||
getAgentScopedMediaLocalRoots: vi.fn(() => []),
|
getAgentScopedMediaLocalRoots: vi.fn(() => []),
|
||||||
saveMediaBuffer: (...args: Parameters<typeof saveMediaBufferSpy>) => saveMediaBufferSpy(...args),
|
saveMediaBuffer: (...args: Parameters<typeof saveMediaBufferSpy>) => saveMediaBufferSpy(...args),
|
||||||
|
saveRemoteMedia: async (...args: Parameters<typeof readRemoteMediaBufferSpy>) => {
|
||||||
|
const fetched = (await readRemoteMediaBufferSpy(...args)) as {
|
||||||
|
buffer: Buffer;
|
||||||
|
contentType?: string;
|
||||||
|
fileName?: string;
|
||||||
|
};
|
||||||
|
return await saveMediaBufferSpy(
|
||||||
|
fetched.buffer,
|
||||||
|
fetched.contentType,
|
||||||
|
"inbound",
|
||||||
|
args[0]?.maxBytes,
|
||||||
|
args[0]?.originalFilename ?? fetched.fileName ?? args[0]?.filePathHint,
|
||||||
|
);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock("./bot-message-context.session.runtime.js", async () => {
|
vi.doMock("./bot-message-context.session.runtime.js", async () => {
|
||||||
|
|||||||
@@ -21,16 +21,16 @@ let createTelegramBotRef: typeof import("./bot.js").createTelegramBot;
|
|||||||
let replySpyRef: ReturnType<typeof vi.fn>;
|
let replySpyRef: ReturnType<typeof vi.fn>;
|
||||||
let onSpyRef: Mock;
|
let onSpyRef: Mock;
|
||||||
let sendChatActionSpyRef: Mock;
|
let sendChatActionSpyRef: Mock;
|
||||||
let fetchRemoteMediaSpyRef: Mock;
|
let readRemoteMediaBufferSpyRef: Mock;
|
||||||
let undiciFetchSpyRef: Mock;
|
let undiciFetchSpyRef: Mock;
|
||||||
let resetFetchRemoteMediaMockRef: () => void;
|
let resetReadRemoteMediaBufferMockRef: () => void;
|
||||||
|
|
||||||
type FetchMockHandle = Mock & { mockRestore: () => void };
|
type FetchMockHandle = Mock & { mockRestore: () => void };
|
||||||
|
|
||||||
function createFetchMockHandle(): FetchMockHandle {
|
function createFetchMockHandle(): FetchMockHandle {
|
||||||
return Object.assign(fetchRemoteMediaSpyRef, {
|
return Object.assign(readRemoteMediaBufferSpyRef, {
|
||||||
mockRestore: () => {
|
mockRestore: () => {
|
||||||
resetFetchRemoteMediaMockRef();
|
resetReadRemoteMediaBufferMockRef();
|
||||||
},
|
},
|
||||||
}) as FetchMockHandle;
|
}) as FetchMockHandle;
|
||||||
}
|
}
|
||||||
@@ -88,7 +88,7 @@ export function mockTelegramFileDownload(params: {
|
|||||||
headers: { "content-type": params.contentType },
|
headers: { "content-type": params.contentType },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fetchRemoteMediaSpyRef.mockResolvedValueOnce({
|
readRemoteMediaBufferSpyRef.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from(params.bytes),
|
buffer: Buffer.from(params.bytes),
|
||||||
contentType: params.contentType,
|
contentType: params.contentType,
|
||||||
fileName: "mock-file",
|
fileName: "mock-file",
|
||||||
@@ -103,7 +103,7 @@ export function mockTelegramPngDownload(): FetchMockHandle {
|
|||||||
headers: { "content-type": "image/png" },
|
headers: { "content-type": "image/png" },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fetchRemoteMediaSpyRef.mockResolvedValue({
|
readRemoteMediaBufferSpyRef.mockResolvedValue({
|
||||||
buffer: Buffer.from(new Uint8Array([0x89, 0x50, 0x4e, 0x47])),
|
buffer: Buffer.from(new Uint8Array([0x89, 0x50, 0x4e, 0x47])),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
fileName: "mock-file.png",
|
fileName: "mock-file.png",
|
||||||
@@ -118,9 +118,9 @@ export function watchTelegramFetch(): FetchMockHandle {
|
|||||||
async function loadTelegramBotHarness() {
|
async function loadTelegramBotHarness() {
|
||||||
onSpyRef = harness.onSpy;
|
onSpyRef = harness.onSpy;
|
||||||
sendChatActionSpyRef = harness.sendChatActionSpy;
|
sendChatActionSpyRef = harness.sendChatActionSpy;
|
||||||
fetchRemoteMediaSpyRef = harness.fetchRemoteMediaSpy;
|
readRemoteMediaBufferSpyRef = harness.readRemoteMediaBufferSpy;
|
||||||
undiciFetchSpyRef = harness.undiciFetchSpy;
|
undiciFetchSpyRef = harness.undiciFetchSpy;
|
||||||
resetFetchRemoteMediaMockRef = harness.resetFetchRemoteMediaMock;
|
resetReadRemoteMediaBufferMockRef = harness.resetReadRemoteMediaBufferMock;
|
||||||
const botModule = await import("./bot.js");
|
const botModule = await import("./bot.js");
|
||||||
botModule.setTelegramBotRuntimeForTest(
|
botModule.setTelegramBotRuntimeForTest(
|
||||||
harness.telegramBotRuntimeForTest as unknown as Parameters<
|
harness.telegramBotRuntimeForTest as unknown as Parameters<
|
||||||
|
|||||||
@@ -5,7 +5,27 @@ import { resolveMedia } from "./delivery.resolve-media.js";
|
|||||||
import type { TelegramContext } from "./types.js";
|
import type { TelegramContext } from "./types.js";
|
||||||
|
|
||||||
const saveMediaBuffer = vi.fn();
|
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();
|
const rootRead = vi.fn();
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk/file-access-runtime", () => ({
|
vi.mock("openclaw/plugin-sdk/file-access-runtime", () => ({
|
||||||
@@ -30,7 +50,7 @@ vi.mock("./delivery.resolve-media.runtime.js", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args),
|
readRemoteMediaBuffer: (...args: unknown[]) => readRemoteMediaBuffer(...args),
|
||||||
formatErrorMessage: (err: unknown) => (err instanceof Error ? err.message : String(err)),
|
formatErrorMessage: (err: unknown) => (err instanceof Error ? err.message : String(err)),
|
||||||
logVerbose: () => {},
|
logVerbose: () => {},
|
||||||
MediaFetchError,
|
MediaFetchError,
|
||||||
@@ -38,6 +58,7 @@ vi.mock("./delivery.resolve-media.runtime.js", () => {
|
|||||||
apiRoot?.trim() ? apiRoot.replace(/\/+$/u, "") : "https://api.telegram.org",
|
apiRoot?.trim() ? apiRoot.replace(/\/+$/u, "") : "https://api.telegram.org",
|
||||||
retryAsync,
|
retryAsync,
|
||||||
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
|
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
|
||||||
|
saveRemoteMedia: (...args: unknown[]) => saveRemoteMedia(...args),
|
||||||
shouldRetryTelegramTransportFallback: vi.fn(() => false),
|
shouldRetryTelegramTransportFallback: vi.fn(() => false),
|
||||||
warn: (s: string) => s,
|
warn: (s: string) => s,
|
||||||
};
|
};
|
||||||
@@ -140,7 +161,7 @@ function setupTransientGetFileRetry() {
|
|||||||
.mockRejectedValueOnce(new Error("Network request for 'getFile' failed!"))
|
.mockRejectedValueOnce(new Error("Network request for 'getFile' failed!"))
|
||||||
.mockResolvedValueOnce({ file_path: "voice/file_0.oga" });
|
.mockResolvedValueOnce({ file_path: "voice/file_0.oga" });
|
||||||
|
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("audio"),
|
buffer: Buffer.from("audio"),
|
||||||
contentType: "audio/ogg",
|
contentType: "audio/ogg",
|
||||||
fileName: "file_0.oga",
|
fileName: "file_0.oga",
|
||||||
@@ -154,7 +175,7 @@ function setupTransientGetFileRetry() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mockPdfFetchAndSave(fileName: string | undefined) {
|
function mockPdfFetchAndSave(fileName: string | undefined) {
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("pdf-data"),
|
buffer: Buffer.from("pdf-data"),
|
||||||
contentType: "application/pdf",
|
contentType: "application/pdf",
|
||||||
fileName,
|
fileName,
|
||||||
@@ -204,21 +225,21 @@ function expectRecordFields(record: Record<string, unknown>, fields: Record<stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireFetchRemoteMediaParams(callIndex = 0): Record<string, unknown> {
|
function requireReadRemoteMediaBufferParams(callIndex = 0): Record<string, unknown> {
|
||||||
const call = (fetchRemoteMedia.mock.calls as unknown[][])[callIndex];
|
const call = (readRemoteMediaBuffer.mock.calls as unknown[][])[callIndex];
|
||||||
if (!call) {
|
if (!call) {
|
||||||
throw new Error(`expected fetchRemoteMedia call ${callIndex}`);
|
throw new Error(`expected readRemoteMediaBuffer call ${callIndex}`);
|
||||||
}
|
}
|
||||||
return requireRecord(call[0], `fetchRemoteMedia call ${callIndex} params`);
|
return requireRecord(call[0], `readRemoteMediaBuffer call ${callIndex} params`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectFetchRemoteMediaFields(fields: Record<string, unknown>, callIndex = 0) {
|
function expectReadRemoteMediaBufferFields(fields: Record<string, unknown>, callIndex = 0) {
|
||||||
expectRecordFields(requireFetchRemoteMediaParams(callIndex), fields);
|
expectRecordFields(requireReadRemoteMediaBufferParams(callIndex), fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectFetchSsrfPolicyFields(fields: Record<string, unknown>, callIndex = 0) {
|
function expectFetchSsrfPolicyFields(fields: Record<string, unknown>, callIndex = 0) {
|
||||||
const params = requireFetchRemoteMediaParams(callIndex);
|
const params = requireReadRemoteMediaBufferParams(callIndex);
|
||||||
expectRecordFields(requireRecord(params.ssrfPolicy, "fetchRemoteMedia ssrfPolicy"), fields);
|
expectRecordFields(requireRecord(params.ssrfPolicy, "readRemoteMediaBuffer ssrfPolicy"), fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectResolvedMediaFields(
|
function expectResolvedMediaFields(
|
||||||
@@ -263,7 +284,7 @@ async function expectTransientGetFileRetrySuccess() {
|
|||||||
await flushRetryTimers();
|
await flushRetryTimers();
|
||||||
const result = await promise;
|
const result = await promise;
|
||||||
expect(getFile).toHaveBeenCalledTimes(2);
|
expect(getFile).toHaveBeenCalledTimes(2);
|
||||||
expectFetchRemoteMediaFields({
|
expectReadRemoteMediaBufferFields({
|
||||||
url: `https://api.telegram.org/file/bot${BOT_TOKEN}/voice/file_0.oga`,
|
url: `https://api.telegram.org/file/bot${BOT_TOKEN}/voice/file_0.oga`,
|
||||||
});
|
});
|
||||||
expectFetchSsrfPolicyFields({
|
expectFetchSsrfPolicyFields({
|
||||||
@@ -280,8 +301,9 @@ async function flushRetryTimers() {
|
|||||||
describe("resolveMedia getFile retry", () => {
|
describe("resolveMedia getFile retry", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
fetchRemoteMedia.mockReset();
|
readRemoteMediaBuffer.mockReset();
|
||||||
saveMediaBuffer.mockReset();
|
saveMediaBuffer.mockReset();
|
||||||
|
saveRemoteMedia.mockClear();
|
||||||
rootRead.mockReset();
|
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" });
|
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(
|
await expect(resolveMediaWithDefaults(makeCtx("voice", getFile))).rejects.toThrow(
|
||||||
"download failed",
|
"download failed",
|
||||||
@@ -378,7 +400,7 @@ describe("resolveMedia getFile retry", () => {
|
|||||||
.mockRejectedValueOnce(new Error("Network request for 'getFile' failed!"))
|
.mockRejectedValueOnce(new Error("Network request for 'getFile' failed!"))
|
||||||
.mockResolvedValueOnce({ file_path: "stickers/file_0.webp" });
|
.mockResolvedValueOnce({ file_path: "stickers/file_0.webp" });
|
||||||
|
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("sticker-data"),
|
buffer: Buffer.from("sticker-data"),
|
||||||
contentType: "image/webp",
|
contentType: "image/webp",
|
||||||
fileName: "file_0.webp",
|
fileName: "file_0.webp",
|
||||||
@@ -430,7 +452,7 @@ describe("resolveMedia getFile retry", () => {
|
|||||||
dispatcherAttempts,
|
dispatcherAttempts,
|
||||||
close: async () => {},
|
close: async () => {},
|
||||||
};
|
};
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("pdf-data"),
|
buffer: Buffer.from("pdf-data"),
|
||||||
contentType: "application/pdf",
|
contentType: "application/pdf",
|
||||||
fileName: "file_42.pdf",
|
fileName: "file_42.pdf",
|
||||||
@@ -445,7 +467,7 @@ describe("resolveMedia getFile retry", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result?.path).toBe("/tmp/file_42---uuid.pdf");
|
expect(result?.path).toBe("/tmp/file_42---uuid.pdf");
|
||||||
const params = requireFetchRemoteMediaParams();
|
const params = requireReadRemoteMediaBufferParams();
|
||||||
expectRecordFields(params, {
|
expectRecordFields(params, {
|
||||||
fetchImpl: callerFetch,
|
fetchImpl: callerFetch,
|
||||||
dispatcherAttempts,
|
dispatcherAttempts,
|
||||||
@@ -463,7 +485,7 @@ describe("resolveMedia getFile retry", () => {
|
|||||||
const getFile = vi.fn().mockResolvedValue({ file_path: "stickers/file_0.webp" });
|
const getFile = vi.fn().mockResolvedValue({ file_path: "stickers/file_0.webp" });
|
||||||
const callerFetch = vi.fn() as unknown as typeof fetch;
|
const callerFetch = vi.fn() as unknown as typeof fetch;
|
||||||
const callerTransport = { fetch: callerFetch, sourceFetch: callerFetch, close: async () => {} };
|
const callerTransport = { fetch: callerFetch, sourceFetch: callerFetch, close: async () => {} };
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("sticker-data"),
|
buffer: Buffer.from("sticker-data"),
|
||||||
contentType: "image/webp",
|
contentType: "image/webp",
|
||||||
fileName: "file_0.webp",
|
fileName: "file_0.webp",
|
||||||
@@ -478,12 +500,12 @@ describe("resolveMedia getFile retry", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result?.path).toBe("/tmp/file_0.webp");
|
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 () => {
|
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" });
|
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("pdf-data"),
|
buffer: Buffer.from("pdf-data"),
|
||||||
contentType: "application/pdf",
|
contentType: "application/pdf",
|
||||||
fileName: "file_42.pdf",
|
fileName: "file_42.pdf",
|
||||||
@@ -498,7 +520,7 @@ describe("resolveMedia getFile retry", () => {
|
|||||||
dangerouslyAllowPrivateNetwork: true,
|
dangerouslyAllowPrivateNetwork: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expectFetchRemoteMediaFields({
|
expectReadRemoteMediaBufferFields({
|
||||||
url: `https://telegram.internal:8443/custom/file/bot${BOT_TOKEN}/documents/file_42.pdf`,
|
url: `https://telegram.internal:8443/custom/file/bot${BOT_TOKEN}/documents/file_42.pdf`,
|
||||||
});
|
});
|
||||||
expectFetchSsrfPolicyFields({
|
expectFetchSsrfPolicyFields({
|
||||||
@@ -526,7 +548,7 @@ describe("resolveMedia getFile retry", () => {
|
|||||||
{ trustedLocalFileRoots: ["/var/lib/telegram-bot-api"] },
|
{ trustedLocalFileRoots: ["/var/lib/telegram-bot-api"] },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(fetchRemoteMedia).not.toHaveBeenCalled();
|
expect(readRemoteMediaBuffer).not.toHaveBeenCalled();
|
||||||
expect(rootRead).toHaveBeenCalledWith({
|
expect(rootRead).toHaveBeenCalledWith({
|
||||||
rootDir: "/var/lib/telegram-bot-api",
|
rootDir: "/var/lib/telegram-bot-api",
|
||||||
relativePath: "file.pdf",
|
relativePath: "file.pdf",
|
||||||
@@ -564,7 +586,7 @@ describe("resolveMedia getFile retry", () => {
|
|||||||
trustedLocalFileRoots: ["/var/lib/telegram-bot-api"],
|
trustedLocalFileRoots: ["/var/lib/telegram-bot-api"],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetchRemoteMedia).not.toHaveBeenCalled();
|
expect(readRemoteMediaBuffer).not.toHaveBeenCalled();
|
||||||
expect(rootRead).toHaveBeenCalledWith({
|
expect(rootRead).toHaveBeenCalledWith({
|
||||||
rootDir: "/var/lib/telegram-bot-api",
|
rootDir: "/var/lib/telegram-bot-api",
|
||||||
relativePath: "sticker.webp",
|
relativePath: "sticker.webp",
|
||||||
@@ -625,14 +647,14 @@ describe("resolveMedia getFile retry", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(rootRead).not.toHaveBeenCalled();
|
expect(rootRead).not.toHaveBeenCalled();
|
||||||
expect(fetchRemoteMedia).not.toHaveBeenCalled();
|
expect(readRemoteMediaBuffer).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveMedia original filename preservation", () => {
|
describe("resolveMedia original filename preservation", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
fetchRemoteMedia.mockClear();
|
readRemoteMediaBuffer.mockClear();
|
||||||
saveMediaBuffer.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 () => {
|
it("passes document.file_name to saveMediaBuffer instead of server-side path", async () => {
|
||||||
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
|
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("pdf-data"),
|
buffer: Buffer.from("pdf-data"),
|
||||||
contentType: "application/pdf",
|
contentType: "application/pdf",
|
||||||
fileName: "file_42.pdf",
|
fileName: "file_42.pdf",
|
||||||
@@ -668,7 +690,7 @@ describe("resolveMedia original filename preservation", () => {
|
|||||||
|
|
||||||
it("passes audio.file_name to saveMediaBuffer", async () => {
|
it("passes audio.file_name to saveMediaBuffer", async () => {
|
||||||
const getFile = vi.fn().mockResolvedValue({ file_path: "music/file_99.mp3" });
|
const getFile = vi.fn().mockResolvedValue({ file_path: "music/file_99.mp3" });
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("audio-data"),
|
buffer: Buffer.from("audio-data"),
|
||||||
contentType: "audio/mpeg",
|
contentType: "audio/mpeg",
|
||||||
fileName: "file_99.mp3",
|
fileName: "file_99.mp3",
|
||||||
@@ -692,7 +714,7 @@ describe("resolveMedia original filename preservation", () => {
|
|||||||
|
|
||||||
it("passes video.file_name to saveMediaBuffer", async () => {
|
it("passes video.file_name to saveMediaBuffer", async () => {
|
||||||
const getFile = vi.fn().mockResolvedValue({ file_path: "videos/file_55.mp4" });
|
const getFile = vi.fn().mockResolvedValue({ file_path: "videos/file_55.mp4" });
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("video-data"),
|
buffer: Buffer.from("video-data"),
|
||||||
contentType: "video/mp4",
|
contentType: "video/mp4",
|
||||||
fileName: "file_55.mp4",
|
fileName: "file_55.mp4",
|
||||||
@@ -786,7 +808,7 @@ describe("resolveMedia original filename preservation", () => {
|
|||||||
const ctx = makeCtx("document", getFile);
|
const ctx = makeCtx("document", getFile);
|
||||||
const result = await resolveMediaWithDefaults(ctx, { apiRoot: customApiRoot });
|
const result = await resolveMediaWithDefaults(ctx, { apiRoot: customApiRoot });
|
||||||
|
|
||||||
expectFetchRemoteMediaFields({
|
expectReadRemoteMediaBufferFields({
|
||||||
url: `${customApiRoot}/file/bot${BOT_TOKEN}/documents/file_42.pdf`,
|
url: `${customApiRoot}/file/bot${BOT_TOKEN}/documents/file_42.pdf`,
|
||||||
});
|
});
|
||||||
requireResolvedMedia(result, "custom apiRoot document URL");
|
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 () => {
|
it("constructs correct download URL with custom apiRoot for stickers", async () => {
|
||||||
const getFile = vi.fn().mockResolvedValue({ file_path: "stickers/file_0.webp" });
|
const getFile = vi.fn().mockResolvedValue({ file_path: "stickers/file_0.webp" });
|
||||||
fetchRemoteMedia.mockResolvedValueOnce({
|
readRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("sticker-data"),
|
buffer: Buffer.from("sticker-data"),
|
||||||
contentType: "image/webp",
|
contentType: "image/webp",
|
||||||
fileName: "file_0.webp",
|
fileName: "file_0.webp",
|
||||||
@@ -808,7 +830,7 @@ describe("resolveMedia original filename preservation", () => {
|
|||||||
const ctx = makeCtx("sticker", getFile);
|
const ctx = makeCtx("sticker", getFile);
|
||||||
const result = await resolveMediaWithDefaults(ctx, { apiRoot: customApiRoot });
|
const result = await resolveMediaWithDefaults(ctx, { apiRoot: customApiRoot });
|
||||||
|
|
||||||
expectFetchRemoteMediaFields({
|
expectReadRemoteMediaBufferFields({
|
||||||
url: `${customApiRoot}/file/bot${BOT_TOKEN}/stickers/file_0.webp`,
|
url: `${customApiRoot}/file/bot${BOT_TOKEN}/stickers/file_0.webp`,
|
||||||
});
|
});
|
||||||
requireResolvedMedia(result, "custom apiRoot sticker URL");
|
requireResolvedMedia(result, "custom apiRoot sticker URL");
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import { logVerbose, retryAsync, warn } from "openclaw/plugin-sdk/runtime-env";
|
import { logVerbose, retryAsync, warn } from "openclaw/plugin-sdk/runtime-env";
|
||||||
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
|
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||||
import { resolveTelegramApiBase, shouldRetryTelegramTransportFallback } from "../fetch.js";
|
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 {
|
export {
|
||||||
fetchRemoteMedia,
|
readRemoteMediaBuffer,
|
||||||
formatErrorMessage,
|
formatErrorMessage,
|
||||||
logVerbose,
|
logVerbose,
|
||||||
MediaFetchError,
|
MediaFetchError,
|
||||||
resolveTelegramApiBase,
|
resolveTelegramApiBase,
|
||||||
retryAsync,
|
retryAsync,
|
||||||
saveMediaBuffer,
|
saveMediaBuffer,
|
||||||
|
saveRemoteMedia,
|
||||||
shouldRetryTelegramTransportFallback,
|
shouldRetryTelegramTransportFallback,
|
||||||
warn,
|
warn,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { root as fsRoot } from "openclaw/plugin-sdk/file-access-runtime";
|
|||||||
import type { TelegramTransport } from "../fetch.js";
|
import type { TelegramTransport } from "../fetch.js";
|
||||||
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
|
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
|
||||||
import {
|
import {
|
||||||
fetchRemoteMedia,
|
|
||||||
formatErrorMessage,
|
formatErrorMessage,
|
||||||
logVerbose,
|
logVerbose,
|
||||||
MediaFetchError,
|
MediaFetchError,
|
||||||
resolveTelegramApiBase,
|
resolveTelegramApiBase,
|
||||||
retryAsync,
|
retryAsync,
|
||||||
saveMediaBuffer,
|
saveMediaBuffer,
|
||||||
|
saveRemoteMedia,
|
||||||
shouldRetryTelegramTransportFallback,
|
shouldRetryTelegramTransportFallback,
|
||||||
warn,
|
warn,
|
||||||
} from "./delivery.resolve-media.runtime.js";
|
} from "./delivery.resolve-media.runtime.js";
|
||||||
@@ -231,25 +231,28 @@ async function downloadAndSaveTelegramFile(params: {
|
|||||||
const transport = resolveRequiredTelegramTransport(params.transport);
|
const transport = resolveRequiredTelegramTransport(params.transport);
|
||||||
const apiBase = resolveTelegramApiBase(params.apiRoot);
|
const apiBase = resolveTelegramApiBase(params.apiRoot);
|
||||||
const url = `${apiBase}/file/bot${params.token}/${params.filePath}`;
|
const url = `${apiBase}/file/bot${params.token}/${params.filePath}`;
|
||||||
const fetched = await fetchRemoteMedia({
|
return await saveRemoteMedia({
|
||||||
url,
|
url,
|
||||||
fetchImpl: transport.sourceFetch,
|
fetchImpl: transport.sourceFetch,
|
||||||
dispatcherAttempts: transport.dispatcherAttempts,
|
dispatcherAttempts: transport.dispatcherAttempts,
|
||||||
trustExplicitProxyDns: usesTrustedTelegramExplicitProxy(transport),
|
trustExplicitProxyDns: usesTrustedTelegramExplicitProxy(transport),
|
||||||
shouldRetryFetchError: shouldRetryTelegramTransportFallback,
|
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,
|
filePathHint: params.filePath,
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS,
|
readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS,
|
||||||
ssrfPolicy: buildTelegramMediaSsrfPolicy(params.apiRoot, params.dangerouslyAllowPrivateNetwork),
|
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: {
|
async function resolveStickerMedia(params: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
fetchRemoteMedia,
|
readRemoteMediaBuffer,
|
||||||
MediaFetchError,
|
MediaFetchError,
|
||||||
saveMediaBuffer,
|
saveMediaBuffer,
|
||||||
|
saveRemoteMedia,
|
||||||
} from "openclaw/plugin-sdk/media-runtime";
|
} from "openclaw/plugin-sdk/media-runtime";
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
fetchRemoteMedia,
|
readRemoteMediaBuffer,
|
||||||
MAX_IMAGE_BYTES,
|
MAX_IMAGE_BYTES,
|
||||||
saveMediaBuffer,
|
saveRemoteMedia,
|
||||||
} from "openclaw/plugin-sdk/media-runtime";
|
} from "openclaw/plugin-sdk/media-runtime";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { downloadMedia, extractImageBlocks } from "./media.js";
|
import { downloadMedia, extractImageBlocks } from "./media.js";
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
|
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
|
||||||
MAX_IMAGE_BYTES: 6 * 1024 * 1024,
|
MAX_IMAGE_BYTES: 6 * 1024 * 1024,
|
||||||
fetchRemoteMedia: vi.fn(),
|
readRemoteMediaBuffer: vi.fn(),
|
||||||
saveMediaBuffer: vi.fn(),
|
saveRemoteMedia: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const fetchRemoteMediaMock = vi.mocked(fetchRemoteMedia);
|
const readRemoteMediaBufferMock = vi.mocked(readRemoteMediaBuffer);
|
||||||
const saveMediaBufferMock = vi.mocked(saveMediaBuffer);
|
const saveRemoteMediaMock = vi.mocked(saveRemoteMedia);
|
||||||
|
|
||||||
describe("tlon monitor media", () => {
|
describe("tlon monitor media", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -40,12 +40,7 @@ describe("tlon monitor media", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("stores fetched media through the shared inbound media store with the image cap", async () => {
|
it("stores fetched media through the shared inbound media store with the image cap", async () => {
|
||||||
fetchRemoteMediaMock.mockResolvedValue({
|
saveRemoteMediaMock.mockResolvedValue({
|
||||||
buffer: Buffer.from("image-data"),
|
|
||||||
contentType: "image/png",
|
|
||||||
fileName: "photo.png",
|
|
||||||
});
|
|
||||||
saveMediaBufferMock.mockResolvedValue({
|
|
||||||
id: "photo---uuid.png",
|
id: "photo---uuid.png",
|
||||||
path: "/tmp/openclaw/media/inbound/photo---uuid.png",
|
path: "/tmp/openclaw/media/inbound/photo---uuid.png",
|
||||||
size: "image-data".length,
|
size: "image-data".length,
|
||||||
@@ -54,21 +49,15 @@ describe("tlon monitor media", () => {
|
|||||||
|
|
||||||
const result = await downloadMedia("https://example.com/photo.png");
|
const result = await downloadMedia("https://example.com/photo.png");
|
||||||
|
|
||||||
expect(fetchRemoteMediaMock).toHaveBeenCalledTimes(1);
|
expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
|
||||||
expect(fetchRemoteMediaMock).toHaveBeenCalledWith({
|
expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(saveRemoteMediaMock).toHaveBeenCalledWith({
|
||||||
url: "https://example.com/photo.png",
|
url: "https://example.com/photo.png",
|
||||||
maxBytes: MAX_IMAGE_BYTES,
|
maxBytes: MAX_IMAGE_BYTES,
|
||||||
readIdleTimeoutMs: 30_000,
|
readIdleTimeoutMs: 30_000,
|
||||||
ssrfPolicy: undefined,
|
ssrfPolicy: undefined,
|
||||||
requestInit: { method: "GET" },
|
requestInit: { method: "GET" },
|
||||||
});
|
});
|
||||||
expect(saveMediaBufferMock).toHaveBeenCalledWith(
|
|
||||||
Buffer.from("image-data"),
|
|
||||||
"image/png",
|
|
||||||
"inbound",
|
|
||||||
MAX_IMAGE_BYTES,
|
|
||||||
"photo.png",
|
|
||||||
);
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
localPath: "/tmp/openclaw/media/inbound/photo---uuid.png",
|
localPath: "/tmp/openclaw/media/inbound/photo---uuid.png",
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
@@ -77,7 +66,7 @@ describe("tlon monitor media", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when the fetch exceeds the image cap", async () => {
|
it("returns null when the fetch exceeds the image cap", async () => {
|
||||||
fetchRemoteMediaMock.mockRejectedValue(
|
saveRemoteMediaMock.mockRejectedValue(
|
||||||
new Error(
|
new Error(
|
||||||
`Failed to fetch media from https://example.com/photo.png: payload exceeds maxBytes ${MAX_IMAGE_BYTES}`,
|
`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");
|
const result = await downloadMedia("https://example.com/photo.png");
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
expect(saveMediaBufferMock).not.toHaveBeenCalled();
|
expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import * as path from "node:path";
|
|||||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||||
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
|
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
|
||||||
import {
|
import {
|
||||||
fetchRemoteMedia,
|
readRemoteMediaBuffer,
|
||||||
MAX_IMAGE_BYTES,
|
MAX_IMAGE_BYTES,
|
||||||
saveMediaBuffer,
|
saveRemoteMedia,
|
||||||
} from "openclaw/plugin-sdk/media-runtime";
|
} from "openclaw/plugin-sdk/media-runtime";
|
||||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||||
import { getDefaultSsrFPolicy } from "../urbit/context.js";
|
import { getDefaultSsrFPolicy } from "../urbit/context.js";
|
||||||
@@ -67,29 +67,24 @@ export async function downloadMedia(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetched = await fetchRemoteMedia({
|
const fetchOptions = {
|
||||||
url,
|
url,
|
||||||
maxBytes: MAX_IMAGE_BYTES,
|
maxBytes: MAX_IMAGE_BYTES,
|
||||||
readIdleTimeoutMs: TLON_MEDIA_DOWNLOAD_IDLE_TIMEOUT_MS,
|
readIdleTimeoutMs: TLON_MEDIA_DOWNLOAD_IDLE_TIMEOUT_MS,
|
||||||
ssrfPolicy: getDefaultSsrFPolicy(),
|
ssrfPolicy: getDefaultSsrFPolicy(),
|
||||||
requestInit: { method: "GET" },
|
requestInit: { method: "GET" },
|
||||||
});
|
};
|
||||||
|
|
||||||
if (!mediaDir) {
|
if (!mediaDir) {
|
||||||
const saved = await saveMediaBuffer(
|
const saved = await saveRemoteMedia(fetchOptions);
|
||||||
fetched.buffer,
|
|
||||||
fetched.contentType,
|
|
||||||
"inbound",
|
|
||||||
MAX_IMAGE_BYTES,
|
|
||||||
fetched.fileName,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
localPath: saved.path,
|
localPath: saved.path,
|
||||||
contentType: saved.contentType ?? fetched.contentType ?? "application/octet-stream",
|
contentType: saved.contentType ?? "application/octet-stream",
|
||||||
originalUrl: url,
|
originalUrl: url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetched = await readRemoteMediaBuffer(fetchOptions);
|
||||||
await mkdir(mediaDir, { recursive: true });
|
await mkdir(mediaDir, { recursive: true });
|
||||||
const ext =
|
const ext =
|
||||||
getExtensionFromFileName(fetched.fileName) ||
|
getExtensionFromFileName(fetched.fileName) ||
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type MockMessageInput = Parameters<typeof mockNormalizeMessageContent>[0];
|
|||||||
|
|
||||||
const readAllowFromStoreMock = vi.fn().mockResolvedValue([]);
|
const readAllowFromStoreMock = vi.fn().mockResolvedValue([]);
|
||||||
const upsertPairingRequestMock = vi.fn().mockResolvedValue({ code: "PAIRCODE", created: true });
|
const upsertPairingRequestMock = vi.fn().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||||
const saveMediaBufferSpy = vi.fn();
|
const saveMediaStreamSpy = vi.fn();
|
||||||
let currentMockSocket:
|
let currentMockSocket:
|
||||||
| {
|
| {
|
||||||
ev: import("node:events").EventEmitter;
|
ev: import("node:events").EventEmitter;
|
||||||
@@ -82,9 +82,9 @@ vi.mock("openclaw/plugin-sdk/media-store", async () => {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
saveMediaBuffer: vi.fn(async (...args: Parameters<typeof actual.saveMediaBuffer>) => {
|
saveMediaStream: vi.fn(async (...args: Parameters<typeof actual.saveMediaStream>) => {
|
||||||
saveMediaBufferSpy(...args);
|
saveMediaStreamSpy(...args);
|
||||||
return actual.saveMediaBuffer(...args);
|
return actual.saveMediaStream(...args);
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -95,6 +95,7 @@ process.env.HOME = HOME;
|
|||||||
|
|
||||||
vi.mock("baileys", async () => {
|
vi.mock("baileys", async () => {
|
||||||
const actual = await vi.importActual<typeof import("baileys")>("baileys");
|
const actual = await vi.importActual<typeof import("baileys")>("baileys");
|
||||||
|
const { Readable } = require("node:stream") as typeof import("node:stream");
|
||||||
const jpegBuffer = Buffer.from([
|
const jpegBuffer = Buffer.from([
|
||||||
0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x02, 0x02, 0x02, 0x03, 0x02, 0x02,
|
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,
|
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 {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
DisconnectReason: actual.DisconnectReason ?? { loggedOut: 401 },
|
DisconnectReason: actual.DisconnectReason ?? { loggedOut: 401 },
|
||||||
downloadMediaMessage: vi.fn().mockResolvedValue(jpegBuffer),
|
downloadMediaMessage: vi.fn().mockImplementation(() => Readable.from([jpegBuffer])),
|
||||||
extractMessageContent: vi.fn((message: MockMessageInput) => mockExtractMessageContent(message)),
|
extractMessageContent: vi.fn((message: MockMessageInput) => mockExtractMessageContent(message)),
|
||||||
getContentType: vi.fn((message: MockMessageInput) => mockGetContentType(message)),
|
getContentType: vi.fn((message: MockMessageInput) => mockGetContentType(message)),
|
||||||
isJidGroup: vi.fn((jid: string | undefined | null) => mockIsJidGroup(jid)),
|
isJidGroup: vi.fn((jid: string | undefined | null) => mockIsJidGroup(jid)),
|
||||||
@@ -156,10 +157,10 @@ async function waitForMessage(onMessage: ReturnType<typeof vi.fn>) {
|
|||||||
return onMessage.mock.calls[0]?.[0];
|
return onMessage.mock.calls[0]?.[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function latestSaveMediaBufferCall() {
|
function latestSaveMediaStreamCall() {
|
||||||
const call = saveMediaBufferSpy.mock.calls[saveMediaBufferSpy.mock.calls.length - 1];
|
const call = saveMediaStreamSpy.mock.calls[saveMediaStreamSpy.mock.calls.length - 1];
|
||||||
if (!call) {
|
if (!call) {
|
||||||
throw new Error("expected saveMediaBuffer call");
|
throw new Error("expected saveMediaStream call");
|
||||||
}
|
}
|
||||||
return call;
|
return call;
|
||||||
}
|
}
|
||||||
@@ -181,7 +182,7 @@ describe("web inbound media saves with extension", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
currentMockSocket = undefined;
|
currentMockSocket = undefined;
|
||||||
saveMediaBufferSpy.mockClear();
|
saveMediaStreamSpy.mockClear();
|
||||||
resetWebInboundDedupe();
|
resetWebInboundDedupe();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,8 +247,8 @@ describe("web inbound media saves with extension", () => {
|
|||||||
|
|
||||||
const second = await waitForMessage(onMessage);
|
const second = await waitForMessage(onMessage);
|
||||||
expect(second.mediaFileName).toBe(fileName);
|
expect(second.mediaFileName).toBe(fileName);
|
||||||
expect(saveMediaBufferSpy).toHaveBeenCalled();
|
expect(saveMediaStreamSpy).toHaveBeenCalled();
|
||||||
const lastCall = latestSaveMediaBufferCall();
|
const lastCall = latestSaveMediaStreamCall();
|
||||||
expect(lastCall[4]).toBe(fileName);
|
expect(lastCall[4]).toBe(fileName);
|
||||||
|
|
||||||
await listener.close();
|
await listener.close();
|
||||||
@@ -294,14 +295,14 @@ describe("web inbound media saves with extension", () => {
|
|||||||
expect(inbound.replyToBody).toBe("<media:image>");
|
expect(inbound.replyToBody).toBe("<media:image>");
|
||||||
const mediaPath = requireMediaPath(inbound.mediaPath);
|
const mediaPath = requireMediaPath(inbound.mediaPath);
|
||||||
expect(path.extname(mediaPath)).toBe(".jpg");
|
expect(path.extname(mediaPath)).toBe(".jpg");
|
||||||
expect(saveMediaBufferSpy).toHaveBeenCalled();
|
expect(saveMediaStreamSpy).toHaveBeenCalled();
|
||||||
const lastCall = latestSaveMediaBufferCall();
|
const lastCall = latestSaveMediaStreamCall();
|
||||||
expect(lastCall[1]).toBe("image/jpeg");
|
expect(lastCall[1]).toBe("image/jpeg");
|
||||||
|
|
||||||
await listener.close();
|
await listener.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes mediaMaxMb to saveMediaBuffer", async () => {
|
it("passes mediaMaxMb to saveMediaStream", async () => {
|
||||||
const onMessage = vi.fn();
|
const onMessage = vi.fn();
|
||||||
const listener = await monitorWebInbox({
|
const listener = await monitorWebInbox({
|
||||||
cfg: {
|
cfg: {
|
||||||
@@ -330,8 +331,8 @@ describe("web inbound media saves with extension", () => {
|
|||||||
realSock.ev.emit("messages.upsert", upsert);
|
realSock.ev.emit("messages.upsert", upsert);
|
||||||
|
|
||||||
await waitForMessage(onMessage);
|
await waitForMessage(onMessage);
|
||||||
expect(saveMediaBufferSpy).toHaveBeenCalled();
|
expect(saveMediaStreamSpy).toHaveBeenCalled();
|
||||||
const lastCall = latestSaveMediaBufferCall();
|
const lastCall = latestSaveMediaStreamCall();
|
||||||
expect(lastCall[3]).toBe(1 * 1024 * 1024);
|
expect(lastCall[3]).toBe(1 * 1024 * 1024);
|
||||||
|
|
||||||
await listener.close();
|
await listener.close();
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import { Readable } from "node:stream";
|
||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { mockNormalizeMessageContent } from "../../../../test/mocks/baileys.js";
|
import { mockNormalizeMessageContent } from "../../../../test/mocks/baileys.js";
|
||||||
|
|
||||||
type MockMessageInput = Parameters<typeof mockNormalizeMessageContent>[0];
|
type MockMessageInput = Parameters<typeof mockNormalizeMessageContent>[0];
|
||||||
|
|
||||||
const { normalizeMessageContent, downloadMediaMessage } = vi.hoisted(() => ({
|
const { normalizeMessageContent, downloadMediaMessage, saveMediaStream } = vi.hoisted(() => ({
|
||||||
normalizeMessageContent: vi.fn((msg: MockMessageInput) => mockNormalizeMessageContent(msg)),
|
normalizeMessageContent: vi.fn((msg: MockMessageInput) => mockNormalizeMessageContent(msg)),
|
||||||
downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from("fake-media-data")),
|
downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from("fake-media-data")),
|
||||||
|
saveMediaStream: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("baileys", async () => {
|
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;
|
let downloadInboundMedia: typeof import("./media.js").downloadInboundMedia;
|
||||||
|
|
||||||
const mockSock = {
|
const mockSock = {
|
||||||
@@ -26,7 +32,12 @@ const mockSock = {
|
|||||||
async function expectMimetype(message: Record<string, unknown>, expected: string) {
|
async function expectMimetype(message: Record<string, unknown>, expected: string) {
|
||||||
const result = await downloadInboundMedia({ message } as never, mockSock as never);
|
const result = await downloadInboundMedia({ message } as never, mockSock as never);
|
||||||
expect(result).toEqual({
|
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,
|
mimetype: expected,
|
||||||
fileName: undefined,
|
fileName: undefined,
|
||||||
});
|
});
|
||||||
@@ -40,6 +51,25 @@ describe("downloadInboundMedia", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
normalizeMessageContent.mockClear();
|
normalizeMessageContent.mockClear();
|
||||||
downloadMediaMessage.mockClear();
|
downloadMediaMessage.mockClear();
|
||||||
|
downloadMediaMessage.mockImplementation(() => Readable.from([Buffer.from("fake-media-data")]));
|
||||||
|
saveMediaStream.mockClear();
|
||||||
|
saveMediaStream.mockImplementation(
|
||||||
|
async (
|
||||||
|
stream: AsyncIterable<Buffer>,
|
||||||
|
contentType: string | undefined,
|
||||||
|
_subdir: string,
|
||||||
|
maxBytes: number,
|
||||||
|
) => {
|
||||||
|
let total = 0;
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
total += chunk.byteLength;
|
||||||
|
if (total > maxBytes) {
|
||||||
|
throw new Error("Media exceeds limit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id: "saved-media", path: "/tmp/saved-media", size: total, contentType };
|
||||||
|
},
|
||||||
|
);
|
||||||
mockSock.updateMediaMessage.mockClear();
|
mockSock.updateMediaMessage.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,9 +110,29 @@ describe("downloadInboundMedia", () => {
|
|||||||
} as never;
|
} as never;
|
||||||
const result = await downloadInboundMedia(msg, mockSock as never);
|
const result = await downloadInboundMedia(msg, mockSock as never);
|
||||||
expect(result).toEqual({
|
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",
|
mimetype: "application/pdf",
|
||||||
fileName: "report.pdf",
|
fileName: "report.pdf",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("downloads in stream mode and rejects over the configured cap", async () => {
|
||||||
|
downloadMediaMessage.mockImplementationOnce(() =>
|
||||||
|
Readable.from([Buffer.alloc(4), Buffer.alloc(4)]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
downloadInboundMedia(
|
||||||
|
{ message: { imageMessage: { mimetype: "image/jpeg" } } } as never,
|
||||||
|
mockSock as never,
|
||||||
|
7,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/Media exceeds/i);
|
||||||
|
expect(downloadMediaMessage.mock.calls[0]?.[1]).toBe("stream");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import type { proto, WAMessage } from "baileys";
|
import type { proto, WAMessage } from "baileys";
|
||||||
|
import { saveMediaStream, type SavedMedia } from "openclaw/plugin-sdk/media-store";
|
||||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||||
import type { createWaSocket } from "../session.js";
|
import type { createWaSocket } from "../session.js";
|
||||||
import { extractContextInfo } from "./extract.js";
|
import { extractContextInfo } from "./extract.js";
|
||||||
import { downloadMediaMessage, normalizeMessageContent } from "./runtime-api.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 {
|
function unwrapMessage(message: proto.IMessage | undefined): proto.IMessage | undefined {
|
||||||
const normalized = normalizeMessageContent(message);
|
const normalized = normalizeMessageContent(message);
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -43,7 +51,8 @@ function resolveMediaMimetype(message: proto.IMessage): string | undefined {
|
|||||||
export async function downloadInboundMedia(
|
export async function downloadInboundMedia(
|
||||||
msg: proto.IWebMessageInfo,
|
msg: proto.IWebMessageInfo,
|
||||||
sock: Awaited<ReturnType<typeof createWaSocket>>,
|
sock: Awaited<ReturnType<typeof createWaSocket>>,
|
||||||
): Promise<{ buffer: Buffer; mimetype?: string; fileName?: string } | undefined> {
|
maxBytes = 50 * 1024 * 1024,
|
||||||
|
): Promise<{ saved: SavedMedia; mimetype?: string; fileName?: string } | undefined> {
|
||||||
const message = unwrapMessage(msg.message as proto.IMessage | undefined);
|
const message = unwrapMessage(msg.message as proto.IMessage | undefined);
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -60,17 +69,32 @@ export async function downloadInboundMedia(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const buffer = await downloadMediaMessage(
|
const stream = await downloadMediaMessage(
|
||||||
msg as WAMessage,
|
msg as WAMessage,
|
||||||
"buffer",
|
"stream",
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
reuploadRequest: sock.updateMediaMessage,
|
reuploadRequest: sock.updateMediaMessage,
|
||||||
logger: sock.logger,
|
logger: sock.logger,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return { buffer, mimetype, fileName };
|
const saved = await saveMediaStream(
|
||||||
|
stream as AsyncIterable<unknown>,
|
||||||
|
mimetype,
|
||||||
|
"inbound",
|
||||||
|
maxBytes,
|
||||||
|
fileName,
|
||||||
|
).catch((err) => {
|
||||||
|
if (err instanceof Error && /Media exceeds/i.test(err.message)) {
|
||||||
|
throw new WhatsAppInboundMediaLimitExceededError(maxBytes);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
return { saved, mimetype, fileName };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof WhatsAppInboundMediaLimitExceededError) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
logVerbose(`downloadMediaMessage failed: ${String(err)}`);
|
logVerbose(`downloadMediaMessage failed: ${String(err)}`);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -79,7 +103,8 @@ export async function downloadInboundMedia(
|
|||||||
export async function downloadQuotedInboundMedia(
|
export async function downloadQuotedInboundMedia(
|
||||||
msg: proto.IWebMessageInfo,
|
msg: proto.IWebMessageInfo,
|
||||||
sock: Awaited<ReturnType<typeof createWaSocket>>,
|
sock: Awaited<ReturnType<typeof createWaSocket>>,
|
||||||
): Promise<{ buffer: Buffer; mimetype?: string; fileName?: string } | undefined> {
|
maxBytes = 50 * 1024 * 1024,
|
||||||
|
): Promise<{ saved: SavedMedia; mimetype?: string; fileName?: string } | undefined> {
|
||||||
const message = unwrapMessage(msg.message as proto.IMessage | undefined);
|
const message = unwrapMessage(msg.message as proto.IMessage | undefined);
|
||||||
const contextInfo = extractContextInfo(message);
|
const contextInfo = extractContextInfo(message);
|
||||||
if (!contextInfo?.quotedMessage) {
|
if (!contextInfo?.quotedMessage) {
|
||||||
@@ -98,5 +123,6 @@ export async function downloadQuotedInboundMedia(
|
|||||||
messageTimestamp: msg.messageTimestamp,
|
messageTimestamp: msg.messageTimestamp,
|
||||||
},
|
},
|
||||||
sock,
|
sock,
|
||||||
|
maxBytes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import {
|
|||||||
resolveWhatsAppOutboundMentions,
|
resolveWhatsAppOutboundMentions,
|
||||||
type WhatsAppOutboundMentionParticipant,
|
type WhatsAppOutboundMentionParticipant,
|
||||||
} from "./outbound-mentions.js";
|
} 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 { createWebSendApi } from "./send-api.js";
|
||||||
import { normalizeWhatsAppSendResult } from "./send-result.js";
|
import { normalizeWhatsAppSendResult } from "./send-result.js";
|
||||||
import type { WebInboundMessage, WebListenerCloseReason } from "./types.js";
|
import type { WebInboundMessage, WebListenerCloseReason } from "./types.js";
|
||||||
@@ -656,32 +656,25 @@ export async function attachWebInboxToSocket(
|
|||||||
let mediaPath: string | undefined;
|
let mediaPath: string | undefined;
|
||||||
let mediaType: string | undefined;
|
let mediaType: string | undefined;
|
||||||
let mediaFileName: 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 (
|
const saveInboundMedia = async (
|
||||||
inboundMedia: Awaited<ReturnType<typeof downloadInboundMedia>>,
|
inboundMedia: Awaited<ReturnType<typeof downloadInboundMedia>>,
|
||||||
) => {
|
) => {
|
||||||
if (!inboundMedia) {
|
if (!inboundMedia) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const maxMb =
|
mediaPath = inboundMedia.saved.path;
|
||||||
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;
|
|
||||||
mediaType = inboundMedia.mimetype;
|
mediaType = inboundMedia.mimetype;
|
||||||
mediaFileName = inboundMedia.fileName;
|
mediaFileName = inboundMedia.fileName;
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const inboundMedia = await downloadInboundMedia(msg as proto.IWebMessageInfo, sock);
|
const inboundMedia = await downloadInboundMedia(msg as proto.IWebMessageInfo, sock, maxBytes);
|
||||||
await saveInboundMedia(inboundMedia);
|
await saveInboundMedia(inboundMedia);
|
||||||
if (!mediaPath && replyContext) {
|
if (!mediaPath && replyContext) {
|
||||||
await saveInboundMedia(
|
await saveInboundMedia(
|
||||||
await downloadQuotedInboundMedia(msg as proto.IWebMessageInfo, sock),
|
await downloadQuotedInboundMedia(msg as proto.IWebMessageInfo, sock, maxBytes),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ describe("Zalo polling image handling", () => {
|
|||||||
core,
|
core,
|
||||||
finalizeInboundContextMock,
|
finalizeInboundContextMock,
|
||||||
recordInboundSessionMock,
|
recordInboundSessionMock,
|
||||||
fetchRemoteMediaMock,
|
readRemoteMediaBufferMock,
|
||||||
|
saveRemoteMediaMock,
|
||||||
saveMediaBufferMock,
|
saveMediaBufferMock,
|
||||||
} = createImageLifecycleCore();
|
} = createImageLifecycleCore();
|
||||||
|
|
||||||
@@ -57,9 +58,11 @@ describe("Zalo polling image handling", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await settleAsyncWork();
|
await settleAsyncWork();
|
||||||
expect(fetchRemoteMediaMock).toHaveBeenCalledTimes(1);
|
expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
|
||||||
expectImageLifecycleDelivery({
|
expectImageLifecycleDelivery({
|
||||||
fetchRemoteMediaMock,
|
readRemoteMediaBufferMock,
|
||||||
|
saveRemoteMediaMock,
|
||||||
saveMediaBufferMock,
|
saveMediaBufferMock,
|
||||||
finalizeInboundContextMock,
|
finalizeInboundContextMock,
|
||||||
recordInboundSessionMock,
|
recordInboundSessionMock,
|
||||||
@@ -99,7 +102,7 @@ describe("Zalo polling image handling", () => {
|
|||||||
|
|
||||||
await settleAsyncWork();
|
await settleAsyncWork();
|
||||||
expect(sendMessageMock).toHaveBeenCalledTimes(1);
|
expect(sendMessageMock).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchRemoteMediaMock).not.toHaveBeenCalled();
|
expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
|
||||||
expect(saveMediaBufferMock).not.toHaveBeenCalled();
|
expect(saveMediaBufferMock).not.toHaveBeenCalled();
|
||||||
expect(finalizeInboundContextMock).not.toHaveBeenCalled();
|
expect(finalizeInboundContextMock).not.toHaveBeenCalled();
|
||||||
expect(recordInboundSessionMock).not.toHaveBeenCalled();
|
expect(recordInboundSessionMock).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -378,13 +378,7 @@ async function handleImageMessage(params: ZaloImageMessageParams): Promise<void>
|
|||||||
if (photo_url) {
|
if (photo_url) {
|
||||||
try {
|
try {
|
||||||
const maxBytes = mediaMaxMb * 1024 * 1024;
|
const maxBytes = mediaMaxMb * 1024 * 1024;
|
||||||
const fetched = await core.channel.media.fetchRemoteMedia({ url: photo_url, maxBytes });
|
const saved = await core.channel.media.saveRemoteMedia({ url: photo_url, maxBytes });
|
||||||
const saved = await core.channel.media.saveMediaBuffer(
|
|
||||||
fetched.buffer,
|
|
||||||
fetched.contentType,
|
|
||||||
"inbound",
|
|
||||||
maxBytes,
|
|
||||||
);
|
|
||||||
mediaPath = saved.path;
|
mediaPath = saved.path;
|
||||||
mediaType = saved.contentType;
|
mediaType = saved.contentType;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -549,7 +549,8 @@ describe("handleZaloWebhookRequest", () => {
|
|||||||
core,
|
core,
|
||||||
finalizeInboundContextMock,
|
finalizeInboundContextMock,
|
||||||
recordInboundSessionMock,
|
recordInboundSessionMock,
|
||||||
fetchRemoteMediaMock,
|
readRemoteMediaBufferMock,
|
||||||
|
saveRemoteMediaMock,
|
||||||
saveMediaBufferMock,
|
saveMediaBufferMock,
|
||||||
} = createImageLifecycleCore();
|
} = createImageLifecycleCore();
|
||||||
const unregister = registerTarget({
|
const unregister = registerTarget({
|
||||||
@@ -582,9 +583,11 @@ describe("handleZaloWebhookRequest", () => {
|
|||||||
unregister();
|
unregister();
|
||||||
}
|
}
|
||||||
|
|
||||||
await vi.waitFor(() => expect(fetchRemoteMediaMock).toHaveBeenCalledTimes(1));
|
await vi.waitFor(() => expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1));
|
||||||
|
expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
|
||||||
expectImageLifecycleDelivery({
|
expectImageLifecycleDelivery({
|
||||||
fetchRemoteMediaMock,
|
readRemoteMediaBufferMock,
|
||||||
|
saveRemoteMediaMock,
|
||||||
saveMediaBufferMock,
|
saveMediaBufferMock,
|
||||||
finalizeInboundContextMock,
|
finalizeInboundContextMock,
|
||||||
recordInboundSessionMock,
|
recordInboundSessionMock,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { request as httpRequest } from "node:http";
|
import { request as httpRequest } from "node:http";
|
||||||
|
import { createPluginRuntimeMediaMock } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||||
import { expect, vi } from "vitest";
|
import { expect, vi } from "vitest";
|
||||||
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
||||||
import type { ResolvedZaloAccount } from "../types.js";
|
import type { ResolvedZaloAccount } from "../types.js";
|
||||||
@@ -167,10 +168,14 @@ export function createImageLifecycleCore() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const recordInboundSessionMock = vi.fn(async () => undefined);
|
const recordInboundSessionMock = vi.fn(async () => undefined);
|
||||||
const fetchRemoteMediaMock = vi.fn(async () => ({
|
const readRemoteMediaBufferMock = vi.fn(async () => ({
|
||||||
buffer: Buffer.from("image-bytes"),
|
buffer: Buffer.from("image-bytes"),
|
||||||
contentType: "image/jpeg",
|
contentType: "image/jpeg",
|
||||||
}));
|
}));
|
||||||
|
const saveRemoteMediaMock = vi.fn(async () => ({
|
||||||
|
path: "/tmp/zalo-photo.jpg",
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
}));
|
||||||
const saveMediaBufferMock = vi.fn(async () => ({
|
const saveMediaBufferMock = vi.fn(async () => ({
|
||||||
path: "/tmp/zalo-photo.jpg",
|
path: "/tmp/zalo-photo.jpg",
|
||||||
contentType: "image/jpeg",
|
contentType: "image/jpeg",
|
||||||
@@ -212,12 +217,14 @@ export function createImageLifecycleCore() {
|
|||||||
() => "code",
|
() => "code",
|
||||||
) as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
|
) as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
|
||||||
},
|
},
|
||||||
media: {
|
media: createPluginRuntimeMediaMock({
|
||||||
fetchRemoteMedia:
|
readRemoteMediaBuffer:
|
||||||
fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
|
readRemoteMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["readRemoteMediaBuffer"],
|
||||||
|
saveRemoteMedia:
|
||||||
|
saveRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["saveRemoteMedia"],
|
||||||
saveMediaBuffer:
|
saveMediaBuffer:
|
||||||
saveMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
|
saveMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
|
||||||
},
|
}) as unknown as PluginRuntime["channel"]["media"],
|
||||||
reply: {
|
reply: {
|
||||||
finalizeInboundContext:
|
finalizeInboundContext:
|
||||||
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
||||||
@@ -341,7 +348,8 @@ export function createImageLifecycleCore() {
|
|||||||
core,
|
core,
|
||||||
finalizeInboundContextMock,
|
finalizeInboundContextMock,
|
||||||
recordInboundSessionMock,
|
recordInboundSessionMock,
|
||||||
fetchRemoteMediaMock,
|
readRemoteMediaBufferMock,
|
||||||
|
saveRemoteMediaMock,
|
||||||
saveMediaBufferMock,
|
saveMediaBufferMock,
|
||||||
readAllowFromStoreMock,
|
readAllowFromStoreMock,
|
||||||
upsertPairingRequestMock,
|
upsertPairingRequestMock,
|
||||||
@@ -349,7 +357,8 @@ export function createImageLifecycleCore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function expectImageLifecycleDelivery(params: {
|
export function expectImageLifecycleDelivery(params: {
|
||||||
fetchRemoteMediaMock: ReturnType<typeof vi.fn>;
|
readRemoteMediaBufferMock: ReturnType<typeof vi.fn>;
|
||||||
|
saveRemoteMediaMock?: ReturnType<typeof vi.fn>;
|
||||||
saveMediaBufferMock: ReturnType<typeof vi.fn>;
|
saveMediaBufferMock: ReturnType<typeof vi.fn>;
|
||||||
finalizeInboundContextMock: ReturnType<typeof vi.fn>;
|
finalizeInboundContextMock: ReturnType<typeof vi.fn>;
|
||||||
recordInboundSessionMock: ReturnType<typeof vi.fn>;
|
recordInboundSessionMock: ReturnType<typeof vi.fn>;
|
||||||
@@ -362,11 +371,12 @@ export function expectImageLifecycleDelivery(params: {
|
|||||||
const senderName = params.senderName ?? "Test User";
|
const senderName = params.senderName ?? "Test User";
|
||||||
const mediaPath = params.mediaPath ?? "/tmp/zalo-photo.jpg";
|
const mediaPath = params.mediaPath ?? "/tmp/zalo-photo.jpg";
|
||||||
const mediaType = params.mediaType ?? "image/jpeg";
|
const mediaType = params.mediaType ?? "image/jpeg";
|
||||||
expect(params.fetchRemoteMediaMock).toHaveBeenCalledWith({
|
const saveRemoteMediaMock = params.saveRemoteMediaMock ?? params.readRemoteMediaBufferMock;
|
||||||
|
expect(saveRemoteMediaMock).toHaveBeenCalledWith({
|
||||||
url: photoUrl,
|
url: photoUrl,
|
||||||
maxBytes: 5 * 1024 * 1024,
|
maxBytes: 5 * 1024 * 1024,
|
||||||
});
|
});
|
||||||
expect(params.saveMediaBufferMock).toHaveBeenCalledTimes(1);
|
expect(params.saveMediaBufferMock).not.toHaveBeenCalled();
|
||||||
expect(params.finalizeInboundContextMock).toHaveBeenCalledWith(
|
expect(params.finalizeInboundContextMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
SenderName: senderName,
|
SenderName: senderName,
|
||||||
|
|||||||
@@ -1357,6 +1357,7 @@
|
|||||||
"check:import-cycles": "node --import tsx scripts/check-import-cycles.ts",
|
"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: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: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-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: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",
|
"check:opengrep-rule-metadata": "node security/opengrep/check-rule-metadata.mjs",
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export function createChangedCheckPlan(result, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (runAll) {
|
if (runAll) {
|
||||||
|
add("media download helper guard", ["check:media-download-helpers"]);
|
||||||
add("runtime sidecar loader guard", ["check:runtime-sidecar-loaders"]);
|
add("runtime sidecar loader guard", ["check:runtime-sidecar-loaders"]);
|
||||||
addTypecheck("typecheck all", ["tsgo:all"]);
|
addTypecheck("typecheck all", ["tsgo:all"]);
|
||||||
addLint("lint", ["lint"]);
|
addLint("lint", ["lint"]);
|
||||||
@@ -200,6 +201,7 @@ export function createChangedCheckPlan(result, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lanes.core || lanes.extensions) {
|
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 sidecar loader guard", ["check:runtime-sidecar-loaders"]);
|
||||||
add("runtime import cycles", ["check:import-cycles"]);
|
add("runtime import cycles", ["check:import-cycles"]);
|
||||||
}
|
}
|
||||||
|
|||||||
65
scripts/check-media-download-helper-roundtrip.mjs
Normal file
65
scripts/check-media-download-helper-roundtrip.mjs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const WINDOW_LINES = 80;
|
||||||
|
const READ_HELPER_RE = /\b(?:readRemoteMediaBuffer|fetchRemoteMedia)\s*\(/;
|
||||||
|
const SAVE_BUFFER_RE = /(?:\.|\b)saveMediaBuffer\s*\(/;
|
||||||
|
|
||||||
|
function listTrackedExtensionSources() {
|
||||||
|
return execFileSync("git", ["ls-files", "extensions/**/*.ts"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
})
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((file) => file.includes("/src/"))
|
||||||
|
.filter((file) => !isTestOrFixture(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTestOrFixture(file) {
|
||||||
|
return (
|
||||||
|
file.endsWith(".test.ts") ||
|
||||||
|
file.endsWith(".e2e.test.ts") ||
|
||||||
|
file.endsWith(".test-harness.ts") ||
|
||||||
|
file.endsWith(".test-utils.ts") ||
|
||||||
|
file.endsWith("/test-runtime.ts") ||
|
||||||
|
file.endsWith("/test-helpers.ts") ||
|
||||||
|
file.includes("/test-support/") ||
|
||||||
|
file.includes("/fixtures/")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const findings = [];
|
||||||
|
|
||||||
|
for (const file of listTrackedExtensionSources()) {
|
||||||
|
const lines = readFileSync(file, "utf8").split("\n");
|
||||||
|
for (let index = 0; index < lines.length; index += 1) {
|
||||||
|
if (!READ_HELPER_RE.test(lines[index])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const end = Math.min(lines.length, index + WINDOW_LINES);
|
||||||
|
for (let nextIndex = index; nextIndex < end; nextIndex += 1) {
|
||||||
|
if (!SAVE_BUFFER_RE.test(lines[nextIndex])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
findings.push({
|
||||||
|
file,
|
||||||
|
line: index + 1,
|
||||||
|
saveLine: nextIndex + 1,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (findings.length > 0) {
|
||||||
|
console.error("Avoid remote-media buffer/store round trips in plugin production code.");
|
||||||
|
console.error(
|
||||||
|
"Use saveRemoteMedia(...) for URL-to-store or saveResponseMedia(...) for fetched Response objects.",
|
||||||
|
);
|
||||||
|
for (const finding of findings) {
|
||||||
|
console.error(`- ${finding.file}:${finding.line} -> saveMediaBuffer at ${finding.saveLine}`);
|
||||||
|
}
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|||||||
name: "deprecated channel access seams",
|
name: "deprecated channel access seams",
|
||||||
args: ["lint:extensions:no-deprecated-channel-access"],
|
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: "runtime sidecar loader guard", args: ["check:runtime-sidecar-loaders"] },
|
||||||
{ name: "tool display", args: ["tool-display:check"] },
|
{ name: "tool display", args: ["tool-display:check"] },
|
||||||
{ name: "host env policy", args: ["check:host-env-policy:swift"] },
|
{ name: "host env policy", args: ["check:host-env-policy:swift"] },
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const hasAvailableAuthForProviderMock = vi.hoisted(() =>
|
|||||||
const getApiKeyForModelMock = vi.hoisted(() =>
|
const getApiKeyForModelMock = vi.hoisted(() =>
|
||||||
vi.fn(async () => ({ apiKey: "test-key", source: "test", mode: "api-key" })),
|
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 runExecMock = vi.hoisted(() => vi.fn());
|
||||||
const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn());
|
const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn());
|
||||||
const mockDeliverOutboundPayloads = vi.hoisted(() => vi.fn());
|
const mockDeliverOutboundPayloads = vi.hoisted(() => vi.fn());
|
||||||
@@ -177,7 +177,7 @@ describe("applyMediaUnderstanding – echo transcript", () => {
|
|||||||
resolveAuthProfileOrder: vi.fn(() => []),
|
resolveAuthProfileOrder: vi.fn(() => []),
|
||||||
}));
|
}));
|
||||||
vi.doMock("../media/fetch.js", () => ({
|
vi.doMock("../media/fetch.js", () => ({
|
||||||
fetchRemoteMedia: fetchRemoteMediaMock,
|
readRemoteMediaBuffer: readRemoteMediaBufferMock,
|
||||||
MediaFetchError: MediaFetchErrorMock,
|
MediaFetchError: MediaFetchErrorMock,
|
||||||
}));
|
}));
|
||||||
vi.doMock("../process/exec.js", () => ({
|
vi.doMock("../process/exec.js", () => ({
|
||||||
@@ -232,7 +232,7 @@ describe("applyMediaUnderstanding – echo transcript", () => {
|
|||||||
resolveApiKeyForProviderMock.mockClear();
|
resolveApiKeyForProviderMock.mockClear();
|
||||||
hasAvailableAuthForProviderMock.mockClear();
|
hasAvailableAuthForProviderMock.mockClear();
|
||||||
getApiKeyForModelMock.mockClear();
|
getApiKeyForModelMock.mockClear();
|
||||||
fetchRemoteMediaMock.mockClear();
|
readRemoteMediaBufferMock.mockClear();
|
||||||
runExecMock.mockReset();
|
runExecMock.mockReset();
|
||||||
runCommandWithTimeoutMock.mockReset();
|
runCommandWithTimeoutMock.mockReset();
|
||||||
mockDeliverOutboundPayloads.mockClear();
|
mockDeliverOutboundPayloads.mockClear();
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ const hasAvailableAuthForProviderMock = vi.hoisted(() =>
|
|||||||
return Boolean(resolved?.apiKey);
|
return Boolean(resolved?.apiKey);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const fetchRemoteMediaMock = vi.hoisted(() => vi.fn());
|
const readRemoteMediaBufferMock = vi.hoisted(() => vi.fn());
|
||||||
const runFfmpegMock = vi.hoisted(() => vi.fn());
|
const runFfmpegMock = vi.hoisted(() => vi.fn());
|
||||||
const runExecMock = vi.hoisted(() => vi.fn());
|
const runExecMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
let applyMediaUnderstanding: typeof import("./apply.js").applyMediaUnderstanding;
|
let applyMediaUnderstanding: typeof import("./apply.js").applyMediaUnderstanding;
|
||||||
let clearMediaUnderstandingBinaryCacheForTests: typeof import("./runner.js").clearMediaUnderstandingBinaryCacheForTests;
|
let clearMediaUnderstandingBinaryCacheForTests: typeof import("./runner.js").clearMediaUnderstandingBinaryCacheForTests;
|
||||||
const mockedResolveApiKey = resolveApiKeyForProviderMock;
|
const mockedResolveApiKey = resolveApiKeyForProviderMock;
|
||||||
const mockedFetchRemoteMedia = fetchRemoteMediaMock;
|
const mockedReadRemoteMediaBuffer = readRemoteMediaBufferMock;
|
||||||
const mockedRunFfmpeg = runFfmpegMock;
|
const mockedRunFfmpeg = runFfmpegMock;
|
||||||
const mockedRunExec = runExecMock;
|
const mockedRunExec = runExecMock;
|
||||||
|
|
||||||
@@ -279,7 +279,7 @@ describe("applyMediaUnderstanding", () => {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.doMock("../media/fetch.js", () => ({
|
vi.doMock("../media/fetch.js", () => ({
|
||||||
fetchRemoteMedia: fetchRemoteMediaMock,
|
readRemoteMediaBuffer: readRemoteMediaBufferMock,
|
||||||
}));
|
}));
|
||||||
vi.doMock("../media/ffmpeg-exec.js", () => ({
|
vi.doMock("../media/ffmpeg-exec.js", () => ({
|
||||||
runFfmpeg: runFfmpegMock,
|
runFfmpeg: runFfmpegMock,
|
||||||
@@ -333,10 +333,10 @@ describe("applyMediaUnderstanding", () => {
|
|||||||
mode: "api-key",
|
mode: "api-key",
|
||||||
});
|
});
|
||||||
hasAvailableAuthForProviderMock.mockClear();
|
hasAvailableAuthForProviderMock.mockClear();
|
||||||
mockedFetchRemoteMedia.mockClear();
|
mockedReadRemoteMediaBuffer.mockClear();
|
||||||
mockedRunFfmpeg.mockReset();
|
mockedRunFfmpeg.mockReset();
|
||||||
mockedRunExec.mockReset();
|
mockedRunExec.mockReset();
|
||||||
mockedFetchRemoteMedia.mockResolvedValue({
|
mockedReadRemoteMediaBuffer.mockResolvedValue({
|
||||||
buffer: createSafeAudioFixtureBuffer(2048),
|
buffer: createSafeAudioFixtureBuffer(2048),
|
||||||
contentType: "audio/ogg",
|
contentType: "audio/ogg",
|
||||||
fileName: "note.ogg",
|
fileName: "note.ogg",
|
||||||
@@ -484,7 +484,7 @@ describe("applyMediaUnderstanding", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("injects a placeholder transcript when URL-only audio is too small", async () => {
|
it("injects a placeholder transcript when URL-only audio is too small", async () => {
|
||||||
mockedFetchRemoteMedia.mockResolvedValueOnce({
|
mockedReadRemoteMediaBuffer.mockResolvedValueOnce({
|
||||||
buffer: Buffer.alloc(100),
|
buffer: Buffer.alloc(100),
|
||||||
contentType: "audio/ogg",
|
contentType: "audio/ogg",
|
||||||
fileName: "tiny.ogg",
|
fileName: "tiny.ogg",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { logVerbose, shouldLogVerbose } from "../globals.js";
|
|||||||
import { FsSafeError, openLocalFileSafely } from "../infra/fs-safe.js";
|
import { FsSafeError, openLocalFileSafely } from "../infra/fs-safe.js";
|
||||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||||
import { isAbortError } from "../infra/unhandled-rejections.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 { isInboundPathAllowed, mergeInboundPathRoots } from "../media/inbound-path-policy.js";
|
||||||
import { getDefaultMediaLocalRoots } from "../media/local-roots.js";
|
import { getDefaultMediaLocalRoots } from "../media/local-roots.js";
|
||||||
import { detectMime } from "../media/mime.js";
|
import { detectMime } from "../media/mime.js";
|
||||||
@@ -162,7 +162,7 @@ export class MediaAttachmentCache {
|
|||||||
try {
|
try {
|
||||||
const fetchImpl = (input: RequestInfo | URL, init?: RequestInit) =>
|
const fetchImpl = (input: RequestInfo | URL, init?: RequestInit) =>
|
||||||
fetchWithTimeout(resolveRequestUrl(input), init ?? {}, params.timeoutMs, globalThis.fetch);
|
fetchWithTimeout(resolveRequestUrl(input), init ?? {}, params.timeoutMs, globalThis.fetch);
|
||||||
const fetched = await fetchRemoteMedia({
|
const fetched = await readRemoteMediaBuffer({
|
||||||
url,
|
url,
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
|
|||||||
@@ -5,29 +5,29 @@ import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
|||||||
import { withTempDir } from "../test-helpers/temp-dir.js";
|
import { withTempDir } from "../test-helpers/temp-dir.js";
|
||||||
import { MediaAttachmentCache } from "./attachments.js";
|
import { MediaAttachmentCache } from "./attachments.js";
|
||||||
|
|
||||||
const fetchRemoteMediaMock = vi.hoisted(() => vi.fn());
|
const readRemoteMediaBufferMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("../media/fetch.js", async () => {
|
vi.mock("../media/fetch.js", async () => {
|
||||||
const actual = await vi.importActual<typeof import("../media/fetch.js")>("../media/fetch.js");
|
const actual = await vi.importActual<typeof import("../media/fetch.js")>("../media/fetch.js");
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
fetchRemoteMedia: fetchRemoteMediaMock,
|
readRemoteMediaBuffer: readRemoteMediaBufferMock,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function requireFetchRemoteMediaInput(): {
|
function requireReadRemoteMediaBufferInput(): {
|
||||||
url?: unknown;
|
url?: unknown;
|
||||||
fetchImpl?: unknown;
|
fetchImpl?: unknown;
|
||||||
maxBytes?: unknown;
|
maxBytes?: unknown;
|
||||||
ssrfPolicy?: unknown;
|
ssrfPolicy?: unknown;
|
||||||
} {
|
} {
|
||||||
const [call] = fetchRemoteMediaMock.mock.calls;
|
const [call] = readRemoteMediaBufferMock.mock.calls;
|
||||||
if (!call) {
|
if (!call) {
|
||||||
throw new Error("expected fetchRemoteMedia call");
|
throw new Error("expected readRemoteMediaBuffer call");
|
||||||
}
|
}
|
||||||
const [input] = call;
|
const [input] = call;
|
||||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
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;
|
return input;
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ async function withBlockedLocalAttachmentFallback(
|
|||||||
localPathRoots: [allowedRoot],
|
localPathRoots: [allowedRoot],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
fetchRemoteMediaMock.mockResolvedValue({
|
readRemoteMediaBufferMock.mockResolvedValue({
|
||||||
buffer: Buffer.from("fallback-buffer"),
|
buffer: Buffer.from("fallback-buffer"),
|
||||||
contentType: "image/jpeg",
|
contentType: "image/jpeg",
|
||||||
fileName: "fallback.jpg",
|
fileName: "fallback.jpg",
|
||||||
@@ -64,7 +64,7 @@ async function withBlockedLocalAttachmentFallback(
|
|||||||
describe("media understanding attachment URL fallback", () => {
|
describe("media understanding attachment URL fallback", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
fetchRemoteMediaMock.mockReset();
|
readRemoteMediaBufferMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("getPath falls back to URL fetch when local path is blocked", async () => {
|
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.dirname(result.path)).toBe(resolvePreferredOpenClawTmpDir());
|
||||||
expect(path.basename(result.path).startsWith("openclaw-media-")).toBe(true);
|
expect(path.basename(result.path).startsWith("openclaw-media-")).toBe(true);
|
||||||
expect(path.extname(result.path)).toBe(".jpg");
|
expect(path.extname(result.path)).toBe(".jpg");
|
||||||
expect(fetchRemoteMediaMock).toHaveBeenCalledTimes(1);
|
expect(readRemoteMediaBufferMock).toHaveBeenCalledTimes(1);
|
||||||
const fetchInput = requireFetchRemoteMediaInput();
|
const fetchInput = requireReadRemoteMediaBufferInput();
|
||||||
const fetchImpl = fetchInput.fetchImpl;
|
const fetchImpl = fetchInput.fetchImpl;
|
||||||
expect(fetchInput).toStrictEqual({
|
expect(fetchInput).toStrictEqual({
|
||||||
url: fallbackUrl,
|
url: fallbackUrl,
|
||||||
@@ -109,8 +109,8 @@ describe("media understanding attachment URL fallback", () => {
|
|||||||
timeoutMs: 1000,
|
timeoutMs: 1000,
|
||||||
});
|
});
|
||||||
expect(result.buffer.toString()).toBe("fallback-buffer");
|
expect(result.buffer.toString()).toBe("fallback-buffer");
|
||||||
expect(fetchRemoteMediaMock).toHaveBeenCalledTimes(1);
|
expect(readRemoteMediaBufferMock).toHaveBeenCalledTimes(1);
|
||||||
const fetchInput = requireFetchRemoteMediaInput();
|
const fetchInput = requireReadRemoteMediaBufferInput();
|
||||||
const fetchImpl = fetchInput.fetchImpl;
|
const fetchImpl = fetchInput.fetchImpl;
|
||||||
expect(fetchInput).toStrictEqual({
|
expect(fetchInput).toStrictEqual({
|
||||||
url: fallbackUrl,
|
url: fallbackUrl,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import fs from "node:fs/promises";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js";
|
||||||
|
|
||||||
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
@@ -12,10 +14,13 @@ vi.mock("../infra/net/fetch-guard.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
type FetchModule = typeof import("./fetch.js");
|
type FetchModule = typeof import("./fetch.js");
|
||||||
type FetchRemoteMedia = FetchModule["fetchRemoteMedia"];
|
type ReadRemoteMediaBuffer = FetchModule["readRemoteMediaBuffer"];
|
||||||
type LookupFn = NonNullable<Parameters<FetchRemoteMedia>[0]["lookupFn"]>;
|
type SaveRemoteMedia = FetchModule["saveRemoteMedia"];
|
||||||
let fetchRemoteMedia: FetchRemoteMedia;
|
type LookupFn = NonNullable<Parameters<ReadRemoteMediaBuffer>[0]["lookupFn"]>;
|
||||||
|
let readRemoteMediaBuffer: ReadRemoteMediaBuffer;
|
||||||
|
let saveRemoteMedia: SaveRemoteMedia;
|
||||||
let defaultFetchMediaMaxBytes: number;
|
let defaultFetchMediaMaxBytes: number;
|
||||||
|
let tempHome: TempHomeEnv;
|
||||||
|
|
||||||
function makeStream(chunks: Uint8Array[]) {
|
function makeStream(chunks: Uint8Array[]) {
|
||||||
return new ReadableStream<Uint8Array>({
|
return new ReadableStream<Uint8Array>({
|
||||||
@@ -54,11 +59,11 @@ function requireFetchGuardRequest(): unknown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function expectRemoteMediaMaxBytesError(params: {
|
async function expectRemoteMediaMaxBytesError(params: {
|
||||||
fetchImpl: Parameters<typeof fetchRemoteMedia>[0]["fetchImpl"];
|
fetchImpl: Parameters<typeof readRemoteMediaBuffer>[0]["fetchImpl"];
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
}) {
|
}) {
|
||||||
await expect(
|
await expect(
|
||||||
fetchRemoteMedia({
|
readRemoteMediaBuffer({
|
||||||
url: "https://example.com/file.bin",
|
url: "https://example.com/file.bin",
|
||||||
fetchImpl: params.fetchImpl,
|
fetchImpl: params.fetchImpl,
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
@@ -71,9 +76,9 @@ async function expectRedactedBotTokenFetchError(params: {
|
|||||||
botFileUrl: string;
|
botFileUrl: string;
|
||||||
botToken: string;
|
botToken: string;
|
||||||
expectedErrorText: string;
|
expectedErrorText: string;
|
||||||
fetchImpl: Parameters<typeof fetchRemoteMedia>[0]["fetchImpl"];
|
fetchImpl: Parameters<typeof readRemoteMediaBuffer>[0]["fetchImpl"];
|
||||||
}) {
|
}) {
|
||||||
const error = await fetchRemoteMedia({
|
const error = await readRemoteMediaBuffer({
|
||||||
url: params.botFileUrl,
|
url: params.botFileUrl,
|
||||||
fetchImpl: params.fetchImpl,
|
fetchImpl: params.fetchImpl,
|
||||||
lookupFn: makeLookupFn(),
|
lookupFn: makeLookupFn(),
|
||||||
@@ -90,9 +95,9 @@ async function expectRedactedBotTokenFetchError(params: {
|
|||||||
expect(errorText).toBe(params.expectedErrorText);
|
expect(errorText).toBe(params.expectedErrorText);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectFetchRemoteMediaRejected(params: {
|
async function expectReadRemoteMediaBufferRejected(params: {
|
||||||
url: string;
|
url: string;
|
||||||
fetchImpl: Parameters<typeof fetchRemoteMedia>[0]["fetchImpl"];
|
fetchImpl: Parameters<typeof readRemoteMediaBuffer>[0]["fetchImpl"];
|
||||||
maxBytes?: number;
|
maxBytes?: number;
|
||||||
readIdleTimeoutMs?: number;
|
readIdleTimeoutMs?: number;
|
||||||
lookupFn?: LookupFn;
|
lookupFn?: LookupFn;
|
||||||
@@ -106,12 +111,12 @@ async function expectFetchRemoteMediaRejected(params: {
|
|||||||
...(params.readIdleTimeoutMs ? { readIdleTimeoutMs: params.readIdleTimeoutMs } : {}),
|
...(params.readIdleTimeoutMs ? { readIdleTimeoutMs: params.readIdleTimeoutMs } : {}),
|
||||||
};
|
};
|
||||||
if (params.expectedError instanceof RegExp || typeof params.expectedError === "string") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let fetchError: unknown;
|
let fetchError: unknown;
|
||||||
try {
|
try {
|
||||||
await fetchRemoteMedia(request);
|
await readRemoteMediaBuffer(request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fetchError = error;
|
fetchError = error;
|
||||||
}
|
}
|
||||||
@@ -121,26 +126,26 @@ async function expectFetchRemoteMediaRejected(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectFetchRemoteMediaResolvesToError(
|
async function expectReadRemoteMediaBufferResolvesToError(
|
||||||
params: Parameters<typeof fetchRemoteMedia>[0],
|
params: Parameters<typeof readRemoteMediaBuffer>[0],
|
||||||
): Promise<Error> {
|
): Promise<Error> {
|
||||||
const result = await fetchRemoteMedia(params).catch((err: unknown) => err);
|
const result = await readRemoteMediaBuffer(params).catch((err: unknown) => err);
|
||||||
expect(result).toBeInstanceOf(Error);
|
expect(result).toBeInstanceOf(Error);
|
||||||
if (!(result instanceof Error)) {
|
if (!(result instanceof Error)) {
|
||||||
expect.unreachable("expected fetchRemoteMedia to reject");
|
expect.unreachable("expected readRemoteMediaBuffer to reject");
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectFetchRemoteMediaIdleTimeoutCase(params: {
|
async function expectReadRemoteMediaBufferIdleTimeoutCase(params: {
|
||||||
lookupFn: LookupFn;
|
lookupFn: LookupFn;
|
||||||
fetchImpl: Parameters<typeof fetchRemoteMedia>[0]["fetchImpl"];
|
fetchImpl: Parameters<typeof readRemoteMediaBuffer>[0]["fetchImpl"];
|
||||||
readIdleTimeoutMs: number;
|
readIdleTimeoutMs: number;
|
||||||
expectedError: Record<string, unknown>;
|
expectedError: Record<string, unknown>;
|
||||||
}) {
|
}) {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
const rejection = expectFetchRemoteMediaRejected({
|
const rejection = expectReadRemoteMediaBufferRejected({
|
||||||
url: "https://example.com/file.bin",
|
url: "https://example.com/file.bin",
|
||||||
fetchImpl: params.fetchImpl,
|
fetchImpl: params.fetchImpl,
|
||||||
lookupFn: params.lookupFn,
|
lookupFn: params.lookupFn,
|
||||||
@@ -156,10 +161,10 @@ async function expectFetchRemoteMediaIdleTimeoutCase(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function expectBoundedErrorBodyCase(
|
async function expectBoundedErrorBodyCase(
|
||||||
fetchImpl: Parameters<typeof fetchRemoteMedia>[0]["fetchImpl"],
|
fetchImpl: Parameters<typeof readRemoteMediaBuffer>[0]["fetchImpl"],
|
||||||
) {
|
) {
|
||||||
const result = await expectFetchRemoteMediaResolvesToError(
|
const result = await expectReadRemoteMediaBufferResolvesToError(
|
||||||
createFetchRemoteMediaParams({
|
createReadRemoteMediaBufferParams({
|
||||||
url: "https://example.com/file.bin",
|
url: "https://example.com/file.bin",
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
}),
|
}),
|
||||||
@@ -170,7 +175,7 @@ async function expectBoundedErrorBodyCase(
|
|||||||
|
|
||||||
async function expectPrivateIpFetchBlockedCase() {
|
async function expectPrivateIpFetchBlockedCase() {
|
||||||
const fetchImpl = vi.fn();
|
const fetchImpl = vi.fn();
|
||||||
await expectFetchRemoteMediaRejected({
|
await expectReadRemoteMediaBufferRejected({
|
||||||
url: "http://127.0.0.1/secret.jpg",
|
url: "http://127.0.0.1/secret.jpg",
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
expectedError: /private|internal|blocked/i,
|
expectedError: /private|internal|blocked/i,
|
||||||
@@ -178,8 +183,8 @@ async function expectPrivateIpFetchBlockedCase() {
|
|||||||
expect(fetchImpl).not.toHaveBeenCalled();
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFetchRemoteMediaParams(
|
function createReadRemoteMediaBufferParams(
|
||||||
params: Omit<Parameters<typeof fetchRemoteMedia>[0], "lookupFn"> & { lookupFn?: LookupFn },
|
params: Omit<Parameters<typeof readRemoteMediaBuffer>[0], "lookupFn"> & { lookupFn?: LookupFn },
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
lookupFn: params.lookupFn ?? makeLookupFn(),
|
lookupFn: params.lookupFn ?? makeLookupFn(),
|
||||||
@@ -188,14 +193,16 @@ function createFetchRemoteMediaParams(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("fetchRemoteMedia", () => {
|
describe("readRemoteMediaBuffer", () => {
|
||||||
const botToken = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcd";
|
const botToken = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcd";
|
||||||
const redactedBotToken = `${botToken.slice(0, 6)}…${botToken.slice(-4)}`;
|
const redactedBotToken = `${botToken.slice(0, 6)}…${botToken.slice(-4)}`;
|
||||||
const botFileUrl = `https://files.example.test/file/bot${botToken}/photos/1.jpg`;
|
const botFileUrl = `https://files.example.test/file/bot${botToken}/photos/1.jpg`;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
tempHome = await createTempHomeEnv("openclaw-test-home-");
|
||||||
const fetchModule = await import("./fetch.js");
|
const fetchModule = await import("./fetch.js");
|
||||||
fetchRemoteMedia = fetchModule.fetchRemoteMedia;
|
readRemoteMediaBuffer = fetchModule.readRemoteMediaBuffer;
|
||||||
|
saveRemoteMedia = fetchModule.saveRemoteMedia;
|
||||||
defaultFetchMediaMaxBytes = fetchModule.DEFAULT_FETCH_MEDIA_MAX_BYTES;
|
defaultFetchMediaMaxBytes = fetchModule.DEFAULT_FETCH_MEDIA_MAX_BYTES;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,6 +229,10 @@ describe("fetchRemoteMedia", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await tempHome.restore();
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{
|
{
|
||||||
name: "rejects when content-length exceeds maxBytes",
|
name: "rejects when content-length exceeds maxBytes",
|
||||||
@@ -252,7 +263,7 @@ describe("fetchRemoteMedia", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
fetchRemoteMedia({
|
readRemoteMediaBuffer({
|
||||||
url: "https://example.com/file.bin",
|
url: "https://example.com/file.bin",
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
lookupFn: makeLookupFn(),
|
lookupFn: makeLookupFn(),
|
||||||
@@ -297,7 +308,7 @@ describe("fetchRemoteMedia", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
] as const)("$name", async ({ lookupFn, fetchImpl, readIdleTimeoutMs, expectedError }) => {
|
] as const)("$name", async ({ lookupFn, fetchImpl, readIdleTimeoutMs, expectedError }) => {
|
||||||
await expectFetchRemoteMediaIdleTimeoutCase({
|
await expectReadRemoteMediaBufferIdleTimeoutCase({
|
||||||
lookupFn,
|
lookupFn,
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
readIdleTimeoutMs,
|
readIdleTimeoutMs,
|
||||||
@@ -339,7 +350,7 @@ describe("fetchRemoteMedia", () => {
|
|||||||
allowPrivateProxy: true,
|
allowPrivateProxy: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
await fetchRemoteMedia({
|
await readRemoteMediaBuffer({
|
||||||
url: "https://files.example.test/file/bot123/photos/test.jpg",
|
url: "https://files.example.test/file/bot123/photos/test.jpg",
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
lookupFn,
|
lookupFn,
|
||||||
@@ -363,4 +374,227 @@ describe("fetchRemoteMedia", () => {
|
|||||||
mode: "trusted_explicit_proxy",
|
mode: "trusted_explicit_proxy",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("streams successful responses directly into the media store", async () => {
|
||||||
|
const fetchImpl = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(makeStream([new Uint8Array([1, 2, 3]), new Uint8Array([4])]), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-disposition": 'attachment; filename="photo"',
|
||||||
|
"content-type": "image/png",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const saved = await saveRemoteMedia({
|
||||||
|
url: "https://example.com/download",
|
||||||
|
fetchImpl,
|
||||||
|
lookupFn: makeLookupFn(),
|
||||||
|
maxBytes: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saved.fileName).toBe("photo");
|
||||||
|
expect(saved.contentType).toBe("image/png");
|
||||||
|
expect(saved.path).toMatch(/[a-f0-9-]{36}\.png$/);
|
||||||
|
expect(saved.path).not.toMatch(/photo---/);
|
||||||
|
await expect(fs.readFile(saved.path)).resolves.toStrictEqual(Buffer.from([1, 2, 3, 4]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses caller filename hints for MIME detection without preserving storage basenames", async () => {
|
||||||
|
const contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
const fetchImpl = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(makeStream([new Uint8Array([1, 2, 3])]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/octet-stream" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const saved = await saveRemoteMedia({
|
||||||
|
url: "https://smba.trafficmanager.net/v3/attachments/att-1/views/original",
|
||||||
|
fetchImpl,
|
||||||
|
lookupFn: makeLookupFn(),
|
||||||
|
filePathHint: "document.docx",
|
||||||
|
maxBytes: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saved.fileName).toBe("document.docx");
|
||||||
|
expect(saved.contentType).toBe(contentType);
|
||||||
|
expect(saved.path).toMatch(/[a-f0-9-]{36}\.docx$/);
|
||||||
|
expect(saved.path).not.toMatch(/document---/);
|
||||||
|
await expect(fs.readFile(saved.path)).resolves.toStrictEqual(Buffer.from([1, 2, 3]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let filename hints force stored extensions before byte sniffing", async () => {
|
||||||
|
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
|
||||||
|
const fetchImpl = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(makeStream([jpeg]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/octet-stream" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const saved = await saveRemoteMedia({
|
||||||
|
url: "https://example.com/views/original",
|
||||||
|
fetchImpl,
|
||||||
|
lookupFn: makeLookupFn(),
|
||||||
|
filePathHint: "document.docx",
|
||||||
|
maxBytes: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saved.fileName).toBe("document.docx");
|
||||||
|
expect(saved.contentType).toBe("image/jpeg");
|
||||||
|
expect(saved.path).toMatch(/[a-f0-9-]{36}\.jpg$/);
|
||||||
|
expect(saved.path).not.toMatch(/\.docx$/);
|
||||||
|
expect(saved.path).not.toMatch(/document---/);
|
||||||
|
await expect(fs.readFile(saved.path)).resolves.toStrictEqual(jpeg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves explicit original filenames when saving streams", async () => {
|
||||||
|
const contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
const fetchImpl = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(makeStream([new Uint8Array([1, 2, 3])]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/octet-stream" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const saved = await saveRemoteMedia({
|
||||||
|
url: "https://smba.trafficmanager.net/v3/attachments/att-1/views/original",
|
||||||
|
fetchImpl,
|
||||||
|
lookupFn: makeLookupFn(),
|
||||||
|
filePathHint: "document.docx",
|
||||||
|
fallbackContentType: contentType,
|
||||||
|
originalFilename: "document.docx",
|
||||||
|
maxBytes: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saved.fileName).toBe("document.docx");
|
||||||
|
expect(saved.contentType).toBe(contentType);
|
||||||
|
expect(saved.path).toMatch(/document---.+\.docx$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses fallback content type when streamed response headers are generic", async () => {
|
||||||
|
const contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
const fetchImpl = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(makeStream([new Uint8Array([4, 5, 6])]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/octet-stream" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const saved = await saveRemoteMedia({
|
||||||
|
url: "https://example.com/views/original",
|
||||||
|
fetchImpl,
|
||||||
|
lookupFn: makeLookupFn(),
|
||||||
|
filePathHint: "document",
|
||||||
|
fallbackContentType: contentType,
|
||||||
|
maxBytes: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saved.fileName).toBe("document");
|
||||||
|
expect(saved.contentType).toBe(contentType);
|
||||||
|
expect(saved.path).toMatch(/[a-f0-9-]{36}\.docx$/);
|
||||||
|
expect(saved.path).not.toMatch(/document---/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses audio fallback content type when streamed response headers report matching video container", async () => {
|
||||||
|
const fetchImpl = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(makeStream([new Uint8Array([7, 8, 9])]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "video/mp4" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const saved = await saveRemoteMedia({
|
||||||
|
url: "https://example.com/voice.mp4",
|
||||||
|
fetchImpl,
|
||||||
|
lookupFn: makeLookupFn(),
|
||||||
|
filePathHint: "voice.mp4",
|
||||||
|
fallbackContentType: "audio/mp4",
|
||||||
|
maxBytes: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saved.contentType).toBe("audio/mp4");
|
||||||
|
expect(saved.path).toMatch(/[a-f0-9-]{36}\.m4a$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels streamed response bodies when media save exceeds maxBytes", async () => {
|
||||||
|
const cancel = vi.fn();
|
||||||
|
const fetchImpl = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||||
|
controller.enqueue(new Uint8Array([4, 5, 6]));
|
||||||
|
},
|
||||||
|
cancel,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
saveRemoteMedia({
|
||||||
|
url: "https://example.com/large.bin",
|
||||||
|
fetchImpl,
|
||||||
|
lookupFn: makeLookupFn(),
|
||||||
|
maxBytes: 4,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("exceeds maxBytes");
|
||||||
|
expect(cancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries saveRemoteMedia after a transient fetch failure", async () => {
|
||||||
|
const fetchImpl = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new TypeError("socket reset"))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(makeStream([new Uint8Array([5, 6])]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "image/png" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const onRetry = vi.fn();
|
||||||
|
|
||||||
|
const saved = await saveRemoteMedia({
|
||||||
|
url: "https://example.com/retry.png",
|
||||||
|
fetchImpl,
|
||||||
|
lookupFn: makeLookupFn(),
|
||||||
|
maxBytes: 8,
|
||||||
|
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0, onRetry },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||||
|
expect(saved.contentType).toBe("image/png");
|
||||||
|
await expect(fs.readFile(saved.path)).resolves.toStrictEqual(Buffer.from([5, 6]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry permanent media limit failures", async () => {
|
||||||
|
const fetchImpl = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(makeStream([new Uint8Array([1, 2, 3, 4, 5])]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-length": "5" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
saveRemoteMedia({
|
||||||
|
url: "https://example.com/too-large.bin",
|
||||||
|
fetchImpl,
|
||||||
|
lookupFn: makeLookupFn(),
|
||||||
|
maxBytes: 4,
|
||||||
|
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("exceeds maxBytes");
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import {
|
|||||||
withTrustedExplicitProxyGuardedFetchMode,
|
withTrustedExplicitProxyGuardedFetchMode,
|
||||||
} from "../infra/net/fetch-guard.js";
|
} from "../infra/net/fetch-guard.js";
|
||||||
import type { LookupFn, PinnedDispatcherPolicy, SsrFPolicy } from "../infra/net/ssrf.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 { redactSensitiveText } from "../logging/redact.js";
|
||||||
import { MAX_DOCUMENT_BYTES } from "./constants.js";
|
import { MAX_DOCUMENT_BYTES } from "./constants.js";
|
||||||
import { detectMime, extensionForMime } from "./mime.js";
|
import { detectMime, extensionForMime } from "./mime.js";
|
||||||
import { readResponseTextSnippet, readResponseWithLimit } from "./read-response-with-limit.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;
|
export const DEFAULT_FETCH_MEDIA_MAX_BYTES = MAX_DOCUMENT_BYTES;
|
||||||
|
|
||||||
@@ -19,14 +21,26 @@ type FetchMediaResult = {
|
|||||||
fileName?: string;
|
fileName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SavedRemoteMedia = SavedMedia & {
|
||||||
|
fileName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed";
|
export type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed";
|
||||||
|
|
||||||
|
export type MediaFetchRetryOptions = RetryOptions;
|
||||||
|
|
||||||
export class MediaFetchError extends Error {
|
export class MediaFetchError extends Error {
|
||||||
readonly code: MediaFetchErrorCode;
|
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);
|
super(message, options);
|
||||||
this.code = code;
|
this.code = code;
|
||||||
|
this.status = options?.status;
|
||||||
this.name = "MediaFetchError";
|
this.name = "MediaFetchError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,6 +66,11 @@ type FetchMediaOptions = {
|
|||||||
dispatcherPolicy?: PinnedDispatcherPolicy;
|
dispatcherPolicy?: PinnedDispatcherPolicy;
|
||||||
dispatcherAttempts?: FetchDispatcherAttempt[];
|
dispatcherAttempts?: FetchDispatcherAttempt[];
|
||||||
shouldRetryFetchError?: (error: unknown) => boolean;
|
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
|
* Allow an operator-configured explicit proxy to resolve target DNS after
|
||||||
* hostname-policy checks instead of forcing local pinned-DNS first.
|
* hostname-policy checks instead of forcing local pinned-DNS first.
|
||||||
@@ -59,6 +78,29 @@ type FetchMediaOptions = {
|
|||||||
trustExplicitProxyDns?: boolean;
|
trustExplicitProxyDns?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SaveResponseMediaOptions = {
|
||||||
|
sourceUrl?: string;
|
||||||
|
filePathHint?: string;
|
||||||
|
maxBytes?: number;
|
||||||
|
readIdleTimeoutMs?: number;
|
||||||
|
fallbackContentType?: string;
|
||||||
|
subdir?: string;
|
||||||
|
originalFilename?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SaveRemoteMediaOptions = FetchMediaOptions & {
|
||||||
|
fallbackContentType?: string;
|
||||||
|
subdir?: string;
|
||||||
|
originalFilename?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GuardedMediaResponse = {
|
||||||
|
response: Response;
|
||||||
|
finalUrl: string;
|
||||||
|
release: (() => Promise<void>) | null;
|
||||||
|
sourceUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
function stripQuotes(value: string): string {
|
function stripQuotes(value: string): string {
|
||||||
return value.replace(/^["']|["']$/g, "");
|
return value.replace(/^["']|["']$/g, "");
|
||||||
}
|
}
|
||||||
@@ -106,15 +148,14 @@ function redactMediaUrl(url: string): string {
|
|||||||
return redactSensitiveText(url);
|
return redactSensitiveText(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<FetchMediaResult> {
|
async function fetchGuardedMediaResponse(
|
||||||
|
options: FetchMediaOptions,
|
||||||
|
): Promise<GuardedMediaResponse> {
|
||||||
const {
|
const {
|
||||||
url,
|
url,
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
requestInit,
|
requestInit,
|
||||||
filePathHint,
|
|
||||||
maxBytes,
|
|
||||||
maxRedirects,
|
maxRedirects,
|
||||||
readIdleTimeoutMs,
|
|
||||||
ssrfPolicy,
|
ssrfPolicy,
|
||||||
lookupFn,
|
lookupFn,
|
||||||
dispatcherPolicy,
|
dispatcherPolicy,
|
||||||
@@ -124,9 +165,6 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
|
|||||||
} = options;
|
} = options;
|
||||||
const sourceUrl = redactMediaUrl(url);
|
const sourceUrl = redactMediaUrl(url);
|
||||||
|
|
||||||
let res: Response;
|
|
||||||
let finalUrl = url;
|
|
||||||
let release: (() => Promise<void>) | null = null;
|
|
||||||
const attempts =
|
const attempts =
|
||||||
dispatcherAttempts && dispatcherAttempts.length > 0
|
dispatcherAttempts && dispatcherAttempts.length > 0
|
||||||
? dispatcherAttempts
|
? dispatcherAttempts
|
||||||
@@ -180,9 +218,12 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
|
|||||||
attemptErrors.push(err);
|
attemptErrors.push(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
res = result.response;
|
return {
|
||||||
finalUrl = result.finalUrl;
|
response: result.response,
|
||||||
release = result.release;
|
finalUrl: result.finalUrl,
|
||||||
|
release: result.release,
|
||||||
|
sourceUrl,
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new MediaFetchError(
|
throw new MediaFetchError(
|
||||||
"fetch_failed",
|
"fetch_failed",
|
||||||
@@ -192,47 +233,363 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertMediaResponseOk(params: {
|
||||||
|
res: Response;
|
||||||
|
url: string;
|
||||||
|
finalUrl: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
readIdleTimeoutMs?: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { res, url, finalUrl, sourceUrl, readIdleTimeoutMs } = params;
|
||||||
|
if (res.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const statusText = res.statusText ? ` ${res.statusText}` : "";
|
||||||
|
const redirected = finalUrl !== url ? ` (redirected to ${redactMediaUrl(finalUrl)})` : "";
|
||||||
|
let detail = `HTTP ${res.status}${statusText}`;
|
||||||
|
if (!res.body) {
|
||||||
|
detail = `HTTP ${res.status}${statusText}; empty response body`;
|
||||||
|
} else {
|
||||||
|
const snippet = await readErrorBodySnippet(res, { chunkTimeoutMs: readIdleTimeoutMs });
|
||||||
|
if (snippet) {
|
||||||
|
detail += `; body: ${snippet}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new MediaFetchError(
|
||||||
|
"http_error",
|
||||||
|
`Failed to fetch media from ${sourceUrl}${redirected}: ${redactSensitiveText(detail)}`,
|
||||||
|
{ status: res.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertMediaContentLength(params: {
|
||||||
|
res: Response;
|
||||||
|
sourceUrl: string;
|
||||||
|
maxBytes: number;
|
||||||
|
}): void {
|
||||||
|
const contentLength = params.res.headers.get("content-length");
|
||||||
|
if (!contentLength) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const length = Number(contentLength);
|
||||||
|
if (Number.isFinite(length) && length > params.maxBytes) {
|
||||||
|
throw new MediaFetchError(
|
||||||
|
"max_bytes",
|
||||||
|
`Failed to fetch media from ${params.sourceUrl}: content length ${length} exceeds maxBytes ${params.maxBytes}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRemoteFileName(params: {
|
||||||
|
res: Response;
|
||||||
|
finalUrl: string;
|
||||||
|
filePathHint?: string;
|
||||||
|
}): string | undefined {
|
||||||
|
let fileNameFromUrl: string | undefined;
|
||||||
try {
|
try {
|
||||||
if (!res.ok) {
|
const parsed = new URL(params.finalUrl);
|
||||||
const statusText = res.statusText ? ` ${res.statusText}` : "";
|
const base = path.basename(parsed.pathname);
|
||||||
const redirected = finalUrl !== url ? ` (redirected to ${redactMediaUrl(finalUrl)})` : "";
|
fileNameFromUrl = base || undefined;
|
||||||
let detail = `HTTP ${res.status}${statusText}`;
|
} catch {
|
||||||
if (!res.body) {
|
// ignore parse errors; leave undefined
|
||||||
detail = `HTTP ${res.status}${statusText}; empty response body`;
|
}
|
||||||
} else {
|
const headerFileName = parseContentDispositionFileName(
|
||||||
const snippet = await readErrorBodySnippet(res, { chunkTimeoutMs: readIdleTimeoutMs });
|
params.res.headers.get("content-disposition"),
|
||||||
if (snippet) {
|
);
|
||||||
detail += `; body: ${snippet}`;
|
return (
|
||||||
}
|
headerFileName ||
|
||||||
|
(params.filePathHint ? path.basename(params.filePathHint) : undefined) ||
|
||||||
|
fileNameFromUrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGenericResponseContentType(value?: string | null): boolean {
|
||||||
|
const normalized = value?.split(";")[0]?.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
!normalized ||
|
||||||
|
normalized === "application/octet-stream" ||
|
||||||
|
normalized === "binary/octet-stream" ||
|
||||||
|
normalized === "application/zip"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveResponseContentType(params: {
|
||||||
|
headerContentType?: string | null;
|
||||||
|
fallbackContentType?: string;
|
||||||
|
}): string | undefined {
|
||||||
|
if (!params.fallbackContentType) {
|
||||||
|
return params.headerContentType ?? undefined;
|
||||||
|
}
|
||||||
|
if (isGenericResponseContentType(params.headerContentType)) {
|
||||||
|
return params.fallbackContentType;
|
||||||
|
}
|
||||||
|
const headerContentType = params.headerContentType?.split(";")[0]?.trim().toLowerCase();
|
||||||
|
const fallbackContentType = params.fallbackContentType.split(";")[0]?.trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
headerContentType?.startsWith("video/") &&
|
||||||
|
fallbackContentType?.startsWith("audio/") &&
|
||||||
|
headerContentType.slice("video/".length) === fallbackContentType.slice("audio/".length)
|
||||||
|
) {
|
||||||
|
return params.fallbackContentType;
|
||||||
|
}
|
||||||
|
return params.headerContentType ?? params.fallbackContentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readChunkWithIdleTimeout(
|
||||||
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||||
|
chunkTimeoutMs: number,
|
||||||
|
): Promise<Awaited<ReturnType<typeof reader.read>>> {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let timedOut = false;
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const clear = () => {
|
||||||
|
if (timeoutId !== undefined) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = undefined;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
clear();
|
||||||
|
void reader.cancel().catch(() => undefined);
|
||||||
|
reject(new Error(`Media download stalled: no data received for ${chunkTimeoutMs}ms`));
|
||||||
|
}, chunkTimeoutMs);
|
||||||
|
void reader.read().then(
|
||||||
|
(result) => {
|
||||||
|
clear();
|
||||||
|
if (!timedOut) {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
clear();
|
||||||
|
if (!timedOut) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* responseBodyChunks(
|
||||||
|
body: ReadableStream<Uint8Array>,
|
||||||
|
readIdleTimeoutMs?: number,
|
||||||
|
): AsyncIterable<Uint8Array> {
|
||||||
|
const reader = body.getReader();
|
||||||
|
let completed = false;
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = readIdleTimeoutMs
|
||||||
|
? await readChunkWithIdleTimeout(reader, readIdleTimeoutMs)
|
||||||
|
: await reader.read();
|
||||||
|
if (done) {
|
||||||
|
completed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value?.byteLength) {
|
||||||
|
yield value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!completed) {
|
||||||
|
await reader.cancel().catch(() => undefined);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
reader.releaseLock();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMediaLimitError(err: unknown): boolean {
|
||||||
|
return err instanceof Error && /Media exceeds .* limit/.test(err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveOkMediaResponse(params: {
|
||||||
|
res: Response;
|
||||||
|
finalUrl: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
filePathHint?: string;
|
||||||
|
maxBytes: number;
|
||||||
|
readIdleTimeoutMs?: number;
|
||||||
|
fallbackContentType?: string;
|
||||||
|
subdir?: string;
|
||||||
|
originalFilename?: string;
|
||||||
|
}): Promise<SavedRemoteMedia> {
|
||||||
|
assertMediaContentLength({
|
||||||
|
res: params.res,
|
||||||
|
sourceUrl: params.sourceUrl,
|
||||||
|
maxBytes: params.maxBytes,
|
||||||
|
});
|
||||||
|
const fileName = resolveRemoteFileName({
|
||||||
|
res: params.res,
|
||||||
|
finalUrl: params.finalUrl,
|
||||||
|
filePathHint: params.filePathHint,
|
||||||
|
});
|
||||||
|
const contentType = resolveResponseContentType({
|
||||||
|
headerContentType: params.res.headers.get("content-type"),
|
||||||
|
fallbackContentType: params.fallbackContentType,
|
||||||
|
});
|
||||||
|
const detectionFilePathHint = isGenericResponseContentType(contentType)
|
||||||
|
? params.filePathHint
|
||||||
|
: undefined;
|
||||||
|
try {
|
||||||
|
const saved = params.res.body
|
||||||
|
? await saveMediaStream(
|
||||||
|
responseBodyChunks(params.res.body, params.readIdleTimeoutMs),
|
||||||
|
contentType ?? undefined,
|
||||||
|
params.subdir ?? "inbound",
|
||||||
|
params.maxBytes,
|
||||||
|
params.originalFilename,
|
||||||
|
detectionFilePathHint,
|
||||||
|
)
|
||||||
|
: await saveMediaBuffer(
|
||||||
|
Buffer.from(await params.res.arrayBuffer()),
|
||||||
|
contentType ?? undefined,
|
||||||
|
params.subdir ?? "inbound",
|
||||||
|
params.maxBytes,
|
||||||
|
params.originalFilename,
|
||||||
|
detectionFilePathHint,
|
||||||
|
);
|
||||||
|
return { ...saved, ...(fileName ? { fileName } : {}) };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof MediaFetchError) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (isMediaLimitError(err)) {
|
||||||
throw new MediaFetchError(
|
throw new MediaFetchError(
|
||||||
"http_error",
|
"max_bytes",
|
||||||
`Failed to fetch media from ${sourceUrl}${redirected}: ${redactSensitiveText(detail)}`,
|
`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;
|
function shouldRetryMediaFetch(err: unknown): boolean {
|
||||||
const contentLength = res.headers.get("content-length");
|
if (err instanceof MediaFetchError) {
|
||||||
if (contentLength) {
|
if (err.code === "max_bytes") {
|
||||||
const length = Number(contentLength);
|
return false;
|
||||||
if (Number.isFinite(length) && length > effectiveMaxBytes) {
|
|
||||||
throw new MediaFetchError(
|
|
||||||
"max_bytes",
|
|
||||||
`Failed to fetch media from ${sourceUrl}: content length ${length} exceeds maxBytes ${effectiveMaxBytes}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (err.code === "http_error") {
|
||||||
|
return typeof err.status === "number" && (err.status === 408 || err.status >= 500);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withMediaFetchRetry<T>(
|
||||||
|
options: FetchMediaOptions,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const retry = options.retry;
|
||||||
|
if (!retry) {
|
||||||
|
return await fn();
|
||||||
|
}
|
||||||
|
const callerShouldRetry = retry.shouldRetry;
|
||||||
|
return await retryAsync(fn, {
|
||||||
|
label: "media:fetch",
|
||||||
|
...retry,
|
||||||
|
shouldRetry: (err, attempt) =>
|
||||||
|
callerShouldRetry ? callerShouldRetry(err, attempt) : shouldRetryMediaFetch(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveResponseMedia(
|
||||||
|
res: Response,
|
||||||
|
options: SaveResponseMediaOptions = {},
|
||||||
|
): Promise<SavedRemoteMedia> {
|
||||||
|
const sourceUrl = redactMediaUrl((options.sourceUrl ?? res.url) || "response");
|
||||||
|
const finalUrl = options.sourceUrl ?? res.url;
|
||||||
|
await assertMediaResponseOk({
|
||||||
|
res,
|
||||||
|
url: options.sourceUrl ?? finalUrl,
|
||||||
|
finalUrl,
|
||||||
|
sourceUrl,
|
||||||
|
readIdleTimeoutMs: options.readIdleTimeoutMs,
|
||||||
|
});
|
||||||
|
return await saveOkMediaResponse({
|
||||||
|
res,
|
||||||
|
finalUrl,
|
||||||
|
sourceUrl,
|
||||||
|
filePathHint: options.filePathHint,
|
||||||
|
maxBytes: options.maxBytes ?? DEFAULT_FETCH_MEDIA_MAX_BYTES,
|
||||||
|
readIdleTimeoutMs: options.readIdleTimeoutMs,
|
||||||
|
fallbackContentType: options.fallbackContentType,
|
||||||
|
subdir: options.subdir,
|
||||||
|
originalFilename: options.originalFilename,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveRemoteMedia(options: SaveRemoteMediaOptions): Promise<SavedRemoteMedia> {
|
||||||
|
return await withMediaFetchRetry(options, () => saveRemoteMediaOnce(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRemoteMediaOnce(options: SaveRemoteMediaOptions): Promise<SavedRemoteMedia> {
|
||||||
|
const { response: res, finalUrl, release, sourceUrl } = await fetchGuardedMediaResponse(options);
|
||||||
|
try {
|
||||||
|
await assertMediaResponseOk({
|
||||||
|
res,
|
||||||
|
url: options.url,
|
||||||
|
finalUrl,
|
||||||
|
sourceUrl,
|
||||||
|
readIdleTimeoutMs: options.readIdleTimeoutMs,
|
||||||
|
});
|
||||||
|
return await saveOkMediaResponse({
|
||||||
|
res,
|
||||||
|
finalUrl,
|
||||||
|
sourceUrl,
|
||||||
|
filePathHint: options.filePathHint,
|
||||||
|
maxBytes: options.maxBytes ?? DEFAULT_FETCH_MEDIA_MAX_BYTES,
|
||||||
|
readIdleTimeoutMs: options.readIdleTimeoutMs,
|
||||||
|
fallbackContentType: options.fallbackContentType,
|
||||||
|
subdir: options.subdir,
|
||||||
|
originalFilename: options.originalFilename,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (release) {
|
||||||
|
await release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readRemoteMediaBuffer(options: FetchMediaOptions): Promise<FetchMediaResult> {
|
||||||
|
return await withMediaFetchRetry(options, () => readRemoteMediaBufferOnce(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use `readRemoteMediaBuffer` for buffer reads or `saveRemoteMedia` for URL-to-store. */
|
||||||
|
export const fetchRemoteMedia = readRemoteMediaBuffer;
|
||||||
|
|
||||||
|
async function readRemoteMediaBufferOnce(options: FetchMediaOptions): Promise<FetchMediaResult> {
|
||||||
|
const { response: res, finalUrl, release, sourceUrl } = await fetchGuardedMediaResponse(options);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assertMediaResponseOk({
|
||||||
|
res,
|
||||||
|
url: options.url,
|
||||||
|
finalUrl,
|
||||||
|
sourceUrl,
|
||||||
|
readIdleTimeoutMs: options.readIdleTimeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const effectiveMaxBytes = options.maxBytes ?? DEFAULT_FETCH_MEDIA_MAX_BYTES;
|
||||||
|
assertMediaContentLength({ res, sourceUrl, maxBytes: effectiveMaxBytes });
|
||||||
let buffer: Buffer;
|
let buffer: Buffer;
|
||||||
try {
|
try {
|
||||||
buffer = await readResponseWithLimit(res, effectiveMaxBytes, {
|
buffer = await readResponseWithLimit(res, effectiveMaxBytes, {
|
||||||
onOverflow: ({ maxBytes, res }) =>
|
onOverflow: ({ maxBytes, res }) =>
|
||||||
new MediaFetchError(
|
new MediaFetchError(
|
||||||
"max_bytes",
|
"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) {
|
} catch (err) {
|
||||||
if (err instanceof MediaFetchError) {
|
if (err instanceof MediaFetchError) {
|
||||||
@@ -240,25 +597,18 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
|
|||||||
}
|
}
|
||||||
throw new MediaFetchError(
|
throw new MediaFetchError(
|
||||||
"fetch_failed",
|
"fetch_failed",
|
||||||
`Failed to fetch media from ${redactMediaUrl(res.url || url)}: ${formatErrorMessage(err)}`,
|
`Failed to fetch media from ${redactMediaUrl(res.url || options.url)}: ${formatErrorMessage(err)}`,
|
||||||
{ cause: err },
|
{ cause: err },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let fileNameFromUrl: string | undefined;
|
let fileName = resolveRemoteFileName({
|
||||||
try {
|
res,
|
||||||
const parsed = new URL(finalUrl);
|
finalUrl,
|
||||||
const base = path.basename(parsed.pathname);
|
filePathHint: options.filePathHint,
|
||||||
fileNameFromUrl = base || undefined;
|
});
|
||||||
} catch {
|
|
||||||
// ignore parse errors; leave undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerFileName = parseContentDispositionFileName(res.headers.get("content-disposition"));
|
|
||||||
let fileName =
|
|
||||||
headerFileName || fileNameFromUrl || (filePathHint ? path.basename(filePathHint) : undefined);
|
|
||||||
|
|
||||||
const filePathForMime =
|
const filePathForMime =
|
||||||
headerFileName && path.extname(headerFileName) ? headerFileName : (filePathHint ?? finalUrl);
|
fileName && path.extname(fileName) ? fileName : (options.filePathHint ?? finalUrl);
|
||||||
const contentType = await detectMime({
|
const contentType = await detectMime({
|
||||||
buffer,
|
buffer,
|
||||||
headerMime: res.headers.get("content-type"),
|
headerMime: res.headers.get("content-type"),
|
||||||
|
|||||||
26
src/media/read-byte-stream-with-limit.test.ts
Normal file
26
src/media/read-byte-stream-with-limit.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Readable } from "node:stream";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { readByteStreamWithLimit } from "./read-byte-stream-with-limit.js";
|
||||||
|
|
||||||
|
describe("readByteStreamWithLimit", () => {
|
||||||
|
it("returns concatenated bytes up to the limit", async () => {
|
||||||
|
const buffer = await readByteStreamWithLimit(Readable.from([Buffer.from("ab"), "cd"]), {
|
||||||
|
maxBytes: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(buffer).toEqual(Buffer.from("abcd"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws and destroys node streams after overflow", async () => {
|
||||||
|
const stream = Readable.from([Buffer.alloc(4), Buffer.alloc(4)]);
|
||||||
|
const destroySpy = vi.spyOn(stream, "destroy");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
readByteStreamWithLimit(stream, {
|
||||||
|
maxBytes: 7,
|
||||||
|
onOverflow: ({ size, maxBytes }) => new Error(`too large ${size}/${maxBytes}`),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("too large 8/7");
|
||||||
|
expect(destroySpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
77
src/media/read-byte-stream-with-limit.ts
Normal file
77
src/media/read-byte-stream-with-limit.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
export type ByteStreamLimitOverflow = {
|
||||||
|
size: number;
|
||||||
|
maxBytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadByteStreamWithLimitOptions = {
|
||||||
|
maxBytes: number;
|
||||||
|
onOverflow?: (params: ByteStreamLimitOverflow) => Error;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeByteChunk(chunk: unknown): Buffer {
|
||||||
|
if (Buffer.isBuffer(chunk)) {
|
||||||
|
return chunk;
|
||||||
|
}
|
||||||
|
if (typeof chunk === "string") {
|
||||||
|
return Buffer.from(chunk);
|
||||||
|
}
|
||||||
|
if (chunk instanceof ArrayBuffer) {
|
||||||
|
return Buffer.from(chunk);
|
||||||
|
}
|
||||||
|
if (ArrayBuffer.isView(chunk)) {
|
||||||
|
return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
||||||
|
}
|
||||||
|
throw new TypeError(`Unsupported byte stream chunk: ${typeof chunk}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyReadableOnOverflow(stream: unknown, err: Error): void {
|
||||||
|
const readable = stream as {
|
||||||
|
destroy?: (error?: Error) => unknown;
|
||||||
|
cancel?: (reason?: unknown) => unknown;
|
||||||
|
};
|
||||||
|
if (typeof readable.destroy === "function") {
|
||||||
|
try {
|
||||||
|
readable.destroy(err);
|
||||||
|
} catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof readable.cancel === "function") {
|
||||||
|
try {
|
||||||
|
void readable.cancel(err);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readByteStreamWithLimit(
|
||||||
|
stream: AsyncIterable<unknown>,
|
||||||
|
opts: ReadByteStreamWithLimitOptions,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const { maxBytes } = opts;
|
||||||
|
if (!Number.isFinite(maxBytes) || maxBytes < 0) {
|
||||||
|
throw new RangeError(`maxBytes must be a non-negative finite number: ${maxBytes}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onOverflow =
|
||||||
|
opts.onOverflow ??
|
||||||
|
((params: ByteStreamLimitOverflow) =>
|
||||||
|
new Error(`Content too large: ${params.size} bytes (limit: ${params.maxBytes} bytes)`));
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
const buffer = normalizeByteChunk(chunk);
|
||||||
|
if (buffer.byteLength === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nextTotal = total + buffer.byteLength;
|
||||||
|
if (nextTotal > maxBytes) {
|
||||||
|
const err = onOverflow({ size: nextTotal, maxBytes });
|
||||||
|
destroyReadableOnOverflow(stream, err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
chunks.push(buffer);
|
||||||
|
total = nextTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat(chunks, total);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
|
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
@@ -405,6 +406,83 @@ describe("media store", () => {
|
|||||||
await expectFailedBufferWriteCase();
|
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",
|
name: "saves buffers when the best-effort fsync step reports EPERM",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
@@ -576,6 +654,14 @@ describe("media store", () => {
|
|||||||
expectedContentType: "image/jpeg",
|
expectedContentType: "image/jpeg",
|
||||||
expectedExtension: ".jpg",
|
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",
|
name: "preserves original extension for generic file buffers",
|
||||||
buffer: Buffer.from("custom binary"),
|
buffer: Buffer.from("custom binary"),
|
||||||
@@ -598,7 +684,13 @@ describe("media store", () => {
|
|||||||
...("originalFilename" in testCase
|
...("originalFilename" in testCase
|
||||||
? {
|
? {
|
||||||
assertSaved: async (saved: Awaited<ReturnType<typeof store.saveMediaBuffer>>) => {
|
assertSaved: async (saved: Awaited<ReturnType<typeof store.saveMediaBuffer>>) => {
|
||||||
expect(path.basename(saved.path)).toMatch(/^report---.+\.custom$/);
|
const escapedExtension = testCase.expectedExtension.replace(
|
||||||
|
/[.*+?^${}()|[\]\\]/g,
|
||||||
|
"\\$&",
|
||||||
|
);
|
||||||
|
expect(path.basename(saved.path)).toMatch(
|
||||||
|
new RegExp(`^report---.+${escapedExtension}$`),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -309,6 +309,17 @@ function safeOriginalFilenameExtension(originalFilename?: string): string | unde
|
|||||||
return /^\.[a-z0-9]{1,16}$/.test(ext) ? ext : undefined;
|
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: {
|
function buildSavedMediaResult(params: {
|
||||||
dir: string;
|
dir: string;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -339,6 +350,52 @@ async function writeSavedMediaBuffer(params: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeMediaStreamToFile(params: {
|
||||||
|
stream: AsyncIterable<unknown>;
|
||||||
|
tempPath: string;
|
||||||
|
maxBytes: number;
|
||||||
|
}): Promise<{ sniffBuffer: Buffer; size: number }> {
|
||||||
|
const handle = await fs.open(params.tempPath, "wx", MEDIA_FILE_MODE);
|
||||||
|
const sniffChunks: Buffer[] = [];
|
||||||
|
let sniffLen = 0;
|
||||||
|
let total = 0;
|
||||||
|
try {
|
||||||
|
for await (const chunk of params.stream) {
|
||||||
|
const buffer = Buffer.isBuffer(chunk)
|
||||||
|
? chunk
|
||||||
|
: typeof chunk === "string"
|
||||||
|
? Buffer.from(chunk)
|
||||||
|
: chunk instanceof ArrayBuffer
|
||||||
|
? Buffer.from(chunk)
|
||||||
|
: ArrayBuffer.isView(chunk)
|
||||||
|
? Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
|
||||||
|
: undefined;
|
||||||
|
if (!buffer) {
|
||||||
|
throw new TypeError(`Unsupported media stream chunk: ${typeof chunk}`);
|
||||||
|
}
|
||||||
|
if (buffer.byteLength === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
total += buffer.byteLength;
|
||||||
|
if (total > params.maxBytes) {
|
||||||
|
throw new Error(`Media exceeds ${formatMediaLimitMb(params.maxBytes)} limit`);
|
||||||
|
}
|
||||||
|
if (sniffLen < 16384) {
|
||||||
|
const remaining = 16384 - sniffLen;
|
||||||
|
sniffChunks.push(buffer.byteLength > remaining ? buffer.subarray(0, remaining) : buffer);
|
||||||
|
sniffLen += Math.min(buffer.byteLength, remaining);
|
||||||
|
}
|
||||||
|
await handle.write(buffer);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sniffBuffer: Buffer.concat(sniffChunks, sniffLen),
|
||||||
|
size: total,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await handle.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SaveMediaSourceErrorCode =
|
export type SaveMediaSourceErrorCode =
|
||||||
| "invalid-path"
|
| "invalid-path"
|
||||||
| "not-found"
|
| "not-found"
|
||||||
@@ -452,6 +509,7 @@ export async function saveMediaBuffer(
|
|||||||
subdir = "inbound",
|
subdir = "inbound",
|
||||||
maxBytes = MAX_BYTES,
|
maxBytes = MAX_BYTES,
|
||||||
originalFilename?: string,
|
originalFilename?: string,
|
||||||
|
detectionFilePathHint?: string,
|
||||||
): Promise<SavedMedia> {
|
): Promise<SavedMedia> {
|
||||||
if (buffer.byteLength > maxBytes) {
|
if (buffer.byteLength > maxBytes) {
|
||||||
throw new Error(`Media exceeds ${formatMediaLimitMb(maxBytes)} limit`);
|
throw new Error(`Media exceeds ${formatMediaLimitMb(maxBytes)} limit`);
|
||||||
@@ -459,8 +517,12 @@ export async function saveMediaBuffer(
|
|||||||
const dir = resolveMediaScopedDir(subdir, "saveMediaBuffer");
|
const dir = resolveMediaScopedDir(subdir, "saveMediaBuffer");
|
||||||
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||||
const uuid = crypto.randomUUID();
|
const uuid = crypto.randomUUID();
|
||||||
const headerExt = extensionForMime(normalizeOptionalString(contentType?.split(";")[0]));
|
const headerExt = extensionForAuthoritativeHeaderMime(contentType);
|
||||||
const mime = await detectMime({ buffer, headerMime: contentType });
|
const mime = await detectMime({
|
||||||
|
buffer,
|
||||||
|
headerMime: contentType,
|
||||||
|
filePath: originalFilename ?? detectionFilePathHint,
|
||||||
|
});
|
||||||
const ext =
|
const ext =
|
||||||
headerExt ?? extensionForMime(mime) ?? safeOriginalFilenameExtension(originalFilename) ?? "";
|
headerExt ?? extensionForMime(mime) ?? safeOriginalFilenameExtension(originalFilename) ?? "";
|
||||||
const id = buildSavedMediaId({ baseId: uuid, ext, 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 });
|
return buildSavedMediaResult({ dir, id, size: buffer.byteLength, contentType: mime });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveMediaStream(
|
||||||
|
stream: AsyncIterable<unknown>,
|
||||||
|
contentType?: string,
|
||||||
|
subdir = "inbound",
|
||||||
|
maxBytes = MAX_BYTES,
|
||||||
|
originalFilename?: string,
|
||||||
|
detectionFilePathHint?: string,
|
||||||
|
): Promise<SavedMedia> {
|
||||||
|
const dir = resolveMediaScopedDir(subdir, "saveMediaStream");
|
||||||
|
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||||
|
const baseId = crypto.randomUUID();
|
||||||
|
const headerExt = extensionForAuthoritativeHeaderMime(contentType);
|
||||||
|
const saved = await retryAfterRecreatingDir(dir, () =>
|
||||||
|
writeSiblingTempFile<{ id: string; size: number; contentType?: string }>({
|
||||||
|
dir,
|
||||||
|
mode: MEDIA_FILE_MODE,
|
||||||
|
tempPrefix: `.${baseId}`,
|
||||||
|
writeTemp: async (tempPath) => {
|
||||||
|
const { sniffBuffer, size } = await writeMediaStreamToFile({
|
||||||
|
stream,
|
||||||
|
tempPath,
|
||||||
|
maxBytes,
|
||||||
|
});
|
||||||
|
const mime = await detectMime({
|
||||||
|
buffer: sniffBuffer,
|
||||||
|
headerMime: contentType,
|
||||||
|
filePath: originalFilename ?? detectionFilePathHint,
|
||||||
|
});
|
||||||
|
const ext =
|
||||||
|
headerExt ??
|
||||||
|
extensionForMime(mime) ??
|
||||||
|
safeOriginalFilenameExtension(originalFilename) ??
|
||||||
|
"";
|
||||||
|
const id = buildSavedMediaId({ baseId, ext, originalFilename });
|
||||||
|
return { id, size, contentType: mime };
|
||||||
|
},
|
||||||
|
resolveFinalPath: (result) => path.join(dir, result.id),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return buildSavedMediaResult({
|
||||||
|
dir,
|
||||||
|
id: saved.result.id,
|
||||||
|
size: saved.result.size,
|
||||||
|
contentType: saved.result.contentType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a media ID saved by saveMediaBuffer to its absolute physical path.
|
* Resolves a media ID saved by saveMediaBuffer to its absolute physical path.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { PinnedDispatcherPolicy, SsrFPolicy } from "../infra/net/ssrf.js";
|
|||||||
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
import { maxBytesForKind, type MediaKind } from "./constants.js";
|
import { maxBytesForKind, type MediaKind } from "./constants.js";
|
||||||
import { fetchRemoteMedia } from "./fetch.js";
|
import { readRemoteMediaBuffer } from "./fetch.js";
|
||||||
import {
|
import {
|
||||||
convertHeicToJpeg,
|
convertHeicToJpeg,
|
||||||
hasAlphaChannel,
|
hasAlphaChannel,
|
||||||
@@ -532,7 +532,7 @@ async function loadWebMediaInternal(
|
|||||||
allowPrivateProxy: true,
|
allowPrivateProxy: true,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const fetched = await fetchRemoteMedia({
|
const fetched = await readRemoteMediaBuffer({
|
||||||
url: mediaUrl,
|
url: mediaUrl,
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
requestInit,
|
requestInit,
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ export {
|
|||||||
} from "./test-helpers/outbound-delivery.js";
|
} from "./test-helpers/outbound-delivery.js";
|
||||||
/** @deprecated Direct outbound delivery is runtime substrate; use channel message runtime helpers. */
|
/** @deprecated Direct outbound delivery is runtime substrate; use channel message runtime helpers. */
|
||||||
export { deliverOutboundPayloads } from "./test-helpers/outbound-delivery.js";
|
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 {
|
export {
|
||||||
createSendCfgThreadingRuntime,
|
createSendCfgThreadingRuntime,
|
||||||
expectProvidedCfgSkipsRuntimeLoad,
|
expectProvidedCfgSkipsRuntimeLoad,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export * from "../media/outbound-attachment.js";
|
|||||||
export * from "../media/png-encode.ts";
|
export * from "../media/png-encode.ts";
|
||||||
export * from "../media/qr-image.ts";
|
export * from "../media/qr-image.ts";
|
||||||
export * from "../media/qr-terminal.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/read-response-with-limit.js";
|
||||||
export * from "../media/store.js";
|
export * from "../media/store.js";
|
||||||
export * from "../media/temp-files.js";
|
export * from "../media/temp-files.js";
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
// Narrow media store helpers for channel runtimes that do not need the full media runtime.
|
// Narrow media store helpers for channel runtimes that do not need the full media runtime.
|
||||||
|
|
||||||
export { readMediaBuffer, resolveMediaBufferPath, saveMediaBuffer } from "../media/store.js";
|
export {
|
||||||
|
readMediaBuffer,
|
||||||
|
resolveMediaBufferPath,
|
||||||
|
saveMediaBuffer,
|
||||||
|
saveMediaStream,
|
||||||
|
} from "../media/store.js";
|
||||||
|
export type { SavedMedia } from "../media/store.js";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
// Narrow response-size reader for plugins that download bounded HTTP bodies.
|
// 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";
|
export { readResponseWithLimit } from "../media/read-response-with-limit.js";
|
||||||
|
|||||||
@@ -69,6 +69,33 @@ function createDeprecatedRuntimeConfigError(name: "loadConfig" | "writeConfigFil
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PluginRuntimeMediaMock = PluginRuntime["channel"]["media"];
|
||||||
|
|
||||||
|
export function createPluginRuntimeMediaMock(
|
||||||
|
overrides: Partial<PluginRuntimeMediaMock> = {},
|
||||||
|
): PluginRuntimeMediaMock {
|
||||||
|
const readRemoteMediaBuffer =
|
||||||
|
vi.fn() as unknown as PluginRuntimeMediaMock["readRemoteMediaBuffer"];
|
||||||
|
return {
|
||||||
|
readRemoteMediaBuffer,
|
||||||
|
fetchRemoteMedia:
|
||||||
|
readRemoteMediaBuffer as unknown as PluginRuntimeMediaMock["fetchRemoteMedia"],
|
||||||
|
saveRemoteMedia: vi.fn().mockResolvedValue({
|
||||||
|
path: "/tmp/test-media.jpg",
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
}) as unknown as PluginRuntimeMediaMock["saveRemoteMedia"],
|
||||||
|
saveResponseMedia: vi.fn().mockResolvedValue({
|
||||||
|
path: "/tmp/test-media.jpg",
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
}) as unknown as PluginRuntimeMediaMock["saveResponseMedia"],
|
||||||
|
saveMediaBuffer: vi.fn().mockResolvedValue({
|
||||||
|
path: "/tmp/test-media.jpg",
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
}) as unknown as PluginRuntimeMediaMock["saveMediaBuffer"],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime {
|
export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime {
|
||||||
const taskFlow = {
|
const taskFlow = {
|
||||||
bindSession: vi.fn(
|
bindSession: vi.fn(
|
||||||
@@ -524,14 +551,7 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
|
|||||||
created: true,
|
created: true,
|
||||||
}) as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
|
}) as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
|
||||||
},
|
},
|
||||||
media: {
|
media: createPluginRuntimeMediaMock(),
|
||||||
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"],
|
|
||||||
},
|
|
||||||
session: {
|
session: {
|
||||||
resolveStorePath: vi.fn(
|
resolveStorePath: vi.fn(
|
||||||
() => "/tmp/sessions.json",
|
() => "/tmp/sessions.json",
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ describe("plugin-sdk exports", () => {
|
|||||||
"resolveStateDir",
|
"resolveStateDir",
|
||||||
"writeConfigFile",
|
"writeConfigFile",
|
||||||
"enqueueSystemEvent",
|
"enqueueSystemEvent",
|
||||||
"fetchRemoteMedia",
|
"readRemoteMediaBuffer",
|
||||||
"saveMediaBuffer",
|
"saveMediaBuffer",
|
||||||
"formatAgentEnvelope",
|
"formatAgentEnvelope",
|
||||||
"buildPairingReply",
|
"buildPairingReply",
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
|
|||||||
'export { GoogleChatConfigSchema } from "openclaw/plugin-sdk/bundled-channel-config-schema";',
|
'export { 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 { GROUP_POLICY_BLOCKED_LABEL, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce } from "openclaw/plugin-sdk/runtime-group-policy";',
|
||||||
'export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";',
|
'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 { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";',
|
||||||
'export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";',
|
'export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";',
|
||||||
'export { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";',
|
'export { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";',
|
||||||
|
|||||||
@@ -70,7 +70,12 @@ import {
|
|||||||
} from "../../config/sessions.js";
|
} from "../../config/sessions.js";
|
||||||
import { getChannelActivity, recordChannelActivity } from "../../infra/channel-activity.js";
|
import { getChannelActivity, recordChannelActivity } from "../../infra/channel-activity.js";
|
||||||
import { convertMarkdownTables } from "../../markdown/tables.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 { saveMediaBuffer } from "../../media/store.js";
|
||||||
import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
||||||
import {
|
import {
|
||||||
@@ -128,7 +133,10 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
media: {
|
media: {
|
||||||
|
readRemoteMediaBuffer,
|
||||||
fetchRemoteMedia,
|
fetchRemoteMedia,
|
||||||
|
saveRemoteMedia,
|
||||||
|
saveResponseMedia,
|
||||||
saveMediaBuffer,
|
saveMediaBuffer,
|
||||||
},
|
},
|
||||||
activity: {
|
activity: {
|
||||||
|
|||||||
@@ -107,7 +107,11 @@ export type PluginRuntimeChannel = {
|
|||||||
upsertPairingRequest: UpsertChannelPairingRequestForAccount;
|
upsertPairingRequest: UpsertChannelPairingRequestForAccount;
|
||||||
};
|
};
|
||||||
media: {
|
media: {
|
||||||
|
readRemoteMediaBuffer: typeof import("../../media/fetch.js").readRemoteMediaBuffer;
|
||||||
|
/** @deprecated Use `readRemoteMediaBuffer`. */
|
||||||
fetchRemoteMedia: typeof import("../../media/fetch.js").fetchRemoteMedia;
|
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;
|
saveMediaBuffer: typeof import("../../media/store.js").saveMediaBuffer;
|
||||||
};
|
};
|
||||||
activity: {
|
activity: {
|
||||||
|
|||||||
Reference in New Issue
Block a user