From 02b529a8ccf99f9a0db45934573c2be6db89d94a Mon Sep 17 00:00:00 2001 From: Sachintha Bhashitha Date: Sat, 4 Jul 2026 12:16:13 +0530 Subject: [PATCH] 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 --- .../line/src/auto-reply-delivery.test.ts | 25 ++++++++++ extensions/line/src/auto-reply-delivery.ts | 7 ++- extensions/line/src/card-command.ts | 16 +++--- extensions/line/src/markdown-to-line.test.ts | 15 ++++++ extensions/line/src/markdown-to-line.ts | 4 +- extensions/line/src/message-cards.test.ts | 21 ++++++++ extensions/line/src/outbound.ts | 9 ++-- extensions/line/src/rich-menu.test.ts | 50 ++++++++++++++++++- extensions/line/src/rich-menu.ts | 19 ++++++- extensions/line/src/send.ts | 7 +-- 10 files changed, 153 insertions(+), 20 deletions(-) diff --git a/extensions/line/src/auto-reply-delivery.test.ts b/extensions/line/src/auto-reply-delivery.test.ts index 6a4d71d5e8e1..eace254a55ee 100644 --- a/extensions/line/src/auto-reply-delivery.test.ts +++ b/extensions/line/src/auto-reply-delivery.test.ts @@ -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])|(? { const lineData = { flexMessage: { altText: "Card", contents: { type: "bubble" } }, diff --git a/extensions/line/src/auto-reply-delivery.ts b/extensions/line/src/auto-reply-delivery.ts index 9226a7cd9b43..81067198bdfe 100644 --- a/extensions/line/src/auto-reply-delivery.ts +++ b/extensions/line/src/auto-reply-delivery.ts @@ -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) : []; diff --git a/extensions/line/src/card-command.ts b/extensions/line/src/card-command.ts index bdd595b61b3d..a124e6fce6ea 100644 --- a/extensions/line/src/card-command.ts +++ b/extensions/line/src/card-command.ts @@ -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, diff --git a/extensions/line/src/markdown-to-line.test.ts b/extensions/line/src/markdown-to-line.test.ts index 526f568322ee..eb757cabfc7b 100644 --- a/extensions/line/src/markdown-to-line.test.ts +++ b/extensions/line/src/markdown-to-line.test.ts @@ -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])|(? { diff --git a/extensions/line/src/markdown-to-line.ts b/extensions/line/src/markdown-to-line.ts index 87c71a7b4367..3da24d362bd1 100644 --- a/extensions/line/src/markdown-to-line.ts +++ b/extensions/line/src/markdown-to-line.ts @@ -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", diff --git a/extensions/line/src/message-cards.test.ts b/extensions/line/src/message-cards.test.ts index c346df261417..ba16091c1dcb 100644 --- a/extensions/line/src/message-cards.test.ts +++ b/extensions/line/src/message-cards.test.ts @@ -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; + }; + 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", diff --git a/extensions/line/src/outbound.ts b/extensions/line/src/outbound.ts index 1bf7d69ee25a..a1418c3b7f40 100644 --- a/extensions/line/src/outbound.ts +++ b/extensions/line/src/outbound.ts @@ -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 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 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 for (const flexMsg of processed.flexMessages) { quickReplyMessages.push({ type: "flex", - altText: flexMsg.altText.slice(0, 400), + altText: truncateUtf16Safe(flexMsg.altText, 400), contents: flexMsg.contents, }); } diff --git a/extensions/line/src/rich-menu.test.ts b/extensions/line/src/rich-menu.test.ts index 344e2c5bb628..b132c009e71f 100644 --- a/extensions/line/src/rich-menu.test.ts +++ b/extensions/line/src/rich-menu.test.ts @@ -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; diff --git a/extensions/line/src/rich-menu.ts b/extensions/line/src/rich-menu.ts index 810ef80ceba7..2b6b4a5b867e 100644 --- a/extensions/line/src/rich-menu.ts +++ b/extensions/line/src/rich-menu.ts @@ -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[], }; diff --git a/extensions/line/src/send.ts b/extensions/line/src/send.ts index d1c7cfaca887..b63060ddce5e 100644 --- a/extensions/line/src/send.ts +++ b/extensions/line/src/send.ts @@ -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 { const flexMessage: FlexMessage = { type: "flex", - altText: altText.slice(0, 400), + altText: truncateUtf16Safe(altText, 400), contents, };