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:
ly-wang19
2026-06-29 02:59:59 +08:00
committed by GitHub
parent 630034ef62
commit 4b36cf451c
2 changed files with 69 additions and 7 deletions
+53
View File
@@ -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", () => {
+16 -7
View File
@@ -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,
};
}