diff --git a/CHANGELOG.md b/CHANGELOG.md index 0947031a572..fd11cf7f1a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/channels/line.md b/docs/channels/line.md index 5c4748e5ba6..3cf39d0a8a1 100644 --- a/docs/channels/line.md +++ b/docs/channels/line.md @@ -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) diff --git a/extensions/line/src/download.test.ts b/extensions/line/src/download.test.ts index 701bce3f996..93eb80c0632 100644 --- a/extensions/line/src/download.test.ts +++ b/extensions/line/src/download.test.ts @@ -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 { @@ -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); }); }); diff --git a/extensions/line/src/download.ts b/extensions/line/src/download.ts index ec7db64d732..a8b0c07e5ed 100644 --- a/extensions/line/src/download.ts +++ b/extensions/line/src/download.ts @@ -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"; - } -}