mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
03fafe2364
commit
02b529a8cc
@@ -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" } },
|
||||
|
||||
@@ -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) : [];
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user