Skip to content

Commit 4e92180

Browse files
committed
fix(line): persist inbound media in shared store
1 parent fb3ea9e commit 4e92180

4 files changed

Lines changed: 58 additions & 58 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
1414
### Fixes
1515

1616
- 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.
17+
- 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.
1718
- 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.
1819
- 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.
1920
- 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.

docs/channels/line.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ LINE IDs are case-sensitive. Valid IDs look like:
143143
- Streaming responses are buffered; LINE receives full chunks with a loading
144144
animation while the agent works.
145145
- Media downloads are capped by `channels.line.mediaMaxMb` (default 10).
146+
- Inbound media is saved under `~/.openclaw/media/inbound/` before it is passed
147+
to the agent, matching the shared media store used by other bundled channel
148+
plugins.
146149

147150
## Channel data (rich messages)
148151

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import fs from "node:fs";
2-
import path from "node:path";
3-
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
41
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
52

63
const getMessageContentMock = vi.hoisted(() => vi.fn());
4+
const saveMediaBufferMock = vi.hoisted(() => vi.fn());
75

86
vi.mock("@line/bot-sdk", () => ({
97
messagingApi: {
@@ -29,6 +27,10 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
2927
logVerbose: () => {},
3028
}));
3129

30+
vi.mock("openclaw/plugin-sdk/media-store", () => ({
31+
saveMediaBuffer: saveMediaBufferMock,
32+
}));
33+
3234
let downloadLineMedia: typeof import("./download.js").downloadLineMedia;
3335

3436
async function* chunks(parts: Buffer[]): AsyncGenerator<Buffer> {
@@ -45,68 +47,87 @@ describe("downloadLineMedia", () => {
4547
beforeEach(() => {
4648
vi.restoreAllMocks();
4749
getMessageContentMock.mockReset();
50+
saveMediaBufferMock.mockReset();
51+
saveMediaBufferMock.mockImplementation(
52+
async (_buffer: Buffer, contentType?: string, subdir?: string) => ({
53+
path: `/home/user/.openclaw/media/${subdir ?? "unknown"}/saved-media`,
54+
contentType,
55+
}),
56+
);
4857
});
4958

50-
it("does not derive temp file path from external messageId", async () => {
51-
const messageId = "a/../../../../etc/passwd";
59+
it("persists inbound media with the shared media store", async () => {
5260
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
5361
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
5462

55-
const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValueOnce(undefined);
63+
const result = await downloadLineMedia("mid-jpeg", "token");
64+
65+
expect(saveMediaBufferMock).toHaveBeenCalledTimes(1);
66+
const call = saveMediaBufferMock.mock.calls[0];
67+
expect((call?.[0] as Buffer).equals(jpeg)).toBe(true);
68+
expect(call?.[1]).toBe("image/jpeg");
69+
expect(call?.[2]).toBe("inbound");
70+
expect(call?.[3]).toBe(10 * 1024 * 1024);
71+
expect(result).toEqual({
72+
path: "/home/user/.openclaw/media/inbound/saved-media",
73+
contentType: "image/jpeg",
74+
size: jpeg.length,
75+
});
76+
});
77+
78+
it("does not pass the external messageId to saveMediaBuffer", async () => {
79+
const messageId = "a/../../../../etc/passwd";
80+
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
81+
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
5682

5783
const result = await downloadLineMedia(messageId, "token");
58-
const writtenPath = writeSpy.mock.calls[0]?.[0];
5984

6085
expect(result.size).toBe(jpeg.length);
6186
expect(result.contentType).toBe("image/jpeg");
62-
expect(typeof writtenPath).toBe("string");
63-
if (typeof writtenPath !== "string") {
64-
throw new Error("expected string temp file path");
87+
for (const arg of saveMediaBufferMock.mock.calls[0] ?? []) {
88+
if (typeof arg === "string") {
89+
expect(arg).not.toContain(messageId);
90+
}
6591
}
66-
expect(result.path).toBe(writtenPath);
67-
expect(writtenPath).toContain("line-media-");
68-
expect(writtenPath).toMatch(/\.jpg$/);
69-
expect(writtenPath).not.toContain(messageId);
70-
expect(writtenPath).not.toContain("..");
71-
72-
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
73-
const rel = path.relative(tmpRoot, path.resolve(writtenPath));
74-
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
7592
});
7693

77-
it("rejects oversized media before writing to disk", async () => {
94+
it("rejects oversized media before invoking saveMediaBuffer", async () => {
7895
getMessageContentMock.mockResolvedValueOnce(chunks([Buffer.alloc(4), Buffer.alloc(4)]));
79-
const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValue(undefined);
8096

8197
await expect(downloadLineMedia("mid", "token", 7)).rejects.toThrow(/Media exceeds/i);
82-
expect(writeSpy).not.toHaveBeenCalled();
98+
expect(saveMediaBufferMock).not.toHaveBeenCalled();
8399
});
84100

85101
it("classifies M4A ftyp major brand as audio/mp4", async () => {
86102
const m4aHeader = Buffer.from([
87103
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20,
88104
]);
89105
getMessageContentMock.mockResolvedValueOnce(chunks([m4aHeader]));
90-
const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValueOnce(undefined);
91106

92107
const result = await downloadLineMedia("mid-audio", "token");
93-
const writtenPath = writeSpy.mock.calls[0]?.[0];
94108

95109
expect(result.contentType).toBe("audio/mp4");
96-
expect(result.path).toMatch(/\.m4a$/);
97-
expect(writtenPath).toBe(result.path);
110+
expect(saveMediaBufferMock.mock.calls[0]?.[1]).toBe("audio/mp4");
111+
expect(saveMediaBufferMock.mock.calls[0]?.[2]).toBe("inbound");
98112
});
99113

100114
it("detects MP4 video from ftyp major brand (isom)", async () => {
101115
const mp4 = Buffer.from([
102116
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d,
103117
]);
104118
getMessageContentMock.mockResolvedValueOnce(chunks([mp4]));
105-
vi.spyOn(fs.promises, "writeFile").mockResolvedValueOnce(undefined);
106119

107120
const result = await downloadLineMedia("mid-mp4", "token");
108121

109122
expect(result.contentType).toBe("video/mp4");
110-
expect(result.path).toMatch(/\.mp4$/);
123+
expect(saveMediaBufferMock.mock.calls[0]?.[1]).toBe("video/mp4");
124+
});
125+
126+
it("propagates media store failures", async () => {
127+
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
128+
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
129+
saveMediaBufferMock.mockRejectedValueOnce(new Error("Media exceeds 0MB limit"));
130+
131+
await expect(downloadLineMedia("mid-bad", "token")).rejects.toThrow(/Media exceeds/i);
111132
});
112133
});

extensions/line/src/download.ts

Lines changed: 5 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import fs from "node:fs";
21
import { messagingApi } from "@line/bot-sdk";
2+
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
33
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
4-
import { buildRandomTempFilePath } from "openclaw/plugin-sdk/temp-path";
54
import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/text-runtime";
65

76
interface DownloadResult {
@@ -35,15 +34,12 @@ export async function downloadLineMedia(
3534

3635
const buffer = Buffer.concat(chunks);
3736
const contentType = detectContentType(buffer);
38-
const ext = getExtensionForContentType(contentType);
39-
const filePath = buildRandomTempFilePath({ prefix: "line-media", extension: ext });
40-
41-
await fs.promises.writeFile(filePath, buffer);
42-
logVerbose(`line: downloaded media ${messageId} to ${filePath} (${buffer.length} bytes)`);
37+
const saved = await saveMediaBuffer(buffer, contentType, "inbound", maxBytes);
38+
logVerbose(`line: persisted media ${messageId} to ${saved.path} (${buffer.length} bytes)`);
4339

4440
return {
45-
path: filePath,
46-
contentType,
41+
path: saved.path,
42+
contentType: saved.contentType,
4743
size: buffer.length,
4844
};
4945
}
@@ -89,24 +85,3 @@ function detectContentType(buffer: Buffer): string {
8985

9086
return "application/octet-stream";
9187
}
92-
93-
function getExtensionForContentType(contentType: string): string {
94-
switch (contentType) {
95-
case "image/jpeg":
96-
return ".jpg";
97-
case "image/png":
98-
return ".png";
99-
case "image/gif":
100-
return ".gif";
101-
case "image/webp":
102-
return ".webp";
103-
case "video/mp4":
104-
return ".mp4";
105-
case "audio/mp4":
106-
return ".m4a";
107-
case "audio/mpeg":
108-
return ".mp3";
109-
default:
110-
return ".bin";
111-
}
112-
}

0 commit comments

Comments
 (0)