fix: route rich menu images through media loader

This commit is contained in:
jesse-merhi
2026-04-29 13:09:20 +10:00
parent 4cf2284667
commit 2b8c20c8a3
2 changed files with 124 additions and 12 deletions

View File

@@ -1,13 +1,30 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createGridLayout,
messageAction,
uriAction,
postbackAction,
datetimePickerAction,
createDefaultMenuConfig,
createGridLayout,
datetimePickerAction,
messageAction,
postbackAction,
uploadRichMenuImage,
uriAction,
} from "./rich-menu.js";
const { setRichMenuImageMock, MessagingApiBlobClientMock } = vi.hoisted(() => {
const setRichMenuImageMock = vi.fn();
const MessagingApiBlobClientMock = vi.fn(function () {
return { setRichMenuImage: setRichMenuImageMock };
});
return { setRichMenuImageMock, MessagingApiBlobClientMock };
});
vi.mock("@line/bot-sdk", () => ({
messagingApi: { MessagingApiBlobClient: MessagingApiBlobClientMock },
}));
describe("messageAction", () => {
it("creates message actions with explicit or default text", () => {
const cases = [
@@ -205,3 +222,89 @@ describe("createDefaultMenuConfig", () => {
expect(commands).toContain("/settings");
});
});
const richMenuUploadCfg: OpenClawConfig = {
channels: {
line: {
channelAccessToken: "line-token",
channelSecret: "line-secret",
},
},
};
describe("uploadRichMenuImage", () => {
let tempRoot: string;
beforeEach(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-line-rich-menu-"));
setRichMenuImageMock.mockReset();
MessagingApiBlobClientMock.mockClear();
});
afterEach(async () => {
await fs.rm(tempRoot, { recursive: true, force: true });
});
it("loads local image paths through approved media localRoots", async () => {
const workspaceDir = path.join(tempRoot, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
const imagePath = path.join(workspaceDir, "menu.png");
const imageBytes = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00,
]);
await fs.writeFile(imagePath, imageBytes);
await uploadRichMenuImage("rich-menu-1", imagePath, {
cfg: richMenuUploadCfg,
mediaLocalRoots: [workspaceDir],
});
expect(MessagingApiBlobClientMock).toHaveBeenCalledWith({ channelAccessToken: "line-token" });
expect(setRichMenuImageMock).toHaveBeenCalledOnce();
const [richMenuId, blob] = setRichMenuImageMock.mock.calls[0] ?? [];
expect(richMenuId).toBe("rich-menu-1");
expect(blob).toBeInstanceOf(Blob);
expect((blob as Blob).type).toBe("image/png");
await expect((blob as Blob).arrayBuffer()).resolves.toEqual(
imageBytes.buffer.slice(imageBytes.byteOffset, imageBytes.byteOffset + imageBytes.byteLength),
);
});
it("rejects local image paths outside approved media localRoots before uploading", async () => {
const workspaceDir = path.join(tempRoot, "workspace");
const outsideDir = path.join(tempRoot, "outside");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.mkdir(outsideDir, { recursive: true });
const outsideImagePath = path.join(outsideDir, "menu.jpg");
await fs.writeFile(outsideImagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
await expect(
uploadRichMenuImage("rich-menu-1", outsideImagePath, {
cfg: richMenuUploadCfg,
mediaLocalRoots: [workspaceDir],
}),
).rejects.toThrow(/Local media path is not under an allowed directory/i);
expect(setRichMenuImageMock).not.toHaveBeenCalled();
});
it("preserves extension-based content-type fallback for approved local paths", async () => {
const workspaceDir = path.join(tempRoot, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
const imagePath = path.join(workspaceDir, "menu.jpg");
const imageBytes = Buffer.from("placeholder image bytes");
await fs.writeFile(imagePath, imageBytes);
await uploadRichMenuImage("rich-menu-2", imagePath, {
cfg: richMenuUploadCfg,
mediaLocalRoots: [workspaceDir],
});
expect(setRichMenuImageMock).toHaveBeenCalledOnce();
const blob = setRichMenuImageMock.mock.calls[0]?.[1] as Blob;
expect(blob.type).toBe("image/jpeg");
await expect(blob.arrayBuffer()).resolves.toEqual(
imageBytes.buffer.slice(imageBytes.byteOffset, imageBytes.byteOffset + imageBytes.byteLength),
);
});
});

View File

@@ -1,8 +1,9 @@
import { readFile } from "node:fs/promises";
import { messagingApi } from "@line/bot-sdk";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/agent-media-payload";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media";
import { resolveLineAccount } from "./accounts.js";
import { datetimePickerAction, messageAction, postbackAction, uriAction } from "./actions.js";
import { resolveLineChannelAccessToken } from "./channel-access-token.js";
@@ -41,6 +42,7 @@ interface RichMenuOpts {
channelAccessToken?: string;
accountId?: string;
verbose?: boolean;
mediaLocalRoots?: readonly string[];
}
function getClient(opts: RichMenuOpts): messagingApi.MessagingApiClient {
@@ -105,12 +107,19 @@ export async function uploadRichMenuImage(
): Promise<void> {
const blobClient = getBlobClient(opts);
const imageData = await readFile(imagePath);
const contentType = normalizeLowercaseStringOrEmpty(imagePath).endsWith(".png")
? "image/png"
: "image/jpeg";
const media = await loadWebMediaRaw(imagePath, {
localRoots: opts.mediaLocalRoots ?? getAgentScopedMediaLocalRoots(opts.cfg),
});
const contentType =
media.contentType === "image/png" || media.contentType === "image/jpeg"
? media.contentType
: normalizeLowercaseStringOrEmpty(imagePath).endsWith(".png")
? "image/png"
: "image/jpeg";
await blobClient.setRichMenuImage(richMenuId, new Blob([imageData], { type: contentType }));
const imageBytes = new ArrayBuffer(media.buffer.byteLength);
new Uint8Array(imageBytes).set(media.buffer);
await blobClient.setRichMenuImage(richMenuId, new Blob([imageBytes], { type: contentType }));
if (opts.verbose) {
logVerbose(`line: uploaded image to rich menu ${richMenuId}`);