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 <steipete@macos.shared>
This commit is contained in:
Peter Steinberger
2026-08-01 01:17:34 -07:00
committed by GitHub
parent 6baf4dd15f
commit 8b7fad18ec
4 changed files with 284 additions and 53 deletions

View File

@@ -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 <type> "title" "body" [options]
@@ -40,6 +40,16 @@ function buildLineReply(lineData: LineChannelData): ReplyPayload {
};
}
function buildLineFlexReply(
altText: string,
contents: Parameters<typeof createFlexMessage>[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 <image-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": {

View File

@@ -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<unknown>;
};
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<void>,
): Promise<void> {
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<void>((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<void>((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<unknown>;
@@ -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);
});

View File

@@ -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<typeof parseLineDirectives>) =>
(result.channelData?.line as Record<string, unknown> | 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", () => {

View File

@@ -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,
};
}