mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:20:43 +00:00
fix(line): persist inbound media in shared store
This commit is contained in:
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Plugins/startup: precompute bundled runtime mirror fingerprints before taking the mirror lock, including dist-runtime canonical roots, so Docker Desktop/WSL cold starts no longer hold `.openclaw-runtime-mirror.lock` while scanning slow persisted volumes. Fixes #73339. Thanks @1yihui.
|
||||
- Channels/LINE: persist inbound image, video, audio, and file downloads in `~/.openclaw/media/inbound/` instead of temporary files so agents can still read LINE media after `/tmp` cleanup. Fixes #73370. Thanks @hijirii and @wenxu007.
|
||||
- Control UI/WebChat: keep large attachment payloads out of Lit state and optimistic chat messages, using object URL previews plus send-time payload serialization so PDF/image uploads no longer trigger `RangeError: Maximum call stack size exceeded`. Fixes #73360; refs #54378 and #63432. Thanks @hejunhui-73, @Ansub, and @christianhernandez3-afk.
|
||||
- Agents/Anthropic: cancel stalled Anthropic Messages SSE body reads when abort signals fire, so active-memory timeouts release transport resources instead of leaving hidden recall runs parked on `reader.read()`. Refs #72965 and #73120. Thanks @wdeveloper16.
|
||||
- Agents/models: keep per-agent primary models strict when `fallbacks` is omitted, so probe-only custom providers are not tried as hidden fallback candidates unless the agent explicitly opts in. Fixes #73332. Thanks @haumanto.
|
||||
|
||||
@@ -143,6 +143,9 @@ LINE IDs are case-sensitive. Valid IDs look like:
|
||||
- Streaming responses are buffered; LINE receives full chunks with a loading
|
||||
animation while the agent works.
|
||||
- Media downloads are capped by `channels.line.mediaMaxMb` (default 10).
|
||||
- Inbound media is saved under `~/.openclaw/media/inbound/` before it is passed
|
||||
to the agent, matching the shared media store used by other bundled channel
|
||||
plugins.
|
||||
|
||||
## Channel data (rich messages)
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getMessageContentMock = vi.hoisted(() => vi.fn());
|
||||
const saveMediaBufferMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@line/bot-sdk", () => ({
|
||||
messagingApi: {
|
||||
@@ -29,6 +27,10 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
|
||||
logVerbose: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/media-store", () => ({
|
||||
saveMediaBuffer: saveMediaBufferMock,
|
||||
}));
|
||||
|
||||
let downloadLineMedia: typeof import("./download.js").downloadLineMedia;
|
||||
|
||||
async function* chunks(parts: Buffer[]): AsyncGenerator<Buffer> {
|
||||
@@ -45,41 +47,55 @@ describe("downloadLineMedia", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
getMessageContentMock.mockReset();
|
||||
saveMediaBufferMock.mockReset();
|
||||
saveMediaBufferMock.mockImplementation(
|
||||
async (_buffer: Buffer, contentType?: string, subdir?: string) => ({
|
||||
path: `/home/user/.openclaw/media/${subdir ?? "unknown"}/saved-media`,
|
||||
contentType,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not derive temp file path from external messageId", async () => {
|
||||
it("persists inbound media with the shared media store", async () => {
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
|
||||
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
|
||||
|
||||
const result = await downloadLineMedia("mid-jpeg", "token");
|
||||
|
||||
expect(saveMediaBufferMock).toHaveBeenCalledTimes(1);
|
||||
const call = saveMediaBufferMock.mock.calls[0];
|
||||
expect((call?.[0] as Buffer).equals(jpeg)).toBe(true);
|
||||
expect(call?.[1]).toBe("image/jpeg");
|
||||
expect(call?.[2]).toBe("inbound");
|
||||
expect(call?.[3]).toBe(10 * 1024 * 1024);
|
||||
expect(result).toEqual({
|
||||
path: "/home/user/.openclaw/media/inbound/saved-media",
|
||||
contentType: "image/jpeg",
|
||||
size: jpeg.length,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not pass the external messageId to saveMediaBuffer", async () => {
|
||||
const messageId = "a/../../../../etc/passwd";
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
|
||||
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
|
||||
|
||||
const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await downloadLineMedia(messageId, "token");
|
||||
const writtenPath = writeSpy.mock.calls[0]?.[0];
|
||||
|
||||
expect(result.size).toBe(jpeg.length);
|
||||
expect(result.contentType).toBe("image/jpeg");
|
||||
expect(typeof writtenPath).toBe("string");
|
||||
if (typeof writtenPath !== "string") {
|
||||
throw new Error("expected string temp file path");
|
||||
for (const arg of saveMediaBufferMock.mock.calls[0] ?? []) {
|
||||
if (typeof arg === "string") {
|
||||
expect(arg).not.toContain(messageId);
|
||||
}
|
||||
}
|
||||
expect(result.path).toBe(writtenPath);
|
||||
expect(writtenPath).toContain("line-media-");
|
||||
expect(writtenPath).toMatch(/\.jpg$/);
|
||||
expect(writtenPath).not.toContain(messageId);
|
||||
expect(writtenPath).not.toContain("..");
|
||||
|
||||
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
|
||||
const rel = path.relative(tmpRoot, path.resolve(writtenPath));
|
||||
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects oversized media before writing to disk", async () => {
|
||||
it("rejects oversized media before invoking saveMediaBuffer", async () => {
|
||||
getMessageContentMock.mockResolvedValueOnce(chunks([Buffer.alloc(4), Buffer.alloc(4)]));
|
||||
const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValue(undefined);
|
||||
|
||||
await expect(downloadLineMedia("mid", "token", 7)).rejects.toThrow(/Media exceeds/i);
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
expect(saveMediaBufferMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("classifies M4A ftyp major brand as audio/mp4", async () => {
|
||||
@@ -87,14 +103,12 @@ describe("downloadLineMedia", () => {
|
||||
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20,
|
||||
]);
|
||||
getMessageContentMock.mockResolvedValueOnce(chunks([m4aHeader]));
|
||||
const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await downloadLineMedia("mid-audio", "token");
|
||||
const writtenPath = writeSpy.mock.calls[0]?.[0];
|
||||
|
||||
expect(result.contentType).toBe("audio/mp4");
|
||||
expect(result.path).toMatch(/\.m4a$/);
|
||||
expect(writtenPath).toBe(result.path);
|
||||
expect(saveMediaBufferMock.mock.calls[0]?.[1]).toBe("audio/mp4");
|
||||
expect(saveMediaBufferMock.mock.calls[0]?.[2]).toBe("inbound");
|
||||
});
|
||||
|
||||
it("detects MP4 video from ftyp major brand (isom)", async () => {
|
||||
@@ -102,11 +116,18 @@ describe("downloadLineMedia", () => {
|
||||
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d,
|
||||
]);
|
||||
getMessageContentMock.mockResolvedValueOnce(chunks([mp4]));
|
||||
vi.spyOn(fs.promises, "writeFile").mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await downloadLineMedia("mid-mp4", "token");
|
||||
|
||||
expect(result.contentType).toBe("video/mp4");
|
||||
expect(result.path).toMatch(/\.mp4$/);
|
||||
expect(saveMediaBufferMock.mock.calls[0]?.[1]).toBe("video/mp4");
|
||||
});
|
||||
|
||||
it("propagates media store failures", async () => {
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
|
||||
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
|
||||
saveMediaBufferMock.mockRejectedValueOnce(new Error("Media exceeds 0MB limit"));
|
||||
|
||||
await expect(downloadLineMedia("mid-bad", "token")).rejects.toThrow(/Media exceeds/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import { messagingApi } from "@line/bot-sdk";
|
||||
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { buildRandomTempFilePath } from "openclaw/plugin-sdk/temp-path";
|
||||
import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
interface DownloadResult {
|
||||
@@ -35,15 +34,12 @@ export async function downloadLineMedia(
|
||||
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const contentType = detectContentType(buffer);
|
||||
const ext = getExtensionForContentType(contentType);
|
||||
const filePath = buildRandomTempFilePath({ prefix: "line-media", extension: ext });
|
||||
|
||||
await fs.promises.writeFile(filePath, buffer);
|
||||
logVerbose(`line: downloaded media ${messageId} to ${filePath} (${buffer.length} bytes)`);
|
||||
const saved = await saveMediaBuffer(buffer, contentType, "inbound", maxBytes);
|
||||
logVerbose(`line: persisted media ${messageId} to ${saved.path} (${buffer.length} bytes)`);
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
contentType,
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
size: buffer.length,
|
||||
};
|
||||
}
|
||||
@@ -89,24 +85,3 @@ function detectContentType(buffer: Buffer): string {
|
||||
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function getExtensionForContentType(contentType: string): string {
|
||||
switch (contentType) {
|
||||
case "image/jpeg":
|
||||
return ".jpg";
|
||||
case "image/png":
|
||||
return ".png";
|
||||
case "image/gif":
|
||||
return ".gif";
|
||||
case "image/webp":
|
||||
return ".webp";
|
||||
case "video/mp4":
|
||||
return ".mp4";
|
||||
case "audio/mp4":
|
||||
return ".m4a";
|
||||
case "audio/mpeg":
|
||||
return ".mp3";
|
||||
default:
|
||||
return ".bin";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user