fix(line): truncate outbound altText, location, menu, and code fields on code point boundaries (#98994)

* fix(line): truncate outbound altText, location, menu, and code fields on code-point boundaries

* fix(line): use safe truncation for receipt card altText

* fix(line): count rich menu limits by grapheme

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Sachintha Bhashitha
2026-07-04 12:16:13 +05:30
committed by GitHub
parent 03fafe2364
commit 02b529a8cc
10 changed files with 153 additions and 20 deletions
@@ -109,6 +109,31 @@ describe("deliverLineAutoReply", () => {
expect(createQuickReplyItems).not.toHaveBeenCalled();
});
it("truncates flex altText on a surrogate boundary", async () => {
// The emoji's surrogate pair straddles LINE's 400-char altText cap; a raw
// slice used to send a lone high surrogate to the LINE API.
const lineData = {
flexMessage: { altText: `${"a".repeat(399)}😀 overflow`, contents: { type: "bubble" } },
};
const createFlexMessageSpy = vi.fn(createFlexMessage);
const { deps } = createDeps({
createFlexMessage: createFlexMessageSpy as LineAutoReplyDeps["createFlexMessage"],
});
await deliverLineAutoReply({
...baseDeliveryParams,
payload: { text: "hello", channelData: { line: lineData } },
lineData,
deps,
});
const sentAltText = createFlexMessageSpy.mock.calls[0]?.[0] ?? "";
expect(sentAltText.length).toBeLessThanOrEqual(400);
expect(
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(sentAltText),
).toBe(false);
});
it("uses reply token for rich-only payloads", async () => {
const lineData = {
flexMessage: { altText: "Card", contents: { type: "bubble" } },
+5 -2
View File
@@ -3,6 +3,7 @@ import type { messagingApi } from "@line/bot-sdk";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type { FlexContainer } from "./flex-templates.js";
import type { ProcessedLineMessage } from "./markdown-to-line.js";
import { buildLineQuickReplyFallbackText } from "./quick-reply-fallback.js";
@@ -103,7 +104,7 @@ export async function deliverLineAutoReply(params: {
if (lineData.flexMessage) {
richMessages.push(
deps.createFlexMessage(
lineData.flexMessage.altText.slice(0, 400),
truncateUtf16Safe(lineData.flexMessage.altText, 400),
lineData.flexMessage.contents as FlexContainer,
),
);
@@ -125,7 +126,9 @@ export async function deliverLineAutoReply(params: {
: { text: "", flexMessages: [] };
for (const flexMsg of processed.flexMessages) {
richMessages.push(deps.createFlexMessage(flexMsg.altText.slice(0, 400), flexMsg.contents));
richMessages.push(
deps.createFlexMessage(truncateUtf16Safe(flexMsg.altText, 400), flexMsg.contents),
);
}
const chunks = processed.text ? deps.chunkMarkdownText(processed.text, textLimit) : [];
+10 -6
View File
@@ -2,6 +2,7 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { messageAction, postbackAction, uriAction } from "./actions.js";
import {
createActionCard,
@@ -187,7 +188,7 @@ export function registerLineCardCommand(api: OpenClawPluginApi): void {
const bubble = createInfoCard(title, body, footer);
return buildLineReply({
flexMessage: {
altText: `${title}: ${body}`.slice(0, 400),
altText: truncateUtf16Safe(`${title}: ${body}`, 400),
contents: bubble,
},
});
@@ -202,7 +203,7 @@ export function registerLineCardCommand(api: OpenClawPluginApi): void {
const bubble = createImageCard(imageUrl, title, caption);
return buildLineReply({
flexMessage: {
altText: `${title}: ${caption}`.slice(0, 400),
altText: truncateUtf16Safe(`${title}: ${caption}`, 400),
contents: bubble,
},
});
@@ -219,7 +220,7 @@ export function registerLineCardCommand(api: OpenClawPluginApi): void {
});
return buildLineReply({
flexMessage: {
altText: `${title}: ${body}`.slice(0, 400),
altText: truncateUtf16Safe(`${title}: ${body}`, 400),
contents: bubble,
},
});
@@ -236,7 +237,10 @@ export function registerLineCardCommand(api: OpenClawPluginApi): void {
const bubble = createListCard(title, items);
return buildLineReply({
flexMessage: {
altText: `${title}: ${items.map((i) => i.title).join(", ")}`.slice(0, 400),
altText: truncateUtf16Safe(
`${title}: ${items.map((i) => i.title).join(", ")}`,
400,
),
contents: bubble,
},
});
@@ -257,8 +261,8 @@ export function registerLineCardCommand(api: OpenClawPluginApi): void {
const bubble = createReceiptCard({ title, items, total, footer });
return buildLineReply({
flexMessage: {
altText: `${title}: ${items.map((i) => `${i.name} ${i.value}`).join(", ")}`.slice(
0,
altText: truncateUtf16Safe(
`${title}: ${items.map((i) => `${i.name} ${i.value}`).join(", ")}`,
400,
),
contents: bubble,
@@ -277,6 +277,21 @@ describe("convertCodeBlockToFlexBubble", () => {
expect(codeText.length).toBeLessThan(longCode.length);
expect(codeText).toContain("...");
});
it("does not split a surrogate pair at the truncation boundary", () => {
// The emoji's surrogate pair straddles the 2000-char cap; a raw slice
// would leave a lone high surrogate at the end of the code text.
const block = { code: `${"x".repeat(1999)}😀${"y".repeat(500)}` };
const bubble = convertCodeBlockToFlexBubble(block);
const body = bubble.body as { contents: Array<{ contents: Array<{ text: string }> }> };
const codeText = body.contents[1].contents[0].text;
expect(codeText.endsWith("\n...")).toBe(true);
expect(
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(codeText),
).toBe(false);
});
});
describe("processLineMessage", () => {
+3 -1
View File
@@ -1,6 +1,7 @@
// Line plugin module implements markdown to line behavior.
import type { messagingApi } from "@line/bot-sdk";
import { stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { uriAction } from "./actions.js";
import { createReceiptCard, toFlexMessage, type FlexBubble } from "./flex-templates.js";
export { stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
@@ -234,7 +235,8 @@ export function convertCodeBlockToFlexBubble(block: CodeBlock): FlexBubble {
const titleText = block.language ? `Code (${block.language})` : "Code";
// Truncate very long code to fit LINE's limits
const displayCode = block.code.length > 2000 ? block.code.slice(0, 2000) + "\n..." : block.code;
const displayCode =
block.code.length > 2000 ? truncateUtf16Safe(block.code, 2000) + "\n..." : block.code;
return {
type: "bubble",
+21
View File
@@ -420,6 +420,27 @@ describe("action label/data surrogate-safe truncation", () => {
expect(loneHighSurrogate.test(action.data)).toBe(false);
});
it("/card receipt altText truncates on a surrogate boundary", async () => {
// The emoji's surrogate pair straddles the 400-char altText cap; a raw
// slice used to leave a lone high surrogate in the receipt flex altText.
const registerCommand = (command: unknown) => {
const { handler } = command as {
handler: (ctx: { args: string; channel: string }) => Promise<unknown>;
};
return handler({
channel: "line",
args: `receipt "R" "${"a".repeat(395)}:😀x" --total "$30"`,
});
};
const result = (await registerCommandWithHandler(registerCommand)) as {
channelData: { line: { flexMessage: { altText: string } } };
};
const altText = result.channelData.line.flexMessage.altText;
expect(altText.length).toBeLessThanOrEqual(400);
expect(loneHighSurrogate.test(altText)).toBe(false);
});
it("media control postback labels truncate on surrogate boundaries", () => {
const card = createMediaPlayerCard({
title: "Track",
+5 -4
View File
@@ -10,6 +10,7 @@ import {
} from "openclaw/plugin-sdk/channel-send-result";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type { ChannelPlugin, ResolvedLineAccount } from "./channel-api.js";
import { resolveLineOutboundMedia, type LineOutboundMediaResolved } from "./outbound-media.js";
import { buildLineQuickReplyFallbackText } from "./quick-reply-fallback.js";
@@ -244,7 +245,7 @@ export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>
if (lineData.flexMessage) {
quickReplyMessages.push({
type: "flex",
altText: lineData.flexMessage.altText.slice(0, 400),
altText: truncateUtf16Safe(lineData.flexMessage.altText, 400),
contents: lineData.flexMessage.contents,
});
}
@@ -257,8 +258,8 @@ export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>
if (lineData.location) {
quickReplyMessages.push({
type: "location",
title: lineData.location.title.slice(0, 100),
address: lineData.location.address.slice(0, 100),
title: truncateUtf16Safe(lineData.location.title, 100),
address: truncateUtf16Safe(lineData.location.address, 100),
latitude: lineData.location.latitude,
longitude: lineData.location.longitude,
});
@@ -266,7 +267,7 @@ export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>
for (const flexMsg of processed.flexMessages) {
quickReplyMessages.push({
type: "flex",
altText: flexMsg.altText.slice(0, 400),
altText: truncateUtf16Safe(flexMsg.altText, 400),
contents: flexMsg.contents,
});
}
+48 -2
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createRichMenu,
createDefaultMenuConfig,
createGridLayout,
datetimePickerAction,
@@ -14,19 +15,33 @@ import {
uriAction,
} from "./rich-menu.js";
const { setRichMenuImageMock, MessagingApiBlobClientMock } = vi.hoisted(() => {
const {
createRichMenuMock,
setRichMenuImageMock,
MessagingApiClientMock,
MessagingApiBlobClientMock,
} = vi.hoisted(() => {
const createRichMenuMockLocal = vi.fn();
const setRichMenuImageMockLocal = vi.fn();
const MessagingApiClientMockLocal = vi.fn(function () {
return { createRichMenu: createRichMenuMockLocal };
});
const MessagingApiBlobClientMockLocal = vi.fn(function () {
return { setRichMenuImage: setRichMenuImageMockLocal };
});
return {
createRichMenuMock: createRichMenuMockLocal,
setRichMenuImageMock: setRichMenuImageMockLocal,
MessagingApiClientMock: MessagingApiClientMockLocal,
MessagingApiBlobClientMock: MessagingApiBlobClientMockLocal,
};
});
vi.mock("@line/bot-sdk", () => ({
messagingApi: { MessagingApiBlobClient: MessagingApiBlobClientMock },
messagingApi: {
MessagingApiClient: MessagingApiClientMock,
MessagingApiBlobClient: MessagingApiBlobClientMock,
},
}));
afterAll(() => {
@@ -241,6 +256,37 @@ const richMenuUploadCfg: OpenClawConfig = {
},
};
describe("createRichMenu", () => {
beforeEach(() => {
createRichMenuMock.mockReset();
createRichMenuMock.mockResolvedValue({ richMenuId: "rich-menu-1" });
MessagingApiClientMock.mockClear();
});
it("truncates names and chat bar text by grapheme cluster", async () => {
const emoji = "😀";
const familyEmoji = "👨‍👩‍👧‍👦";
await createRichMenu(
{
size: { width: 2500, height: 843 },
name: emoji.repeat(301),
chatBarText: familyEmoji.repeat(15),
areas: [],
},
{ cfg: richMenuUploadCfg },
);
expect(MessagingApiClientMock).toHaveBeenCalledWith({ channelAccessToken: "line-token" });
expect(createRichMenuMock).toHaveBeenCalledWith(
expect.objectContaining({
name: emoji.repeat(300),
chatBarText: familyEmoji.repeat(14),
}),
);
});
});
describe("uploadRichMenuImage", () => {
let tempRoot: string;
+17 -2
View File
@@ -14,6 +14,8 @@ type RichMenuResponse = messagingApi.RichMenuResponse;
type RichMenuArea = messagingApi.RichMenuArea;
type Action = messagingApi.Action;
const USER_BATCH_SIZE = 500;
// LINE counts rich-menu names and chat-bar text in grapheme clusters, unlike most message fields.
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
export interface RichMenuSize {
width: 2500;
@@ -78,6 +80,19 @@ function chunkUserIds(userIds: string[]): string[][] {
return batches;
}
function truncateGraphemes(input: string, maxLength: number): string {
let result = "";
let count = 0;
for (const { segment } of graphemeSegmenter.segment(input)) {
if (count >= maxLength) {
break;
}
result += segment;
count += 1;
}
return result;
}
export async function createRichMenu(
menu: CreateRichMenuParams,
opts: RichMenuOpts,
@@ -87,8 +102,8 @@ export async function createRichMenu(
const richMenuRequest: RichMenuRequest = {
size: menu.size,
selected: menu.selected ?? false,
name: menu.name.slice(0, 300),
chatBarText: menu.chatBarText.slice(0, 14),
name: truncateGraphemes(menu.name, 300),
chatBarText: truncateGraphemes(menu.chatBarText, 14),
areas: menu.areas as RichMenuArea[],
};
+4 -3
View File
@@ -4,6 +4,7 @@ import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runt
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { resolveLineAccount } from "./accounts.js";
import { messageAction } from "./actions.js";
import { resolveLineChannelAccessToken } from "./channel-access-token.js";
@@ -162,8 +163,8 @@ export function createLocationMessage(location: {
}): LocationMessage {
return {
type: "location",
title: location.title.slice(0, 100),
address: location.address.slice(0, 100),
title: truncateUtf16Safe(location.title, 100),
address: truncateUtf16Safe(location.address, 100),
latitude: location.latitude,
longitude: location.longitude,
};
@@ -419,7 +420,7 @@ export async function pushFlexMessage(
): Promise<LineSendResult> {
const flexMessage: FlexMessage = {
type: "flex",
altText: altText.slice(0, 400),
altText: truncateUtf16Safe(altText, 400),
contents,
};