mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 06:11:37 +00:00
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:
committed by
GitHub
parent
6baf4dd15f
commit
8b7fad18ec
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user