From 8b7fad18ecc59aae8a8a2f318a5b4a5f491dab4f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 01:17:34 -0700 Subject: [PATCH] fix(line): centralize card and template message alt text (#117280) * fix(line): unify card and template alternative text limits * test(line): use synchronous provider request listener --------- Co-authored-by: Peter Steinberger --- extensions/line/src/card-command.ts | 59 ++--- extensions/line/src/message-cards.test.ts | 235 +++++++++++++++++- .../line/src/reply-payload-transform.test.ts | 25 ++ extensions/line/src/template-messages.ts | 18 +- 4 files changed, 284 insertions(+), 53 deletions(-) diff --git a/extensions/line/src/card-command.ts b/extensions/line/src/card-command.ts index b7482c98eeea..5c0114561f63 100644 --- a/extensions/line/src/card-command.ts +++ b/extensions/line/src/card-command.ts @@ -3,7 +3,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; 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, @@ -14,6 +13,7 @@ import { type CardAction, type ListItem, } from "./flex-templates.js"; +import { createFlexMessage } from "./send.js"; import type { LineChannelData } from "./types.js"; const CARD_USAGE = `Usage: /card "title" "body" [options] @@ -40,6 +40,16 @@ function buildLineReply(lineData: LineChannelData): ReplyPayload { }; } +function buildLineFlexReply( + altText: string, + contents: Parameters[1], +): ReplyPayload { + const message = createFlexMessage(altText, contents); + return buildLineReply({ + flexMessage: { altText: message.altText, contents: message.contents }, + }); +} + /** * Parse action string format: "Label|data,Label2|data2" * Data can be a URL (uri action) or plain text (message action) or key=value (postback) @@ -188,12 +198,7 @@ export function registerLineCardCommand(api: OpenClawPluginApi): void { case "info": { const [title = "Info", body = "", footer] = args; const bubble = createInfoCard(title, body, footer); - return buildLineReply({ - flexMessage: { - altText: truncateUtf16Safe(`${title}: ${body}`, 400), - contents: bubble, - }, - }); + return buildLineFlexReply(`${title}: ${body}`, bubble); } case "image": { @@ -203,12 +208,7 @@ export function registerLineCardCommand(api: OpenClawPluginApi): void { return { text: "Error: Image card requires --url " }; } const bubble = createImageCard(imageUrl, title, caption); - return buildLineReply({ - flexMessage: { - altText: truncateUtf16Safe(`${title}: ${caption}`, 400), - contents: bubble, - }, - }); + return buildLineFlexReply(`${title}: ${caption}`, bubble); } case "action": { @@ -220,12 +220,7 @@ export function registerLineCardCommand(api: OpenClawPluginApi): void { const bubble = createActionCard(title, body, actions, { imageUrl: flags.url || flags.image, }); - return buildLineReply({ - flexMessage: { - altText: truncateUtf16Safe(`${title}: ${body}`, 400), - contents: bubble, - }, - }); + return buildLineFlexReply(`${title}: ${body}`, bubble); } case "list": { @@ -237,15 +232,10 @@ export function registerLineCardCommand(api: OpenClawPluginApi): void { }; } const bubble = createListCard(title, items); - return buildLineReply({ - flexMessage: { - altText: truncateUtf16Safe( - `${title}: ${items.map((i) => i.title).join(", ")}`, - 400, - ), - contents: bubble, - }, - }); + return buildLineFlexReply( + `${title}: ${items.map((item) => item.title).join(", ")}`, + bubble, + ); } case "receipt": { @@ -261,15 +251,10 @@ export function registerLineCardCommand(api: OpenClawPluginApi): void { } const bubble = createReceiptCard({ title, items, total, footer }); - return buildLineReply({ - flexMessage: { - altText: truncateUtf16Safe( - `${title}: ${items.map((i) => `${i.name} ${i.value}`).join(", ")}`, - 400, - ), - contents: bubble, - }, - }); + return buildLineFlexReply( + `${title}: ${items.map((item) => `${item.name} ${item.value}`).join(", ")}`, + bubble, + ); } case "confirm": { diff --git a/extensions/line/src/message-cards.test.ts b/extensions/line/src/message-cards.test.ts index 2c623315d0f6..a7031b0fe200 100644 --- a/extensions/line/src/message-cards.test.ts +++ b/extensions/line/src/message-cards.test.ts @@ -1,4 +1,6 @@ // Line tests cover message cards plugin behavior. +import { createServer } from "node:http"; +import { messagingApi } from "@line/bot-sdk"; import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it } from "vitest"; import { @@ -34,6 +36,133 @@ import { } from "./template-messages.js"; const loneHighSurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/; +const lineFlexCardCommandScenarios = [ + { + kind: "info", + args: (body: string) => `info "Title" "${body}"`, + expectedAltText: (body: string) => `Title: ${body}`, + }, + { + kind: "image", + args: (body: string) => `image "Title" "${body}" --url https://example.test/image.png`, + expectedAltText: (body: string) => `Title: ${body}`, + }, + { + kind: "action", + args: (body: string) => `action "Title" "${body}" --actions "Open|ok"`, + expectedAltText: (body: string) => `Title: ${body}`, + }, + { + kind: "list", + args: (body: string) => `list "Title" "${body}|Description"`, + expectedAltText: (body: string) => `Title: ${body}`, + }, + { + kind: "receipt", + args: (body: string) => `receipt "Title" "${body}:$1" --total "$1"`, + expectedAltText: (body: string) => `Title: ${body} $1`, + }, +] as const; + +const lineTemplateMessageScenarios = [ + { + kind: "confirm", + create: (altText: string) => + createConfirmTemplate("q".repeat(300), messageAction("Yes"), messageAction("No"), altText), + bodyLimit: 240, + }, + { + kind: "buttons", + create: (altText: string) => + createButtonTemplate("Menu", "b".repeat(200), [messageAction("Open")], { altText }), + bodyLimit: 60, + }, + { + kind: "carousel", + create: (altText: string) => + createTemplateCarousel( + [createCarouselColumn({ text: "c".repeat(150), actions: [messageAction("Open")] })], + { altText }, + ), + bodyLimit: 120, + }, + { + kind: "image carousel", + create: (altText: string) => + createImageCarousel( + [createImageCarouselColumn("https://example.test/image.png", messageAction("Open"))], + altText, + ), + }, +] as const; + +async function runLineFlexCardCommand( + args: string, +): Promise<{ altText: string; contents: messagingApi.FlexContainer }> { + const result = (await registerCommandWithHandler((command: unknown) => { + const { handler } = command as { + handler: (ctx: { args: string; channel: string }) => Promise; + }; + return handler({ channel: "line", args }); + })) as { + channelData: { + line: { flexMessage: { altText: string; contents: messagingApi.FlexContainer } }; + }; + }; + return result.channelData.line.flexMessage; +} + +type LineProviderRequest = { + path: string; + authenticated: boolean; + type: string; + altText: string; +}; + +async function withLineProvider( + run: (client: messagingApi.MessagingApiClient, requests: LineProviderRequest[]) => Promise, +): Promise { + const requests: LineProviderRequest[] = []; + const server = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => { + chunks.push(chunk); + }); + request.once("end", () => { + const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { + messages: Array<{ type: string; altText: string }>; + }; + requests.push({ + path: request.url ?? "", + authenticated: request.headers.authorization === "Bearer isolated-test-token", + type: payload.messages[0]?.type ?? "", + altText: payload.messages[0]?.altText ?? "", + }); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ sentMessages: [{ id: `card-${requests.length}` }] })); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("LINE card provider did not bind a TCP port"); + } + const client = new messagingApi.MessagingApiClient({ + channelAccessToken: "isolated-test-token", + baseURL: `http://127.0.0.1:${address.port}`, + }); + await run(client, requests); + } finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +} describe("createConfirmTemplate", () => { it("truncates text to 240 characters", () => { @@ -45,12 +174,12 @@ describe("createConfirmTemplate", () => { it("drops a surrogate-pair emoji from fallback altText instead of splitting it", () => { const template = createConfirmTemplate( - `${"x".repeat(399)}😀`, + `${"x".repeat(1499)}😀`, messageAction("Yes"), messageAction("No"), ); - expect(template.altText).toBe("x".repeat(399)); + expect(template.altText).toBe("x".repeat(1499)); expect(loneHighSurrogate.test(template.altText)).toBe(false); }); }); @@ -96,10 +225,10 @@ describe("createButtonTemplate", () => { it("drops a surrogate-pair emoji from explicit altText instead of splitting it", () => { const template = createButtonTemplate("Title", "Text", [messageAction("OK")], { - altText: `${"x".repeat(399)}😀`, + altText: `${"x".repeat(1499)}😀`, }); - expect(template.altText).toBe("x".repeat(399)); + expect(template.altText).toBe("x".repeat(1499)); expect(loneHighSurrogate.test(template.altText)).toBe(false); }); @@ -236,10 +365,10 @@ describe("carousel column limits", () => { 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)}😀`, + `${"x".repeat(1499)}😀`, ); - expect(template.altText).toBe("x".repeat(399)); + expect(template.altText).toBe("x".repeat(1499)); expect(loneHighSurrogate.test(template.altText)).toBe(false); }); @@ -488,9 +617,95 @@ describe("action label/data surrogate-safe truncation", () => { }); }); - 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. + it.each(lineFlexCardCommandScenarios)( + "/card $kind preserves provider-valid Flex alternative text", + async (scenario) => { + const body = "a".repeat(1200); + const message = await runLineFlexCardCommand(scenario.args(body)); + + expect(message.altText).toBe(scenario.expectedAltText(body)); + }, + ); + + it.each(lineFlexCardCommandScenarios)( + "/card $kind bounds alternative text without splitting a Unicode surrogate pair", + async (scenario) => { + const body = `${"a".repeat(1492)}😀 overflow`; + const { altText } = await runLineFlexCardCommand(scenario.args(body)); + + expect(altText).toBe(`Title: ${"a".repeat(1492)}`); + expect(loneHighSurrogate.test(altText)).toBe(false); + }, + ); + + it("preserves every Flex card command through the real LINE provider SDK", async () => { + await withLineProvider(async (client, received) => { + const body = "a".repeat(1200); + + for (const scenario of lineFlexCardCommandScenarios) { + const message = await runLineFlexCardCommand(scenario.args(body)); + await client.pushMessage({ + to: "U123", + messages: [{ type: "flex", altText: message.altText, contents: message.contents }], + }); + } + + expect(received).toHaveLength(lineFlexCardCommandScenarios.length); + expect(received.every((request) => request.authenticated)).toBe(true); + expect(received.every((request) => request.type === "flex")).toBe(true); + expect(received.map((request) => request.altText)).toEqual( + lineFlexCardCommandScenarios.map((scenario) => scenario.expectedAltText(body)), + ); + }); + }); + + it.each(lineTemplateMessageScenarios)( + "preserves provider-valid $kind alternative text without changing inner text limits", + (scenario) => { + const altText = "a".repeat(1200); + const message = scenario.create(altText); + + expect(message.altText).toBe(altText); + if ("bodyLimit" in scenario) { + const template = message.template as { + text?: string; + columns?: Array<{ text?: string }>; + }; + expect((template.text ?? template.columns?.[0]?.text)?.length).toBe(scenario.bodyLimit); + } + }, + ); + + it.each(lineTemplateMessageScenarios)( + "bounds $kind alternative text at the provider's Unicode-safe 1500-unit limit", + (scenario) => { + const message = scenario.create(`${"a".repeat(1499)}😀 overflow`); + + expect(message.altText).toBe("a".repeat(1499)); + expect(loneHighSurrogate.test(message.altText)).toBe(false); + }, + ); + + it("preserves all template families through real SDK push and reply requests", async () => { + await withLineProvider(async (client, received) => { + const altText = "a".repeat(1200); + + for (const scenario of lineTemplateMessageScenarios) { + const message = scenario.create(altText); + await client.pushMessage({ to: "U123", messages: [message] }); + await client.replyMessage({ replyToken: "reply-token", messages: [message] }); + } + + expect(received).toHaveLength(lineTemplateMessageScenarios.length * 2); + expect(received.every((request) => request.authenticated)).toBe(true); + expect(received.every((request) => request.type === "template")).toBe(true); + expect(received.every((request) => request.altText === altText)).toBe(true); + expect(received.filter((request) => request.path.endsWith("/push"))).toHaveLength(4); + expect(received.filter((request) => request.path.endsWith("/reply"))).toHaveLength(4); + }); + }); + + it("/card receipt preserves a provider-valid Unicode alternative-text boundary", async () => { const registerCommand = (command: unknown) => { const { handler } = command as { handler: (ctx: { args: string; channel: string }) => Promise; @@ -505,7 +720,7 @@ describe("action label/data surrogate-safe truncation", () => { }; const altText = result.channelData.line.flexMessage.altText; - expect(altText.length).toBeLessThanOrEqual(400); + expect(altText).toBe(`R: ${"a".repeat(395)} 😀x`); expect(loneHighSurrogate.test(altText)).toBe(false); }); diff --git a/extensions/line/src/reply-payload-transform.test.ts b/extensions/line/src/reply-payload-transform.test.ts index 93c1f1ab6457..8758975ece88 100644 --- a/extensions/line/src/reply-payload-transform.test.ts +++ b/extensions/line/src/reply-payload-transform.test.ts @@ -1,6 +1,7 @@ // Line tests cover reply payload transform plugin behavior. import { describe, expect, it } from "vitest"; import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js"; +import { buildTemplateMessageFromPayload } from "./template-messages.js"; const getLineData = (result: ReturnType) => (result.channelData?.line as Record | undefined) ?? {}; @@ -163,6 +164,18 @@ describe("parseLineDirectives", () => { expect(result.text, testCase.name).toBe(testCase.expectedText); } }); + + it("preserves the full provider-valid confirm alternative text through template delivery", () => { + const question = "q".repeat(1200); + const parsed = parseLineDirectives({ text: `[[confirm: ${question} | Yes | No]]` }); + const payload = getLineData(parsed).templateMessage as Parameters< + typeof buildTemplateMessageFromPayload + >[0]; + const message = buildTemplateMessageFromPayload(payload); + + expect(message?.altText).toBe(question); + expect((message?.template as { text?: string } | undefined)?.text).toHaveLength(240); + }); }); describe("buttons", () => { @@ -229,6 +242,18 @@ describe("parseLineDirectives", () => { } } }); + + it("preserves the full provider-valid button alternative text through template delivery", () => { + const text = "b".repeat(1200); + const parsed = parseLineDirectives({ text: `[[buttons: Menu | ${text} | Open:ok]]` }); + const payload = getLineData(parsed).templateMessage as Parameters< + typeof buildTemplateMessageFromPayload + >[0]; + const message = buildTemplateMessageFromPayload(payload); + + expect(message?.altText).toBe(`Menu: ${text}`); + expect((message?.template as { text?: string } | undefined)?.text).toHaveLength(60); + }); }); describe("blank required template fields", () => { diff --git a/extensions/line/src/template-messages.ts b/extensions/line/src/template-messages.ts index bc9c8d2b0199..90d8e42fd06d 100644 --- a/extensions/line/src/template-messages.ts +++ b/extensions/line/src/template-messages.ts @@ -18,6 +18,7 @@ type ImageCarouselTemplate = messagingApi.ImageCarouselTemplate; type ImageCarouselColumn = messagingApi.ImageCarouselColumn; const COMPACT_TEMPLATE_TEXT_LIMIT = 60; +const TEMPLATE_ALT_TEXT_LIMIT = 1500; const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); type TemplatePayloadAction = { @@ -76,6 +77,10 @@ function truncateOptionalTemplateText( return value === undefined ? undefined : truncateTemplateText(value, limit); } +function resolveTemplateAltText(value: string | undefined, fallback: string): string { + return truncateTemplateText(value ?? fallback, TEMPLATE_ALT_TEXT_LIMIT); +} + function formatProductCarouselText(description: string, price?: string): string { if (!price) { return description; @@ -119,7 +124,7 @@ export function createConfirmTemplate( return { type: "template", - altText: truncateOptionalTemplateText(altText, 400) ?? truncateTemplateText(text, 400), + altText: resolveTemplateAltText(altText, text), template, }; } @@ -161,9 +166,10 @@ export function createButtonTemplate( return { type: "template", - altText: - truncateOptionalTemplateText(options?.altText, 400) ?? - truncateTemplateText(normalizedTitle ? `${normalizedTitle}: ${text}` : text, 400), + altText: resolveTemplateAltText( + options?.altText, + normalizedTitle ? `${normalizedTitle}: ${text}` : text, + ), template, }; } @@ -188,7 +194,7 @@ export function createTemplateCarousel( return { type: "template", - altText: truncateOptionalTemplateText(options?.altText, 400) ?? "View carousel", + altText: resolveTemplateAltText(options?.altText, "View carousel"), template, }; } @@ -234,7 +240,7 @@ export function createImageCarousel( return { type: "template", - altText: truncateOptionalTemplateText(altText, 400) ?? "View images", + altText: resolveTemplateAltText(altText, "View images"), template, }; }