mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-08 19:12:22 +00:00
fix(line): cap carousel column text at 60 chars when a title or image is set (#93429)
* fix(line): cap carousel column text at 60 chars with title or image LINE limits a carousel column's text to 60 characters when the column has a title or thumbnail image, and 120 characters otherwise. createCarouselColumn always truncated to 120, so a column with a title/image and 61-120 char text exceeded the limit and made LINE reject the entire carousel reply (HTTP 400). Apply the conditional limit (mirroring the buttons template) and drop the now redundant slice in createProductCarousel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(line): apply conditional text limits across templates * fix(line): truncate template text by code point * fix(line): preserve grapheme clusters when truncating * fix(line): apply compact limit for default actions * fix(line): follow title and thumbnail text limits * fix(line): truncate template text within UTF-16 limits * fix(line): preserve required text within template limits * fix(line): preserve carousel product prices --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
This commit is contained in:
@@ -51,13 +51,13 @@ describe("createButtonTemplate", () => {
|
||||
expect((template.template as { text: string }).text.length).toBe(60);
|
||||
});
|
||||
|
||||
it("keeps longer text when thumbnail is provided", () => {
|
||||
it("truncates text to 60 chars when title and thumbnail are provided", () => {
|
||||
const longText = "x".repeat(100);
|
||||
const template = createButtonTemplate("Title", longText, [messageAction("OK")], {
|
||||
thumbnailImageUrl: "https://example.com/thumb.jpg",
|
||||
});
|
||||
|
||||
expect((template.template as { text: string }).text.length).toBe(100);
|
||||
expect((template.template as { text: string }).text.length).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,12 +77,67 @@ describe("createCarouselColumn", () => {
|
||||
expect(column.actions.length).toBe(3);
|
||||
});
|
||||
|
||||
it("truncates text to 120 characters", () => {
|
||||
it("truncates text to 120 characters when no title or image is set", () => {
|
||||
const longText = "x".repeat(150);
|
||||
const column = createCarouselColumn({ text: longText, actions: [messageAction("OK")] });
|
||||
|
||||
expect(column.text.length).toBe(120);
|
||||
});
|
||||
|
||||
it("truncates text to 60 characters when a title is set", () => {
|
||||
const longText = "x".repeat(150);
|
||||
const column = createCarouselColumn({
|
||||
title: "Title",
|
||||
text: longText,
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text.length).toBe(60);
|
||||
});
|
||||
|
||||
it("does not split an emoji grapheme at the 60-code-unit boundary", () => {
|
||||
const text = `${"x".repeat(59)}👨👩👧👦after`;
|
||||
const column = createCarouselColumn({
|
||||
title: "Title",
|
||||
text,
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text).toBe("x".repeat(59));
|
||||
});
|
||||
|
||||
it("keeps required text when the first grapheme exceeds the limit", () => {
|
||||
const text = `😀${"\u0301".repeat(59)}`;
|
||||
const column = createCarouselColumn({
|
||||
title: "Title",
|
||||
text,
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text.length).toBe(60);
|
||||
expect(column.text.startsWith("😀")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the compact limit when a whitespace-only title is present", () => {
|
||||
const column = createCarouselColumn({
|
||||
title: " ",
|
||||
text: "x".repeat(150),
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text).toBe("x".repeat(60));
|
||||
});
|
||||
|
||||
it("truncates text to 60 characters when a thumbnail image is set", () => {
|
||||
const longText = "x".repeat(150);
|
||||
const column = createCarouselColumn({
|
||||
text: longText,
|
||||
thumbnailImageUrl: "https://example.com/thumb.jpg",
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text.length).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("carousel column limits", () => {
|
||||
@@ -131,6 +186,20 @@ describe("createProductCarousel", () => {
|
||||
.columns;
|
||||
expect(columns[0].actions[0].type).toBe(expectedType);
|
||||
});
|
||||
|
||||
it("preserves the complete price when truncating a long description", () => {
|
||||
const template = createProductCarousel([
|
||||
{
|
||||
title: "Product",
|
||||
description: "x".repeat(59),
|
||||
price: "$12.99",
|
||||
},
|
||||
]);
|
||||
const columns = (template.template as { columns: Array<{ text: string }> }).columns;
|
||||
|
||||
expect(columns[0].text).toBe(`${"x".repeat(53)}\n$12.99`);
|
||||
expect(columns[0].text.length).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flex cards", () => {
|
||||
|
||||
@@ -13,6 +13,9 @@ type CarouselColumn = messagingApi.CarouselColumn;
|
||||
type ImageCarouselTemplate = messagingApi.ImageCarouselTemplate;
|
||||
type ImageCarouselColumn = messagingApi.ImageCarouselColumn;
|
||||
|
||||
const COMPACT_TEMPLATE_TEXT_LIMIT = 60;
|
||||
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
|
||||
type TemplatePayloadAction = {
|
||||
type?: "uri" | "postback" | "message";
|
||||
uri?: string;
|
||||
@@ -30,6 +33,48 @@ function buildTemplatePayloadAction(action: TemplatePayloadAction): Action {
|
||||
return messageAction(action.label, action.data ?? action.label);
|
||||
}
|
||||
|
||||
function resolveTemplateTextLimit(params: {
|
||||
title?: string;
|
||||
thumbnailImageUrl?: string;
|
||||
textOnlyLimit: number;
|
||||
}): number {
|
||||
return params.title !== undefined || params.thumbnailImageUrl !== undefined
|
||||
? COMPACT_TEMPLATE_TEXT_LIMIT
|
||||
: params.textOnlyLimit;
|
||||
}
|
||||
|
||||
function truncateTemplateText(text: string, limit: number): string {
|
||||
let result = "";
|
||||
for (const { segment } of graphemeSegmenter.segment(text)) {
|
||||
if (result.length + segment.length > limit) {
|
||||
// A pathological grapheme can exceed LINE's whole field limit. Preserve
|
||||
// graphemes normally, but keep required text non-empty without splitting
|
||||
// a surrogate pair when the first grapheme alone cannot fit.
|
||||
if (!result) {
|
||||
for (const codePoint of segment) {
|
||||
if (result.length + codePoint.length > limit) {
|
||||
break;
|
||||
}
|
||||
result += codePoint;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
result += segment;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatProductCarouselText(description: string, price?: string): string {
|
||||
if (!price) {
|
||||
return description;
|
||||
}
|
||||
const priceText = truncateTemplateText(price, COMPACT_TEMPLATE_TEXT_LIMIT);
|
||||
const descriptionLimit = Math.max(0, COMPACT_TEMPLATE_TEXT_LIMIT - priceText.length - 1);
|
||||
const descriptionText = truncateTemplateText(description, descriptionLimit);
|
||||
return descriptionText ? `${descriptionText}\n${priceText}` : priceText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a confirm template (yes/no style dialog)
|
||||
*/
|
||||
@@ -68,12 +113,15 @@ export function createButtonTemplate(
|
||||
altText?: string;
|
||||
},
|
||||
): TemplateMessage {
|
||||
const hasThumbnail = Boolean(options?.thumbnailImageUrl?.trim());
|
||||
const textLimit = hasThumbnail ? 160 : 60;
|
||||
const textLimit = resolveTemplateTextLimit({
|
||||
title,
|
||||
thumbnailImageUrl: options?.thumbnailImageUrl,
|
||||
textOnlyLimit: 160,
|
||||
});
|
||||
const template: ButtonsTemplate = {
|
||||
type: "buttons",
|
||||
title: title.slice(0, 40), // LINE limit
|
||||
text: text.slice(0, textLimit), // LINE limit (60 if no thumbnail, 160 with thumbnail)
|
||||
text: truncateTemplateText(text, textLimit),
|
||||
actions: actions.slice(0, 4), // LINE limit: max 4 actions
|
||||
thumbnailImageUrl: options?.thumbnailImageUrl,
|
||||
imageAspectRatio: options?.imageAspectRatio ?? "rectangle",
|
||||
@@ -125,9 +173,14 @@ export function createCarouselColumn(params: {
|
||||
imageBackgroundColor?: string;
|
||||
defaultAction?: Action;
|
||||
}): CarouselColumn {
|
||||
// LINE caps a carousel column's text at 60 chars when the column carries a
|
||||
// title or thumbnail image, and 120 chars otherwise. Sending an over-length
|
||||
// text makes LINE reject the whole carousel, so mirror the conditional limit
|
||||
// the buttons template already applies above.
|
||||
const textLimit = resolveTemplateTextLimit({ ...params, textOnlyLimit: 120 });
|
||||
return {
|
||||
title: params.title?.slice(0, 40),
|
||||
text: params.text.slice(0, 120), // LINE limit
|
||||
text: truncateTemplateText(params.text, textLimit),
|
||||
actions: params.actions.slice(0, 3), // LINE limit: max 3 actions per column
|
||||
thumbnailImageUrl: params.thumbnailImageUrl,
|
||||
imageBackgroundColor: params.imageBackgroundColor,
|
||||
@@ -256,9 +309,7 @@ export function createProductCarousel(
|
||||
|
||||
return createCarouselColumn({
|
||||
title: product.title,
|
||||
text: product.price
|
||||
? `${product.description}\n${product.price}`.slice(0, 120)
|
||||
: product.description,
|
||||
text: formatProductCarouselText(product.description, product.price),
|
||||
thumbnailImageUrl: product.imageUrl,
|
||||
actions,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user