mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 20:20:42 +00:00
fix(line): persist inbound media in shared store
This commit is contained in:
@@ -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