mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(line): truncate template title/altText on grapheme boundaries, not raw UTF-16 (#97428)
* fix(line): truncate template title/altText on grapheme boundaries, not raw UTF-16 createConfirmTemplate/createButtonTemplate/createTemplateCarousel/createCarouselColumn/ createImageCarousel truncated title and altText with a raw `.slice(0, N)`, so an emoji straddling a LINE field limit (e.g. a 40-char button title) was cut in half, leaving a lone high surrogate that LINE renders as the replacement char or rejects. Route those fields through the file's existing grapheme-safe truncateTemplateText (already used for the text body) via a small truncateOptionalTemplateText wrapper. Byte-identical for all-BMP input; only straddling-emoji truncation changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: retry OpenGrep scan (HTTP 502 infra flake) * test(line): cover grapheme-safe template fields --------- Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
@@ -23,6 +23,8 @@ import {
|
||||
messageAction,
|
||||
} from "./template-messages.js";
|
||||
|
||||
const loneHighSurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/;
|
||||
|
||||
describe("createConfirmTemplate", () => {
|
||||
it("truncates text to 240 characters", () => {
|
||||
const longText = "x".repeat(300);
|
||||
@@ -30,6 +32,17 @@ describe("createConfirmTemplate", () => {
|
||||
|
||||
expect((template.template as { text: string }).text.length).toBe(240);
|
||||
});
|
||||
|
||||
it("drops a surrogate-pair emoji from fallback altText instead of splitting it", () => {
|
||||
const template = createConfirmTemplate(
|
||||
`${"x".repeat(399)}😀`,
|
||||
messageAction("Yes"),
|
||||
messageAction("No"),
|
||||
);
|
||||
|
||||
expect(template.altText).toBe("x".repeat(399));
|
||||
expect(loneHighSurrogate.test(template.altText)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createButtonTemplate", () => {
|
||||
@@ -47,6 +60,25 @@ describe("createButtonTemplate", () => {
|
||||
expect((template.template as { title: string }).title.length).toBe(40);
|
||||
});
|
||||
|
||||
it("drops a surrogate-pair emoji from the title instead of splitting it", () => {
|
||||
// 39 chars + an emoji land the truncation boundary inside the surrogate pair;
|
||||
// a raw code-unit slice would keep only the lone high surrogate.
|
||||
const template = createButtonTemplate(`${"x".repeat(39)}😀`, "Text", [messageAction("OK")]);
|
||||
const title = (template.template as { title: string }).title;
|
||||
|
||||
expect(title).toBe("x".repeat(39));
|
||||
expect(loneHighSurrogate.test(title)).toBe(false);
|
||||
});
|
||||
|
||||
it("drops a surrogate-pair emoji from explicit altText instead of splitting it", () => {
|
||||
const template = createButtonTemplate("Title", "Text", [messageAction("OK")], {
|
||||
altText: `${"x".repeat(399)}😀`,
|
||||
});
|
||||
|
||||
expect(template.altText).toBe("x".repeat(399));
|
||||
expect(loneHighSurrogate.test(template.altText)).toBe(false);
|
||||
});
|
||||
|
||||
it("truncates text to 60 chars when no thumbnail is provided", () => {
|
||||
const longText = "x".repeat(100);
|
||||
const template = createButtonTemplate("Title", longText, [messageAction("OK")]);
|
||||
@@ -98,6 +130,17 @@ describe("createCarouselColumn", () => {
|
||||
expect(column.text.length).toBe(60);
|
||||
});
|
||||
|
||||
it("drops a surrogate-pair emoji from the title instead of splitting it", () => {
|
||||
const column = createCarouselColumn({
|
||||
title: `${"x".repeat(39)}😀`,
|
||||
text: "Text",
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.title).toBe("x".repeat(39));
|
||||
expect(loneHighSurrogate.test(column.title ?? "")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not split an emoji grapheme at the 60-code-unit boundary", () => {
|
||||
const text = `${"x".repeat(59)}👨👩👧👦after`;
|
||||
const column = createCarouselColumn({
|
||||
@@ -165,6 +208,16 @@ describe("carousel column limits", () => {
|
||||
const template = createTemplate();
|
||||
expect((template.template as { columns: unknown[] }).columns.length).toBe(10);
|
||||
});
|
||||
|
||||
it("drops a surrogate-pair emoji from image-carousel altText instead of splitting it", () => {
|
||||
const template = createImageCarousel(
|
||||
[createImageCarouselColumn("https://example.com/0.jpg", messageAction("View"))],
|
||||
`${"x".repeat(399)}😀`,
|
||||
);
|
||||
|
||||
expect(template.altText).toBe("x".repeat(399));
|
||||
expect(loneHighSurrogate.test(template.altText)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createProductCarousel", () => {
|
||||
|
||||
@@ -65,6 +65,13 @@ function truncateTemplateText(text: string, limit: number): string {
|
||||
return result;
|
||||
}
|
||||
|
||||
function truncateOptionalTemplateText(
|
||||
value: string | undefined,
|
||||
limit: number,
|
||||
): string | undefined {
|
||||
return value === undefined ? undefined : truncateTemplateText(value, limit);
|
||||
}
|
||||
|
||||
function formatProductCarouselText(description: string, price?: string): string {
|
||||
if (!price) {
|
||||
return description;
|
||||
@@ -86,13 +93,13 @@ export function createConfirmTemplate(
|
||||
): TemplateMessage {
|
||||
const template: ConfirmTemplate = {
|
||||
type: "confirm",
|
||||
text: text.slice(0, 240), // LINE limit
|
||||
text: truncateTemplateText(text, 240), // LINE limit
|
||||
actions: [confirmAction, cancelAction],
|
||||
};
|
||||
|
||||
return {
|
||||
type: "template",
|
||||
altText: altText?.slice(0, 400) ?? text.slice(0, 400),
|
||||
altText: truncateOptionalTemplateText(altText, 400) ?? truncateTemplateText(text, 400),
|
||||
template,
|
||||
};
|
||||
}
|
||||
@@ -120,7 +127,7 @@ export function createButtonTemplate(
|
||||
});
|
||||
const template: ButtonsTemplate = {
|
||||
type: "buttons",
|
||||
title: title.slice(0, 40), // LINE limit
|
||||
title: truncateTemplateText(title, 40), // LINE limit
|
||||
text: truncateTemplateText(text, textLimit),
|
||||
actions: actions.slice(0, 4), // LINE limit: max 4 actions
|
||||
thumbnailImageUrl: options?.thumbnailImageUrl,
|
||||
@@ -132,7 +139,9 @@ export function createButtonTemplate(
|
||||
|
||||
return {
|
||||
type: "template",
|
||||
altText: options?.altText?.slice(0, 400) ?? `${title}: ${text}`.slice(0, 400),
|
||||
altText:
|
||||
truncateOptionalTemplateText(options?.altText, 400) ??
|
||||
truncateTemplateText(`${title}: ${text}`, 400),
|
||||
template,
|
||||
};
|
||||
}
|
||||
@@ -157,7 +166,7 @@ export function createTemplateCarousel(
|
||||
|
||||
return {
|
||||
type: "template",
|
||||
altText: options?.altText?.slice(0, 400) ?? "View carousel",
|
||||
altText: truncateOptionalTemplateText(options?.altText, 400) ?? "View carousel",
|
||||
template,
|
||||
};
|
||||
}
|
||||
@@ -179,7 +188,7 @@ export function createCarouselColumn(params: {
|
||||
// the buttons template already applies above.
|
||||
const textLimit = resolveTemplateTextLimit({ ...params, textOnlyLimit: 120 });
|
||||
return {
|
||||
title: params.title?.slice(0, 40),
|
||||
title: truncateOptionalTemplateText(params.title, 40),
|
||||
text: truncateTemplateText(params.text, textLimit),
|
||||
actions: params.actions.slice(0, 3), // LINE limit: max 3 actions per column
|
||||
thumbnailImageUrl: params.thumbnailImageUrl,
|
||||
@@ -202,7 +211,7 @@ export function createImageCarousel(
|
||||
|
||||
return {
|
||||
type: "template",
|
||||
altText: altText?.slice(0, 400) ?? "View images",
|
||||
altText: truncateOptionalTemplateText(altText, 400) ?? "View images",
|
||||
template,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user