mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 04:50:44 +00:00
fix: route rich menu images through media loader
This commit is contained in:
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user