fix(line): persist inbound media in shared store

This commit is contained in:
Peter Steinberger
2026-04-28 09:11:48 +01:00
parent fb3ea9efb1
commit 4e921808d1
4 changed files with 58 additions and 58 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -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);
});
});

View File

@@ -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";
}
}