Skip to content

Commit 8b8b72c

Browse files
committed
fix(line): count rich menu limits by grapheme
1 parent 402842c commit 8b8b72c

2 files changed

Lines changed: 65 additions & 5 deletions

File tree

extensions/line/src/rich-menu.test.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import path from "node:path";
55
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
66
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
77
import {
8+
createRichMenu,
89
createDefaultMenuConfig,
910
createGridLayout,
1011
datetimePickerAction,
@@ -14,19 +15,33 @@ import {
1415
uriAction,
1516
} from "./rich-menu.js";
1617

17-
const { setRichMenuImageMock, MessagingApiBlobClientMock } = vi.hoisted(() => {
18+
const {
19+
createRichMenuMock,
20+
setRichMenuImageMock,
21+
MessagingApiClientMock,
22+
MessagingApiBlobClientMock,
23+
} = vi.hoisted(() => {
24+
const createRichMenuMockLocal = vi.fn();
1825
const setRichMenuImageMockLocal = vi.fn();
26+
const MessagingApiClientMockLocal = vi.fn(function () {
27+
return { createRichMenu: createRichMenuMockLocal };
28+
});
1929
const MessagingApiBlobClientMockLocal = vi.fn(function () {
2030
return { setRichMenuImage: setRichMenuImageMockLocal };
2131
});
2232
return {
33+
createRichMenuMock: createRichMenuMockLocal,
2334
setRichMenuImageMock: setRichMenuImageMockLocal,
35+
MessagingApiClientMock: MessagingApiClientMockLocal,
2436
MessagingApiBlobClientMock: MessagingApiBlobClientMockLocal,
2537
};
2638
});
2739

2840
vi.mock("@line/bot-sdk", () => ({
29-
messagingApi: { MessagingApiBlobClient: MessagingApiBlobClientMock },
41+
messagingApi: {
42+
MessagingApiClient: MessagingApiClientMock,
43+
MessagingApiBlobClient: MessagingApiBlobClientMock,
44+
},
3045
}));
3146

3247
afterAll(() => {
@@ -241,6 +256,37 @@ const richMenuUploadCfg: OpenClawConfig = {
241256
},
242257
};
243258

259+
describe("createRichMenu", () => {
260+
beforeEach(() => {
261+
createRichMenuMock.mockReset();
262+
createRichMenuMock.mockResolvedValue({ richMenuId: "rich-menu-1" });
263+
MessagingApiClientMock.mockClear();
264+
});
265+
266+
it("truncates names and chat bar text by grapheme cluster", async () => {
267+
const emoji = "😀";
268+
const familyEmoji = "👨‍👩‍👧‍👦";
269+
270+
await createRichMenu(
271+
{
272+
size: { width: 2500, height: 843 },
273+
name: emoji.repeat(301),
274+
chatBarText: familyEmoji.repeat(15),
275+
areas: [],
276+
},
277+
{ cfg: richMenuUploadCfg },
278+
);
279+
280+
expect(MessagingApiClientMock).toHaveBeenCalledWith({ channelAccessToken: "line-token" });
281+
expect(createRichMenuMock).toHaveBeenCalledWith(
282+
expect.objectContaining({
283+
name: emoji.repeat(300),
284+
chatBarText: familyEmoji.repeat(14),
285+
}),
286+
);
287+
});
288+
});
289+
244290
describe("uploadRichMenuImage", () => {
245291
let tempRoot: string;
246292

extensions/line/src/rich-menu.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/agent-media-p
44
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
55
import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
66
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
7-
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
87
import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media";
98
import { resolveLineAccount } from "./accounts.js";
109
import { datetimePickerAction, messageAction, postbackAction, uriAction } from "./actions.js";
@@ -15,6 +14,8 @@ type RichMenuResponse = messagingApi.RichMenuResponse;
1514
type RichMenuArea = messagingApi.RichMenuArea;
1615
type Action = messagingApi.Action;
1716
const USER_BATCH_SIZE = 500;
17+
// LINE counts rich-menu names and chat-bar text in grapheme clusters, unlike most message fields.
18+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
1819

1920
export interface RichMenuSize {
2021
width: 2500;
@@ -79,6 +80,19 @@ function chunkUserIds(userIds: string[]): string[][] {
7980
return batches;
8081
}
8182

83+
function truncateGraphemes(input: string, maxLength: number): string {
84+
let result = "";
85+
let count = 0;
86+
for (const { segment } of graphemeSegmenter.segment(input)) {
87+
if (count >= maxLength) {
88+
break;
89+
}
90+
result += segment;
91+
count += 1;
92+
}
93+
return result;
94+
}
95+
8296
export async function createRichMenu(
8397
menu: CreateRichMenuParams,
8498
opts: RichMenuOpts,
@@ -88,8 +102,8 @@ export async function createRichMenu(
88102
const richMenuRequest: RichMenuRequest = {
89103
size: menu.size,
90104
selected: menu.selected ?? false,
91-
name: truncateUtf16Safe(menu.name, 300),
92-
chatBarText: truncateUtf16Safe(menu.chatBarText, 14),
105+
name: truncateGraphemes(menu.name, 300),
106+
chatBarText: truncateGraphemes(menu.chatBarText, 14),
93107
areas: menu.areas as RichMenuArea[],
94108
};
95109

0 commit comments

Comments
 (0)