mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(line): keep replies deliverable when action data or button URLs exceed LINE's size caps (#113081)
* fix(line): cap flex postback data and action URIs at LINE's size limits * fix(line): preserve encoded action boundaries * fix(line): surface unavailable oversized actions * fix(line): centralize oversized callback fallback * test(line): pin UTF-16 action limits * fix(line): fit fallback labels in image carousels * fix(line): normalize raw actions at builder boundaries * fix(line): normalize remaining flex actions * fix(line): enforce action limits at send boundary * fix(line): preserve message action identity * fix(line): surface unavailable video links * fix(line): render non-button action warnings * fix(line): count action limits by code point * fix(line): normalize imagemap actions * fix(line): normalize imagemap video links * fix(line): bound imagemap video labels * fix(line): satisfy code-point lint guard * fix(line): finalize imagemap action limits Co-authored-by: 許元豪 <146086744+edenfunf@users.noreply.github.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
+379
-16
@@ -3,49 +3,412 @@ import type { messagingApi } from "@line/bot-sdk";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
|
||||
export type Action = messagingApi.Action;
|
||||
type Message = messagingApi.Message;
|
||||
type ImagemapAction = messagingApi.ImagemapAction;
|
||||
type ImagemapVideo = messagingApi.ImagemapVideo;
|
||||
const LINE_ACTION_LABEL_LIMIT = 20;
|
||||
const LINE_ACTION_DATA_LIMIT = 300;
|
||||
const LINE_ACTION_URI_LIMIT = 1000;
|
||||
const LINE_CLIPBOARD_TEXT_LIMIT = 1000;
|
||||
const LINE_RICH_MENU_ALIAS_LIMIT = 32;
|
||||
const LINE_IMAGEMAP_ACTION_LABEL_LIMIT = 100;
|
||||
const LINE_IMAGEMAP_MESSAGE_TEXT_LIMIT = 400;
|
||||
const LINE_IMAGEMAP_EXTERNAL_LINK_LABEL_LIMIT = 30;
|
||||
const LINE_IMAGEMAP_ACTION_LIMIT = 50;
|
||||
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
|
||||
function truncateLineActionText(text: string, limit: number): string {
|
||||
let result = "";
|
||||
let count = 0;
|
||||
for (const { segment } of graphemeSegmenter.segment(text)) {
|
||||
const codePointCount = Array.from(segment).length;
|
||||
if (count + codePointCount > limit) {
|
||||
break;
|
||||
}
|
||||
result += segment;
|
||||
count += codePointCount;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function truncateLineActionLabel(label: string, limit = LINE_ACTION_LABEL_LIMIT): string {
|
||||
return truncateUtf16Safe(label, limit);
|
||||
const truncated = truncateLineActionText(label, limit);
|
||||
return truncated || (label ? "…" : "");
|
||||
}
|
||||
|
||||
function truncateLineActionData(data: string): string {
|
||||
return truncateUtf16Safe(data, LINE_ACTION_DATA_LIMIT);
|
||||
}
|
||||
|
||||
const unavailableActionMarker = Symbol("lineUnavailableAction");
|
||||
type UnavailableAction = Extract<Action, { type: "message" }> & {
|
||||
[unavailableActionMarker]: true;
|
||||
};
|
||||
|
||||
function unavailableAction(kind: "Action" | "Link", reason: string): Action {
|
||||
const action = {
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: `${kind} unavailable: ${reason}`,
|
||||
} satisfies Action;
|
||||
Object.defineProperty(action, unavailableActionMarker, { value: true });
|
||||
return action;
|
||||
}
|
||||
|
||||
const actionTypes = new Set([
|
||||
"camera",
|
||||
"cameraRoll",
|
||||
"clipboard",
|
||||
"datetimepicker",
|
||||
"location",
|
||||
"message",
|
||||
"postback",
|
||||
"richmenuswitch",
|
||||
"uri",
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isLineAction(value: unknown): value is Action {
|
||||
return isRecord(value) && typeof value.type === "string" && actionTypes.has(value.type);
|
||||
}
|
||||
|
||||
function isUnavailableAction(action: Action): action is UnavailableAction {
|
||||
return (action as Partial<UnavailableAction>)[unavailableActionMarker] === true;
|
||||
}
|
||||
|
||||
function normalizeNestedActions(value: unknown, labelLimit: number, warnings?: string[]): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const normalized: unknown[] = [];
|
||||
for (const item of value) {
|
||||
normalized.push(normalizeNestedActions(item, labelLimit, warnings));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const normalized: Record<string, unknown> = { ...value };
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if ((key === "action" || key === "defaultAction") && isLineAction(nested)) {
|
||||
const action = normalizeLineAction(nested, labelLimit);
|
||||
if (
|
||||
warnings &&
|
||||
key === "action" &&
|
||||
((value.type === "video" && action.type !== "uri") ||
|
||||
(value.type !== "button" && isUnavailableAction(action)))
|
||||
) {
|
||||
delete normalized[key];
|
||||
warnings.push(
|
||||
isUnavailableAction(action)
|
||||
? (action.text ?? "Action unavailable.")
|
||||
: "Action unavailable in this video.",
|
||||
);
|
||||
} else {
|
||||
normalized[key] = action;
|
||||
}
|
||||
} else if (key === "actions" && Array.isArray(nested)) {
|
||||
normalized[key] = nested.map((action) =>
|
||||
isLineAction(action) ? normalizeLineAction(action, labelLimit) : action,
|
||||
);
|
||||
} else {
|
||||
normalized[key] = normalizeNestedActions(nested, labelLimit, warnings);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeFlexBubbleActions(value: unknown): unknown {
|
||||
if (!isRecord(value) || value.type !== "bubble") {
|
||||
return normalizeNestedActions(value, 40);
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const normalized = normalizeNestedActions(value, 40, warnings);
|
||||
if (!isRecord(normalized) || warnings.length === 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const warning = {
|
||||
type: "text",
|
||||
text: [...new Set(warnings)].join("\n"),
|
||||
wrap: true,
|
||||
size: "sm",
|
||||
color: "#B45309",
|
||||
margin: "md",
|
||||
};
|
||||
const body = normalized.body;
|
||||
if (isRecord(body) && Array.isArray(body.contents)) {
|
||||
normalized.body = { ...body, contents: [...body.contents, warning] };
|
||||
} else {
|
||||
normalized.body = { type: "box", layout: "vertical", contents: [warning] };
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeFlexContainerActions(value: unknown): unknown {
|
||||
if (!isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
if (value.type === "bubble") {
|
||||
return normalizeFlexBubbleActions(value);
|
||||
}
|
||||
if (value.type === "carousel" && Array.isArray(value.contents)) {
|
||||
return {
|
||||
...value,
|
||||
contents: value.contents.map((bubble) => normalizeFlexBubbleActions(bubble)),
|
||||
};
|
||||
}
|
||||
return normalizeNestedActions(value, 40);
|
||||
}
|
||||
|
||||
function unavailableImagemapAction(
|
||||
kind: "Action" | "Link",
|
||||
reason: string,
|
||||
area: ImagemapAction["area"],
|
||||
): ImagemapAction {
|
||||
return {
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: `${kind} unavailable: ${reason}`,
|
||||
area,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeImagemapAction(action: ImagemapAction): ImagemapAction {
|
||||
const label =
|
||||
action.label === undefined
|
||||
? undefined
|
||||
: truncateLineActionText(action.label, LINE_IMAGEMAP_ACTION_LABEL_LIMIT);
|
||||
|
||||
if (action.type === "uri") {
|
||||
if (truncateUtf16Safe(action.linkUri, LINE_ACTION_URI_LIMIT) !== action.linkUri) {
|
||||
return unavailableImagemapAction("Link", "URL exceeds LINE's limit.", action.area);
|
||||
}
|
||||
return { ...action, label };
|
||||
}
|
||||
|
||||
if (action.type === "message") {
|
||||
const text = truncateUtf16Safe(action.text, LINE_IMAGEMAP_MESSAGE_TEXT_LIMIT);
|
||||
if (text !== action.text) {
|
||||
return unavailableImagemapAction("Action", "message text exceeds LINE's limit.", action.area);
|
||||
}
|
||||
return { ...action, label, text };
|
||||
}
|
||||
|
||||
if (truncateUtf16Safe(action.clipboardText, LINE_CLIPBOARD_TEXT_LIMIT) !== action.clipboardText) {
|
||||
return unavailableImagemapAction("Action", "clipboard text exceeds LINE's limit.", action.area);
|
||||
}
|
||||
return { ...action, label };
|
||||
}
|
||||
|
||||
function normalizeImagemapVideo(video: ImagemapVideo): {
|
||||
video: ImagemapVideo;
|
||||
fallbackAction?: ImagemapAction;
|
||||
} {
|
||||
const externalLink = video.externalLink;
|
||||
if (!externalLink) {
|
||||
return { video };
|
||||
}
|
||||
|
||||
const label =
|
||||
externalLink.label === undefined
|
||||
? undefined
|
||||
: truncateUtf16Safe(externalLink.label, LINE_IMAGEMAP_EXTERNAL_LINK_LABEL_LIMIT) ||
|
||||
(externalLink.label ? "…" : "");
|
||||
if (
|
||||
externalLink.linkUri !== undefined &&
|
||||
truncateUtf16Safe(externalLink.linkUri, LINE_ACTION_URI_LIMIT) !== externalLink.linkUri
|
||||
) {
|
||||
const normalizedVideo = { ...video };
|
||||
delete normalizedVideo.externalLink;
|
||||
return {
|
||||
video: normalizedVideo,
|
||||
fallbackAction:
|
||||
video.area === undefined
|
||||
? undefined
|
||||
: unavailableImagemapAction("Link", "URL exceeds LINE's limit.", video.area),
|
||||
};
|
||||
}
|
||||
return { video: { ...video, externalLink: { ...externalLink, label } } };
|
||||
}
|
||||
|
||||
export function normalizeLineMessageActions(message: Message): Message {
|
||||
let normalized: Message;
|
||||
if (message.type === "flex") {
|
||||
normalized = {
|
||||
...message,
|
||||
contents: normalizeFlexContainerActions(message.contents) as messagingApi.FlexContainer,
|
||||
};
|
||||
} else if (message.type === "template") {
|
||||
const labelLimit = message.template.type === "image_carousel" ? 12 : 20;
|
||||
normalized = {
|
||||
...message,
|
||||
template: normalizeNestedActions(message.template, labelLimit) as messagingApi.Template,
|
||||
};
|
||||
} else if (message.type === "imagemap") {
|
||||
const actions = message.actions.map(normalizeImagemapAction);
|
||||
const videoResult = message.video ? normalizeImagemapVideo(message.video) : undefined;
|
||||
if (videoResult?.fallbackAction) {
|
||||
// At LINE's 50-action cap, silently drop the invalid video link so its
|
||||
// warning never displaces a valid action.
|
||||
if (actions.length < LINE_IMAGEMAP_ACTION_LIMIT) {
|
||||
actions.push(videoResult.fallbackAction);
|
||||
}
|
||||
}
|
||||
normalized = {
|
||||
...message,
|
||||
actions,
|
||||
video: videoResult?.video,
|
||||
};
|
||||
} else {
|
||||
normalized = { ...message };
|
||||
}
|
||||
|
||||
if (message.quickReply) {
|
||||
normalized = {
|
||||
...normalized,
|
||||
quickReply: normalizeNestedActions(message.quickReply, 20) as messagingApi.QuickReply,
|
||||
};
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeLineAction(action: Action, labelLimit = LINE_ACTION_LABEL_LIMIT): Action {
|
||||
if (isUnavailableAction(action)) {
|
||||
return action;
|
||||
}
|
||||
const label =
|
||||
action.label === undefined ? undefined : truncateLineActionLabel(action.label, labelLimit);
|
||||
|
||||
if (action.type === "uri") {
|
||||
const uriTooLong =
|
||||
action.uri !== undefined &&
|
||||
truncateUtf16Safe(action.uri, LINE_ACTION_URI_LIMIT) !== action.uri;
|
||||
const desktopUri = action.altUri?.desktop;
|
||||
const desktopUriTooLong =
|
||||
desktopUri !== undefined &&
|
||||
truncateUtf16Safe(desktopUri, LINE_ACTION_URI_LIMIT) !== desktopUri;
|
||||
if (uriTooLong || desktopUriTooLong) {
|
||||
return unavailableAction("Link", "URL exceeds LINE's limit.");
|
||||
}
|
||||
return { ...action, label };
|
||||
}
|
||||
|
||||
if (action.type === "postback") {
|
||||
const data = action.data === undefined ? undefined : truncateLineActionData(action.data);
|
||||
if (data !== action.data) {
|
||||
// Callback data is opaque and echoed back by LINE. Never dispatch a value
|
||||
// whose identity changed merely to satisfy the transport cap.
|
||||
return unavailableAction("Action", "callback data exceeds LINE's limit.");
|
||||
}
|
||||
const text =
|
||||
action.text === undefined
|
||||
? undefined
|
||||
: truncateLineActionText(action.text, LINE_ACTION_DATA_LIMIT);
|
||||
const fillInText =
|
||||
action.fillInText === undefined
|
||||
? undefined
|
||||
: truncateLineActionText(action.fillInText, LINE_ACTION_DATA_LIMIT);
|
||||
if (text !== action.text || fillInText !== action.fillInText) {
|
||||
return unavailableAction("Action", "message text exceeds LINE's limit.");
|
||||
}
|
||||
return {
|
||||
...action,
|
||||
label,
|
||||
data,
|
||||
displayText:
|
||||
action.displayText === undefined
|
||||
? undefined
|
||||
: truncateLineActionText(action.displayText, LINE_ACTION_DATA_LIMIT),
|
||||
text,
|
||||
fillInText,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === "datetimepicker") {
|
||||
const data = action.data === undefined ? undefined : truncateLineActionData(action.data);
|
||||
if (data !== action.data) {
|
||||
return unavailableAction("Action", "callback data exceeds LINE's limit.");
|
||||
}
|
||||
return { ...action, label, data };
|
||||
}
|
||||
|
||||
if (action.type === "message") {
|
||||
const text =
|
||||
action.text === undefined
|
||||
? undefined
|
||||
: truncateLineActionText(action.text, LINE_ACTION_DATA_LIMIT);
|
||||
if (text !== action.text) {
|
||||
return unavailableAction("Action", "message text exceeds LINE's limit.");
|
||||
}
|
||||
return {
|
||||
...action,
|
||||
label,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === "clipboard") {
|
||||
if (
|
||||
truncateUtf16Safe(action.clipboardText, LINE_CLIPBOARD_TEXT_LIMIT) !== action.clipboardText
|
||||
) {
|
||||
return unavailableAction("Action", "clipboard text exceeds LINE's limit.");
|
||||
}
|
||||
return { ...action, label };
|
||||
}
|
||||
|
||||
if (action.type === "richmenuswitch") {
|
||||
const data = action.data === undefined ? undefined : truncateLineActionData(action.data);
|
||||
const aliasTooLong =
|
||||
action.richMenuAliasId !== undefined &&
|
||||
truncateUtf16Safe(action.richMenuAliasId, LINE_RICH_MENU_ALIAS_LIMIT) !==
|
||||
action.richMenuAliasId;
|
||||
if (data !== action.data || aliasTooLong) {
|
||||
return unavailableAction("Action", "rich menu data exceeds LINE's limit.");
|
||||
}
|
||||
return { ...action, label, data };
|
||||
}
|
||||
|
||||
return action.label === label ? action : { ...action, label };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a message action (sends text when tapped)
|
||||
*/
|
||||
export function messageAction(label: string, text?: string): Action {
|
||||
return {
|
||||
return normalizeLineAction({
|
||||
type: "message",
|
||||
label: truncateLineActionLabel(label),
|
||||
label,
|
||||
text: text ?? label,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a URI action (opens a URL when tapped)
|
||||
*/
|
||||
export function uriAction(label: string, uri: string): Action {
|
||||
return {
|
||||
return normalizeLineAction({
|
||||
type: "uri",
|
||||
label: truncateLineActionLabel(label),
|
||||
label,
|
||||
uri,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a postback action (sends data to webhook when tapped)
|
||||
*/
|
||||
export function postbackAction(label: string, data: string, displayText?: string): Action {
|
||||
return {
|
||||
return normalizeLineAction({
|
||||
type: "postback",
|
||||
label: truncateLineActionLabel(label),
|
||||
data: truncateLineActionData(data),
|
||||
displayText: displayText === undefined ? undefined : truncateLineActionData(displayText),
|
||||
};
|
||||
label,
|
||||
data,
|
||||
displayText,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,13 +424,13 @@ export function datetimePickerAction(
|
||||
min?: string;
|
||||
},
|
||||
): Action {
|
||||
return {
|
||||
return normalizeLineAction({
|
||||
type: "datetimepicker",
|
||||
label: truncateLineActionLabel(label),
|
||||
data: truncateLineActionData(data),
|
||||
label,
|
||||
data,
|
||||
mode,
|
||||
initial: options?.initial,
|
||||
max: options?.max,
|
||||
min: options?.min,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeLineAction } from "../actions.js";
|
||||
// Line plugin module implements basic cards behavior.
|
||||
import { attachFooterText } from "./common.js";
|
||||
import type {
|
||||
@@ -149,7 +150,7 @@ export function createListCard(title: string, items: ListItem[]): FlexBubble {
|
||||
};
|
||||
|
||||
if (item.action) {
|
||||
itemBox.action = item.action;
|
||||
itemBox.action = normalizeLineAction(item.action, 40);
|
||||
}
|
||||
|
||||
return itemBox;
|
||||
@@ -209,7 +210,7 @@ export function createImageCard(
|
||||
size: "full",
|
||||
aspectRatio: options?.aspectRatio ?? "20:13",
|
||||
aspectMode: options?.aspectMode ?? "cover",
|
||||
action: options?.action,
|
||||
action: options?.action === undefined ? undefined : normalizeLineAction(options.action, 40),
|
||||
} as FlexImage,
|
||||
body: {
|
||||
type: "box",
|
||||
@@ -284,7 +285,7 @@ export function createActionCard(
|
||||
(action, index) =>
|
||||
({
|
||||
type: "button",
|
||||
action: action.action,
|
||||
action: normalizeLineAction(action.action, 40),
|
||||
style: index === 0 ? "primary" : "secondary",
|
||||
margin: index > 0 ? "sm" : undefined,
|
||||
}) as FlexButton,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Line plugin module implements media control cards behavior.
|
||||
import { truncateLineActionLabel } from "../actions.js";
|
||||
import { postbackAction, truncateLineActionLabel } from "../actions.js";
|
||||
import type {
|
||||
FlexBox,
|
||||
FlexBubble,
|
||||
@@ -158,11 +158,7 @@ export function createMediaPlayerCard(params: {
|
||||
if (controls.previous) {
|
||||
controlButtons.push({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label: "⏮",
|
||||
data: controls.previous.data,
|
||||
},
|
||||
action: postbackAction("⏮", controls.previous.data),
|
||||
style: "secondary",
|
||||
flex: 1,
|
||||
height: "sm",
|
||||
@@ -172,11 +168,7 @@ export function createMediaPlayerCard(params: {
|
||||
if (controls.play) {
|
||||
controlButtons.push({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label: "▶",
|
||||
data: controls.play.data,
|
||||
},
|
||||
action: postbackAction("▶", controls.play.data),
|
||||
style: isPlaying ? "secondary" : "primary",
|
||||
flex: 1,
|
||||
height: "sm",
|
||||
@@ -187,11 +179,7 @@ export function createMediaPlayerCard(params: {
|
||||
if (controls.pause) {
|
||||
controlButtons.push({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label: "⏸",
|
||||
data: controls.pause.data,
|
||||
},
|
||||
action: postbackAction("⏸", controls.pause.data),
|
||||
style: isPlaying ? "primary" : "secondary",
|
||||
flex: 1,
|
||||
height: "sm",
|
||||
@@ -202,11 +190,7 @@ export function createMediaPlayerCard(params: {
|
||||
if (controls.next) {
|
||||
controlButtons.push({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label: "⏭",
|
||||
data: controls.next.data,
|
||||
},
|
||||
action: postbackAction("⏭", controls.next.data),
|
||||
style: "secondary",
|
||||
flex: 1,
|
||||
height: "sm",
|
||||
@@ -232,11 +216,7 @@ export function createMediaPlayerCard(params: {
|
||||
(action, index) =>
|
||||
({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label: truncateLineActionLabel(action.label, 15),
|
||||
data: action.data,
|
||||
},
|
||||
action: postbackAction(truncateLineActionLabel(action.label, 15), action.data),
|
||||
style: "secondary",
|
||||
flex: 1,
|
||||
height: "sm",
|
||||
@@ -312,11 +292,7 @@ export function createAppleTvRemoteCard(params: {
|
||||
style: "primary" | "secondary" = "secondary",
|
||||
): FlexButton => ({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label,
|
||||
data,
|
||||
},
|
||||
action: postbackAction(label, data),
|
||||
style,
|
||||
height: "sm",
|
||||
flex: 1,
|
||||
@@ -516,11 +492,7 @@ export function createDeviceControlCard(params: {
|
||||
|
||||
rowButtons.push({
|
||||
type: "button",
|
||||
action: {
|
||||
type: "postback",
|
||||
label: truncateLineActionLabel(buttonLabel, 18),
|
||||
data: ctrl.data,
|
||||
},
|
||||
action: postbackAction(truncateLineActionLabel(buttonLabel, 18), ctrl.data),
|
||||
style: ctrl.style ?? "secondary",
|
||||
flex: 1,
|
||||
height: "sm",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Line plugin module implements schedule cards behavior.
|
||||
import { normalizeLineAction } from "../actions.js";
|
||||
import { attachFooterText } from "./common.js";
|
||||
import type { Action, FlexBox, FlexBubble, FlexComponent, FlexText } from "./types.js";
|
||||
|
||||
@@ -333,7 +334,7 @@ export function createEventCard(params: {
|
||||
contents: bodyContents,
|
||||
paddingAll: "xl",
|
||||
backgroundColor: "#FFFFFF",
|
||||
action,
|
||||
action: action === undefined ? undefined : normalizeLineAction(action, 40),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
// Line tests cover message cards plugin behavior.
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { datetimePickerAction, messageAction, postbackAction, uriAction } from "./actions.js";
|
||||
import {
|
||||
datetimePickerAction,
|
||||
messageAction,
|
||||
normalizeLineAction,
|
||||
postbackAction,
|
||||
truncateLineActionLabel,
|
||||
uriAction,
|
||||
type Action,
|
||||
} from "./actions.js";
|
||||
import { registerLineCardCommand } from "./card-command.js";
|
||||
import {
|
||||
createActionCard,
|
||||
createAppleTvRemoteCard,
|
||||
createCarousel,
|
||||
createDeviceControlCard,
|
||||
createEventCard,
|
||||
@@ -14,6 +23,7 @@ import {
|
||||
createMediaPlayerCard,
|
||||
} from "./flex-templates.js";
|
||||
import {
|
||||
buildTemplateMessageFromPayload,
|
||||
createConfirmTemplate,
|
||||
createButtonTemplate,
|
||||
createTemplateCarousel,
|
||||
@@ -232,6 +242,27 @@ describe("carousel column limits", () => {
|
||||
expect(template.altText).toBe("x".repeat(399));
|
||||
expect(loneHighSurrogate.test(template.altText)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps unavailable action labels within the image-carousel cap", () => {
|
||||
const template = createImageCarousel([
|
||||
createImageCarouselColumn(
|
||||
"https://example.com/0.jpg",
|
||||
uriAction("Open", `https://example.com/?q=${"x".repeat(1200)}`),
|
||||
),
|
||||
]);
|
||||
const column = (
|
||||
template.template as {
|
||||
columns: Array<{ action: { label?: string; text?: string; type: string } }>;
|
||||
}
|
||||
).columns[0];
|
||||
|
||||
expect(column?.action).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Link unavailable: URL exceeds LINE's limit.",
|
||||
});
|
||||
expect(column?.action.label).toHaveLength(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createProductCarousel", () => {
|
||||
@@ -353,7 +384,7 @@ describe("action label/data surrogate-safe truncation", () => {
|
||||
it("messageAction drops a half emoji instead of leaving a lone surrogate", () => {
|
||||
const action = messageAction(labelWithEmoji) as { label: string };
|
||||
|
||||
expect(action.label).toBe("1234567890123456789");
|
||||
expect(action.label).toBe(labelWithEmoji);
|
||||
expect(loneHighSurrogate.test(action.label)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -366,50 +397,61 @@ describe("action label/data surrogate-safe truncation", () => {
|
||||
it("uriAction drops a half emoji instead of leaving a lone surrogate", () => {
|
||||
const action = uriAction(labelWithEmoji, "https://example.com") as { label: string };
|
||||
|
||||
expect(action.label).toBe("1234567890123456789");
|
||||
expect(action.label).toBe(labelWithEmoji);
|
||||
expect(loneHighSurrogate.test(action.label)).toBe(false);
|
||||
});
|
||||
|
||||
it("postbackAction truncates label and data on surrogate boundaries", () => {
|
||||
// 299 ASCII chars + 😀 = 301 code units; the 300-unit slice cuts the emoji.
|
||||
const data = `${"d".repeat(299)}😀`;
|
||||
const action = postbackAction(labelWithEmoji, data) as {
|
||||
label: string;
|
||||
data: string;
|
||||
};
|
||||
it("postbackAction preserves valid grapheme labels but disables overlong callback data", () => {
|
||||
const exactData = `${"d".repeat(298)}😀`;
|
||||
const overlongData = `${"d".repeat(299)}😀`;
|
||||
const action = postbackAction(labelWithEmoji, "data") as { label: string };
|
||||
const exact = postbackAction("Label", exactData) as { data: string };
|
||||
const unavailable = postbackAction("Label", overlongData);
|
||||
|
||||
expect(action.label).toBe("1234567890123456789");
|
||||
expect(exactData).toHaveLength(300);
|
||||
expect(overlongData).toHaveLength(301);
|
||||
expect(action.label).toBe(labelWithEmoji);
|
||||
expect(loneHighSurrogate.test(action.label)).toBe(false);
|
||||
expect(action.data).toBe("d".repeat(299));
|
||||
expect(loneHighSurrogate.test(action.data)).toBe(false);
|
||||
expect(exact.data).toBe(exactData);
|
||||
expect(unavailable).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: callback data exceeds LINE's limit.",
|
||||
});
|
||||
});
|
||||
|
||||
it("postbackAction truncates displayText on surrogate boundaries but keeps undefined", () => {
|
||||
const displayText = `${"t".repeat(299)}😀`;
|
||||
it("postbackAction truncates displayText by grapheme cluster but keeps undefined", () => {
|
||||
const displayText = `${"t".repeat(300)}😀`;
|
||||
const withDisplay = postbackAction("Label", "data", displayText) as {
|
||||
displayText?: string;
|
||||
};
|
||||
const withoutDisplay = postbackAction("Label", "data") as { displayText?: string };
|
||||
|
||||
expect(withDisplay.displayText).toBe("t".repeat(299));
|
||||
expect(withDisplay.displayText).toBe("t".repeat(300));
|
||||
expect(loneHighSurrogate.test(withDisplay.displayText ?? "")).toBe(false);
|
||||
expect(withoutDisplay.displayText).toBeUndefined();
|
||||
});
|
||||
|
||||
it("datetimePickerAction truncates label and data on surrogate boundaries", () => {
|
||||
const data = `${"d".repeat(299)}😀`;
|
||||
const action = datetimePickerAction(labelWithEmoji, data, "datetime") as {
|
||||
label: string;
|
||||
data: string;
|
||||
};
|
||||
it("datetimePickerAction preserves valid grapheme labels but disables overlong callback data", () => {
|
||||
const exactData = `${"d".repeat(298)}😀`;
|
||||
const overlongData = `${"d".repeat(299)}😀`;
|
||||
const action = datetimePickerAction(labelWithEmoji, "data", "datetime") as { label: string };
|
||||
const exact = datetimePickerAction("Pick", exactData, "datetime") as { data: string };
|
||||
const unavailable = datetimePickerAction("Pick", overlongData, "datetime");
|
||||
|
||||
expect(action.label).toBe("1234567890123456789");
|
||||
expect(exactData).toHaveLength(300);
|
||||
expect(overlongData).toHaveLength(301);
|
||||
expect(action.label).toBe(labelWithEmoji);
|
||||
expect(loneHighSurrogate.test(action.label)).toBe(false);
|
||||
expect(action.data).toBe("d".repeat(299));
|
||||
expect(loneHighSurrogate.test(action.data)).toBe(false);
|
||||
expect(exact.data).toBe(exactData);
|
||||
expect(unavailable).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: callback data exceeds LINE's limit.",
|
||||
});
|
||||
});
|
||||
|
||||
it("/card action command uses surrogate-safe labels and postback data", async () => {
|
||||
it("/card action command visibly disables overlong callback data", async () => {
|
||||
const registerCommand = (command: unknown) => {
|
||||
const { handler } = command as {
|
||||
handler: (ctx: { args: string; channel: string }) => Promise<unknown>;
|
||||
@@ -423,7 +465,13 @@ describe("action label/data surrogate-safe truncation", () => {
|
||||
channelData: {
|
||||
line: {
|
||||
flexMessage: {
|
||||
contents: { footer: { contents: Array<{ action: { label: string; data: string } }> } };
|
||||
contents: {
|
||||
footer: {
|
||||
contents: Array<{
|
||||
action: { type: string; label: string; text?: string };
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -433,10 +481,11 @@ describe("action label/data surrogate-safe truncation", () => {
|
||||
"LINE flex-message footer action",
|
||||
).action;
|
||||
|
||||
expect(action.label).toBe("1234567890123456789");
|
||||
expect(loneHighSurrogate.test(action.label)).toBe(false);
|
||||
expect(action.data).toBe(`k=${"d".repeat(297)}`);
|
||||
expect(loneHighSurrogate.test(action.data)).toBe(false);
|
||||
expect(action).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: callback data exceeds LINE's limit.",
|
||||
});
|
||||
});
|
||||
|
||||
it("/card receipt altText truncates on a surrogate boundary", async () => {
|
||||
@@ -460,7 +509,7 @@ describe("action label/data surrogate-safe truncation", () => {
|
||||
expect(loneHighSurrogate.test(altText)).toBe(false);
|
||||
});
|
||||
|
||||
it("media control postback labels truncate on surrogate boundaries", () => {
|
||||
it("media control postback labels count grapheme clusters", () => {
|
||||
const card = createMediaPlayerCard({
|
||||
title: "Track",
|
||||
controls: {
|
||||
@@ -475,9 +524,349 @@ describe("action label/data surrogate-safe truncation", () => {
|
||||
.flatMap((content) => content.contents ?? [])
|
||||
.find((button) => button.action?.data === "extra")?.action;
|
||||
|
||||
expect(extraAction?.label).toBe("x".repeat(14));
|
||||
expect(extraAction?.label).toBe(`${"x".repeat(14)}😀`);
|
||||
expect(loneHighSurrogate.test(extraAction?.label ?? "")).toBe(false);
|
||||
});
|
||||
|
||||
it("uriAction visibly disables overlong links instead of changing their destination", () => {
|
||||
const validUri = `https://e.example/?q=${"u".repeat(979)}`;
|
||||
const validAction = uriAction("Open", validUri) as { type: string; uri?: string };
|
||||
const overlongAction = uriAction("Open", `${validUri}u`) as {
|
||||
type: string;
|
||||
label?: string;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
expect(validUri).toHaveLength(1000);
|
||||
expect(validAction).toMatchObject({ type: "uri", uri: validUri });
|
||||
expect(overlongAction).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Link unavailable: URL exceeds LINE's limit.",
|
||||
});
|
||||
});
|
||||
|
||||
it("buttons template payload visibly disables URIs past the 1000-unit cap", () => {
|
||||
const template = buildTemplateMessageFromPayload({
|
||||
type: "buttons",
|
||||
text: "Pick",
|
||||
actions: [{ type: "uri", label: "Open", uri: `https://e.example/?q=${"u".repeat(1200)}` }],
|
||||
});
|
||||
|
||||
const buttonsTemplate = expectDefined(template, "buttons template message").template as {
|
||||
actions: Array<{ type: string; label?: string; text?: string }>;
|
||||
};
|
||||
const uriTemplateAction = expectDefined(
|
||||
buttonsTemplate.actions[0],
|
||||
"buttons template uri action",
|
||||
);
|
||||
expect(uriTemplateAction).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Link unavailable: URL exceeds LINE's limit.",
|
||||
});
|
||||
});
|
||||
|
||||
it("buttons template payload visibly disables overlong postback data", () => {
|
||||
const template = buildTemplateMessageFromPayload({
|
||||
type: "buttons",
|
||||
text: "Pick",
|
||||
actions: [{ type: "postback", label: "Open", data: `action=open&token=${"x".repeat(300)}` }],
|
||||
});
|
||||
const buttonsTemplate = expectDefined(template, "buttons template message").template as {
|
||||
actions: Array<{ type: string; label?: string; text?: string }>;
|
||||
};
|
||||
|
||||
expect(buttonsTemplate.actions[0]).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: callback data exceeds LINE's limit.",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes raw actions at exported template builder boundaries", () => {
|
||||
const oversizedPostback: Action = {
|
||||
type: "postback",
|
||||
label: "Open",
|
||||
data: "x".repeat(301),
|
||||
};
|
||||
const oversizedUri: Action = {
|
||||
type: "uri",
|
||||
label: "Open",
|
||||
uri: `https://e.example/?q=${"x".repeat(1200)}`,
|
||||
};
|
||||
const unavailableAction = {
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: callback data exceeds LINE's limit.",
|
||||
};
|
||||
const unavailableLink = {
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Link unavailable: URL exceeds LINE's limit.",
|
||||
};
|
||||
|
||||
const buttons = createButtonTemplate(undefined, "Pick", [oversizedPostback], {
|
||||
defaultAction: oversizedUri,
|
||||
}).template as {
|
||||
actions: Action[];
|
||||
defaultAction?: Action;
|
||||
};
|
||||
expect(buttons.actions).toEqual([unavailableAction]);
|
||||
expect(buttons.defaultAction).toEqual(unavailableLink);
|
||||
|
||||
const carousel = createTemplateCarousel([
|
||||
{
|
||||
text: "Pick",
|
||||
actions: [oversizedPostback],
|
||||
defaultAction: oversizedUri,
|
||||
},
|
||||
]).template as {
|
||||
columns: Array<{ actions: Action[]; defaultAction?: Action }>;
|
||||
};
|
||||
expect(carousel.columns[0]?.actions).toEqual([unavailableAction]);
|
||||
expect(carousel.columns[0]?.defaultAction).toEqual(unavailableLink);
|
||||
|
||||
const imageCarousel = createImageCarousel([
|
||||
{ imageUrl: "https://e.example/image.jpg", action: oversizedPostback },
|
||||
]).template as { columns: Array<{ action: Action }> };
|
||||
expect(imageCarousel.columns[0]?.action).toEqual(unavailableAction);
|
||||
});
|
||||
|
||||
it("normalizes every length-constrained raw action field", () => {
|
||||
expect(
|
||||
normalizeLineAction({
|
||||
type: "uri",
|
||||
label: "Open",
|
||||
uri: "https://e.example",
|
||||
altUri: { desktop: `https://e.example/?q=${"x".repeat(1200)}` },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Link unavailable: URL exceeds LINE's limit.",
|
||||
});
|
||||
|
||||
const postback = normalizeLineAction({
|
||||
type: "postback",
|
||||
label: "Open",
|
||||
data: "action=open",
|
||||
displayText: "d".repeat(301),
|
||||
});
|
||||
expect(postback).toMatchObject({
|
||||
displayText: "d".repeat(300),
|
||||
});
|
||||
|
||||
expect(normalizeLineAction({ type: "message", label: "Open", text: "x".repeat(301) })).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: message text exceeds LINE's limit.",
|
||||
});
|
||||
expect(
|
||||
normalizeLineAction({
|
||||
type: "postback",
|
||||
label: "Open",
|
||||
data: "action=open",
|
||||
fillInText: "x".repeat(301),
|
||||
}),
|
||||
).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: message text exceeds LINE's limit.",
|
||||
});
|
||||
expect(
|
||||
normalizeLineAction({
|
||||
type: "postback",
|
||||
label: "Open",
|
||||
data: "action=open",
|
||||
text: "x".repeat(301),
|
||||
}),
|
||||
).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: message text exceeds LINE's limit.",
|
||||
});
|
||||
const emojiText = "😀".repeat(300);
|
||||
expect(messageAction("Open", emojiText)).toMatchObject({ text: emojiText });
|
||||
const familyEmoji = "👨👩👧👦";
|
||||
expect(truncateLineActionLabel(familyEmoji.repeat(3))).toBe(familyEmoji.repeat(2));
|
||||
expect(truncateLineActionLabel(`👩${"👩".repeat(10)}`)).toBe("…");
|
||||
expect(messageAction("Open", familyEmoji.repeat(43))).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: message text exceeds LINE's limit.",
|
||||
});
|
||||
expect(messageAction("Open", familyEmoji.repeat(42))).toMatchObject({
|
||||
text: familyEmoji.repeat(42),
|
||||
});
|
||||
expect(
|
||||
normalizeLineAction({
|
||||
type: "clipboard",
|
||||
label: "Copy",
|
||||
clipboardText: "x".repeat(1001),
|
||||
}),
|
||||
).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: clipboard text exceeds LINE's limit.",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes raw actions at exported flex builder boundaries", () => {
|
||||
const oversizedUri: Action = {
|
||||
type: "uri",
|
||||
label: "Open",
|
||||
uri: `https://e.example/?q=${"x".repeat(1200)}`,
|
||||
};
|
||||
const oversizedPostback: Action = {
|
||||
type: "postback",
|
||||
label: "Open",
|
||||
data: "x".repeat(301),
|
||||
};
|
||||
|
||||
const image = createImageCard("https://e.example/image.jpg", "Image", undefined, {
|
||||
action: oversizedUri,
|
||||
});
|
||||
expect((image.hero as { action?: Action }).action).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Link unavailable: URL exceeds LINE's limit.",
|
||||
});
|
||||
|
||||
const card = createActionCard("Title", "Body", [{ label: "Open", action: oversizedPostback }]);
|
||||
const button = (card.footer as { contents: Array<{ action: Action }> }).contents[0];
|
||||
expect(button?.action).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: callback data exceeds LINE's limit.",
|
||||
});
|
||||
|
||||
const validLongLabel = "x".repeat(40);
|
||||
const labeledCard = createActionCard("Title", "Body", [
|
||||
{ label: validLongLabel, action: { type: "message", label: validLongLabel, text: "Open" } },
|
||||
]);
|
||||
const labeledButton = (labeledCard.footer as { contents: Array<{ action: Action }> })
|
||||
.contents[0];
|
||||
expect(labeledButton?.action.label).toBe(validLongLabel);
|
||||
|
||||
const list = createListCard("List", [{ title: "Item", action: oversizedPostback }]);
|
||||
const listBody = (list.body as { contents: unknown[] }).contents;
|
||||
const listBox = listBody[2] as {
|
||||
contents: Array<{ action?: Action }>;
|
||||
};
|
||||
expect(listBox.contents[0]?.action).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: callback data exceeds LINE's limit.",
|
||||
});
|
||||
|
||||
const event = createEventCard({
|
||||
title: "Event",
|
||||
date: "Today",
|
||||
action: oversizedUri,
|
||||
});
|
||||
expect((event.body as { action?: Action }).action).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Link unavailable: URL exceeds LINE's limit.",
|
||||
});
|
||||
});
|
||||
|
||||
it("media control cards visibly disable overlong opaque callbacks", () => {
|
||||
const overlongData = `${"d".repeat(299)}😀`;
|
||||
const card = createMediaPlayerCard({
|
||||
title: "Track",
|
||||
controls: {
|
||||
previous: { data: overlongData },
|
||||
play: { data: overlongData },
|
||||
pause: { data: overlongData },
|
||||
next: { data: overlongData },
|
||||
},
|
||||
extraActions: [{ label: "Extra", data: overlongData }],
|
||||
});
|
||||
const footer = card.footer as {
|
||||
contents: Array<{
|
||||
contents?: Array<{
|
||||
action?: { type: string; data?: string; label?: string; text?: string };
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
const actions = footer.contents
|
||||
.flatMap((content) => content.contents ?? [])
|
||||
.flatMap((button) => (button.action ? [button.action] : []));
|
||||
|
||||
expect(actions).toHaveLength(5);
|
||||
for (const action of actions) {
|
||||
expect(action).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: callback data exceeds LINE's limit.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("device controls visibly disable overlong opaque callbacks", () => {
|
||||
const card = createDeviceControlCard({
|
||||
deviceName: "Device",
|
||||
controls: [{ label: "On", data: `${"d".repeat(299)}😀` }],
|
||||
});
|
||||
const footer = card.footer as {
|
||||
contents: Array<{
|
||||
contents: Array<{
|
||||
action?: { type: string; data?: string; label?: string; text?: string };
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
const action = footer.contents
|
||||
.flatMap((row) => row.contents)
|
||||
.find((button) => button.action)?.action;
|
||||
|
||||
expect(action).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: callback data exceeds LINE's limit.",
|
||||
});
|
||||
});
|
||||
|
||||
it("Apple TV controls visibly disable overlong opaque callbacks", () => {
|
||||
const overlongData = `${"d".repeat(299)}😀`;
|
||||
const card = createAppleTvRemoteCard({
|
||||
deviceName: "TV",
|
||||
actionData: {
|
||||
up: overlongData,
|
||||
down: overlongData,
|
||||
left: overlongData,
|
||||
right: overlongData,
|
||||
select: overlongData,
|
||||
menu: overlongData,
|
||||
home: overlongData,
|
||||
play: overlongData,
|
||||
pause: overlongData,
|
||||
volumeUp: overlongData,
|
||||
volumeDown: overlongData,
|
||||
mute: overlongData,
|
||||
},
|
||||
});
|
||||
const body = card.body as {
|
||||
contents: Array<{
|
||||
contents?: Array<{
|
||||
action?: { type: string; data?: string; label?: string; text?: string };
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
const actions = body.contents
|
||||
.flatMap((row) => row.contents ?? [])
|
||||
.flatMap((button) => (button.action ? [button.action] : []));
|
||||
|
||||
expect(actions).toHaveLength(12);
|
||||
for (const action of actions) {
|
||||
expect(action).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: callback data exceeds LINE's limit.",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function registerCommandWithHandler(
|
||||
|
||||
@@ -450,6 +450,33 @@ describe("parseLineDirectives", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("overlong action payloads", () => {
|
||||
it("keeps the card visible but disables a callback that cannot round-trip", () => {
|
||||
const deviceName = `${"a".repeat(280)}_living_room`;
|
||||
const result = parseLineDirectives({
|
||||
text: `[[device: ${deviceName} | Streaming Box | Playing | Play/Pause:toggle]]`,
|
||||
});
|
||||
const flexMessage = requireFlexMessage(getLineData(result).flexMessage, "long device name");
|
||||
const footer = flexMessage.contents?.footer as {
|
||||
contents?: Array<{
|
||||
contents?: Array<{
|
||||
action?: { type?: string; data?: string; label?: string; text?: string };
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
const action = (footer?.contents ?? [])
|
||||
.flatMap((row) => row.contents ?? [])
|
||||
.find((button) => button.action)?.action;
|
||||
|
||||
expect(flexMessage.altText).toContain("living_room");
|
||||
expect(action).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: callback data exceeds LINE's limit.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("appletv_remote", () => {
|
||||
it("parses appletv remote variants", () => {
|
||||
const cases = [
|
||||
|
||||
@@ -100,9 +100,13 @@ describe("postbackAction", () => {
|
||||
expect((action as { displayText: string }).displayText).toBe("Selected item 1");
|
||||
});
|
||||
|
||||
it("applies postback payload truncation and displayText behavior", () => {
|
||||
const truncatedData = postbackAction("Test", "x".repeat(400));
|
||||
expect((truncatedData as { data: string }).data.length).toBe(300);
|
||||
it("visibly disables overlong postback data and truncates displayText", () => {
|
||||
const unavailable = postbackAction("Test", "x".repeat(400));
|
||||
expect(unavailable).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: callback data exceeds LINE's limit.",
|
||||
});
|
||||
|
||||
const truncatedDisplay = postbackAction("Test", "data", "y".repeat(400));
|
||||
expect((truncatedDisplay as { displayText: string }).displayText?.length).toBe(300);
|
||||
|
||||
@@ -165,16 +165,226 @@ describe("LINE send helpers", () => {
|
||||
expect(quickReply.items).toHaveLength(13);
|
||||
});
|
||||
|
||||
it("truncates quick reply labels without leaving lone surrogates", () => {
|
||||
it("counts quick reply labels in grapheme clusters", () => {
|
||||
const label = "1234567890123456789😀";
|
||||
const quickReply = sendModule.createQuickReplyItems([label]);
|
||||
const item = quickReply.items?.[0] as { action: { label: string; text: string } } | undefined;
|
||||
|
||||
expect(item?.action.label).toBe("1234567890123456789");
|
||||
expect(item?.action.label).toBe(label);
|
||||
expect(item?.action.text).toBe(label);
|
||||
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(item?.action.label ?? "")).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes raw Flex actions at both outbound API boundaries", async () => {
|
||||
const oversizedPostback = { type: "postback", label: "Open", data: "x".repeat(301) };
|
||||
const oversizedUri = {
|
||||
type: "uri",
|
||||
label: "Open",
|
||||
uri: `https://e.example/?q=${"x".repeat(1200)}`,
|
||||
};
|
||||
const message = {
|
||||
type: "flex",
|
||||
altText: "Raw Flex",
|
||||
contents: {
|
||||
type: "bubble",
|
||||
action: oversizedPostback,
|
||||
hero: {
|
||||
type: "video",
|
||||
url: "https://e.example/video.mp4",
|
||||
previewUrl: "https://e.example/preview.jpg",
|
||||
altContent: {
|
||||
type: "image",
|
||||
url: "https://e.example/preview.jpg",
|
||||
size: "full",
|
||||
},
|
||||
action: oversizedUri,
|
||||
},
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
action: oversizedPostback,
|
||||
contents: [
|
||||
{ type: "text", text: "Open", action: oversizedUri },
|
||||
{ type: "button", action: oversizedPostback },
|
||||
{
|
||||
type: "text",
|
||||
text: "Still works",
|
||||
action: { type: "message", label: "Unavailable", text: "keep" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await sendModule.pushMessagesLine("U123", [message] as never, { cfg: LINE_TEST_CFG });
|
||||
await sendModule.replyMessageLine("reply-token", [message] as never, { cfg: LINE_TEST_CFG });
|
||||
|
||||
const pushed = pushMessageMock.mock.calls[0]?.[0] as {
|
||||
messages: Array<{ contents: Record<string, unknown> }>;
|
||||
};
|
||||
const replied = replyMessageMock.mock.calls[0]?.[0] as {
|
||||
messages: Array<{ contents: Record<string, unknown> }>;
|
||||
};
|
||||
expect(pushed.messages[0]?.contents).toEqual(replied.messages[0]?.contents);
|
||||
|
||||
const contents = pushed.messages[0]?.contents as {
|
||||
action?: unknown;
|
||||
hero: { type: string; action?: unknown };
|
||||
body: { action?: unknown; contents: Array<{ action?: unknown; text?: string }> };
|
||||
};
|
||||
const unavailableAction = {
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: callback data exceeds LINE's limit.",
|
||||
};
|
||||
expect(contents.action).toBeUndefined();
|
||||
expect(contents.hero).toEqual({
|
||||
type: "video",
|
||||
url: "https://e.example/video.mp4",
|
||||
previewUrl: "https://e.example/preview.jpg",
|
||||
altContent: {
|
||||
type: "image",
|
||||
url: "https://e.example/preview.jpg",
|
||||
size: "full",
|
||||
},
|
||||
});
|
||||
expect(contents.body.action).toBeUndefined();
|
||||
expect(contents.body.contents[0]?.action).toBeUndefined();
|
||||
expect(contents.body.contents[1]?.action).toEqual(unavailableAction);
|
||||
expect(contents.body.contents[2]?.action).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "keep",
|
||||
});
|
||||
expect(contents.body.contents.slice(3).map((item) => item.text)).toEqual([
|
||||
"Action unavailable: callback data exceeds LINE's limit.\nLink unavailable: URL exceeds LINE's limit.",
|
||||
]);
|
||||
expect(message.contents.action).toBe(oversizedPostback);
|
||||
});
|
||||
|
||||
it("normalizes raw imagemap actions at both outbound API boundaries", async () => {
|
||||
const area = { x: 0, y: 0, width: 520, height: 1040 };
|
||||
const videoArea = { x: 520, y: 0, width: 520, height: 1040 };
|
||||
const message = {
|
||||
type: "imagemap",
|
||||
baseUrl: "https://e.example/imagemap",
|
||||
altText: "Map",
|
||||
baseSize: { width: 1040, height: 1040 },
|
||||
actions: [
|
||||
{
|
||||
type: "uri",
|
||||
label: "Open",
|
||||
linkUri: `https://e.example/?q=${"x".repeat(1200)}`,
|
||||
area,
|
||||
},
|
||||
...Array.from({ length: 49 }, (_, index) => ({
|
||||
type: "message",
|
||||
label: `Item ${index}`,
|
||||
text: `item-${index}`,
|
||||
area,
|
||||
})),
|
||||
],
|
||||
video: {
|
||||
originalContentUrl: "https://e.example/video.mp4",
|
||||
previewImageUrl: "https://e.example/preview.jpg",
|
||||
area: videoArea,
|
||||
externalLink: {
|
||||
linkUri: `https://e.example/video?q=${"x".repeat(1200)}`,
|
||||
label: "Open video",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await sendModule.pushMessagesLine("U123", [message] as never, { cfg: LINE_TEST_CFG });
|
||||
await sendModule.replyMessageLine("reply-token", [message] as never, { cfg: LINE_TEST_CFG });
|
||||
|
||||
const pushed = pushMessageMock.mock.calls[0]?.[0] as {
|
||||
messages: Array<{ actions: unknown[]; video?: { externalLink?: unknown } }>;
|
||||
};
|
||||
const replied = replyMessageMock.mock.calls[0]?.[0] as {
|
||||
messages: Array<{ actions: unknown[] }>;
|
||||
};
|
||||
expect(pushed.messages[0]?.actions).toEqual(replied.messages[0]?.actions);
|
||||
expect(pushed.messages[0]?.actions).toHaveLength(50);
|
||||
expect(pushed.messages[0]?.actions[0]).toEqual({
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Link unavailable: URL exceeds LINE's limit.",
|
||||
area,
|
||||
});
|
||||
expect(pushed.messages[0]?.actions[1]).toMatchObject({
|
||||
type: "message",
|
||||
text: "item-0",
|
||||
});
|
||||
expect(pushed.messages[0]?.actions[49]).toMatchObject({
|
||||
type: "message",
|
||||
text: "item-48",
|
||||
});
|
||||
expect(pushed.messages[0]?.video?.externalLink).toBeUndefined();
|
||||
expect(message.actions[0]?.type).toBe("uri");
|
||||
});
|
||||
|
||||
it("counts imagemap message text in UTF-16 units at both outbound API boundaries", async () => {
|
||||
const area = { x: 0, y: 0, width: 1040, height: 1040 };
|
||||
const exactText = "😀".repeat(200);
|
||||
const message = {
|
||||
type: "imagemap",
|
||||
baseUrl: "https://e.example/imagemap",
|
||||
altText: "Map",
|
||||
baseSize: { width: 1040, height: 1040 },
|
||||
actions: [
|
||||
{ type: "message", label: "Exact", text: exactText, area },
|
||||
{ type: "message", label: "Too long", text: `${exactText}😀`, area },
|
||||
],
|
||||
};
|
||||
|
||||
await sendModule.pushMessagesLine("U123", [message] as never, { cfg: LINE_TEST_CFG });
|
||||
await sendModule.replyMessageLine("reply-token", [message] as never, { cfg: LINE_TEST_CFG });
|
||||
|
||||
const pushed = pushMessageMock.mock.calls[0]?.[0] as {
|
||||
messages: Array<{ actions: unknown[] }>;
|
||||
};
|
||||
const replied = replyMessageMock.mock.calls[0]?.[0] as {
|
||||
messages: Array<{ actions: unknown[] }>;
|
||||
};
|
||||
expect(pushed.messages[0]?.actions).toEqual(replied.messages[0]?.actions);
|
||||
expect(pushed.messages[0]?.actions).toEqual([
|
||||
{ type: "message", label: "Exact", text: exactText, area },
|
||||
{
|
||||
type: "message",
|
||||
label: "Unavailable",
|
||||
text: "Action unavailable: message text exceeds LINE's limit.",
|
||||
area,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts imagemap video-link labels in UTF-16 units", async () => {
|
||||
const message = {
|
||||
type: "imagemap",
|
||||
baseUrl: "https://e.example/imagemap",
|
||||
altText: "Map",
|
||||
baseSize: { width: 1040, height: 1040 },
|
||||
actions: [],
|
||||
video: {
|
||||
originalContentUrl: "https://e.example/video.mp4",
|
||||
previewImageUrl: "https://e.example/preview.jpg",
|
||||
area: { x: 0, y: 0, width: 1040, height: 1040 },
|
||||
externalLink: {
|
||||
linkUri: "https://e.example/video",
|
||||
label: "😀".repeat(16),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await sendModule.pushMessagesLine("U123", [message] as never, { cfg: LINE_TEST_CFG });
|
||||
|
||||
const pushed = pushMessageMock.mock.calls[0]?.[0] as {
|
||||
messages: Array<{ video: { externalLink: { label: string } } }>;
|
||||
};
|
||||
expect(pushed.messages[0]?.video.externalLink.label).toBe("😀".repeat(15));
|
||||
});
|
||||
|
||||
it("pushes images via normalized LINE target", async () => {
|
||||
const result = await sendModule.pushImageMessage(
|
||||
"line:user:U123",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime"
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { resolveLineAccount } from "./accounts.js";
|
||||
import { messageAction } from "./actions.js";
|
||||
import { messageAction, normalizeLineMessageActions } from "./actions.js";
|
||||
import { resolveLineChannelAccessToken } from "./channel-access-token.js";
|
||||
import { validateLineMediaUrl } from "./outbound-media.js";
|
||||
import { createLineSendReceipt } from "./send-receipt.js";
|
||||
@@ -241,9 +241,10 @@ async function pushLineMessages(
|
||||
}
|
||||
|
||||
const { account, client, chatId } = createLinePushContext(to, opts);
|
||||
const normalizedMessages = messages.map(normalizeLineMessageActions);
|
||||
const pushRequest = client.pushMessage({
|
||||
to: chatId,
|
||||
messages,
|
||||
messages: normalizedMessages,
|
||||
});
|
||||
|
||||
if (behavior.errorContext) {
|
||||
@@ -283,10 +284,11 @@ async function replyLineMessages(
|
||||
behavior: LineReplyBehavior = {},
|
||||
): Promise<void> {
|
||||
const { account, client } = createLineMessagingClient(opts);
|
||||
const normalizedMessages = messages.map(normalizeLineMessageActions);
|
||||
|
||||
await client.replyMessage({
|
||||
replyToken,
|
||||
messages,
|
||||
messages: normalizedMessages,
|
||||
});
|
||||
|
||||
recordLineOutboundActivity(account.accountId);
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// Line plugin module implements template messages behavior.
|
||||
import type { messagingApi } from "@line/bot-sdk";
|
||||
import { messageAction, postbackAction, uriAction, type Action } from "./actions.js";
|
||||
import {
|
||||
messageAction,
|
||||
normalizeLineAction,
|
||||
postbackAction,
|
||||
uriAction,
|
||||
type Action,
|
||||
} from "./actions.js";
|
||||
import type { LineTemplateMessagePayload } from "./types.js";
|
||||
|
||||
type TemplateMessage = messagingApi.TemplateMessage;
|
||||
@@ -80,6 +86,22 @@ function formatProductCarouselText(description: string, price?: string): string
|
||||
return descriptionText ? `${descriptionText}\n${priceText}` : priceText;
|
||||
}
|
||||
|
||||
function normalizeCarouselColumnActions(column: CarouselColumn): CarouselColumn {
|
||||
return {
|
||||
...column,
|
||||
actions: column.actions.map((action) => normalizeLineAction(action)),
|
||||
defaultAction:
|
||||
column.defaultAction === undefined ? undefined : normalizeLineAction(column.defaultAction),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeImageCarouselColumnAction(column: ImageCarouselColumn): ImageCarouselColumn {
|
||||
return {
|
||||
...column,
|
||||
action: normalizeLineAction(column.action, 12),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a confirm template (yes/no style dialog)
|
||||
*/
|
||||
@@ -92,7 +114,7 @@ export function createConfirmTemplate(
|
||||
const template: ConfirmTemplate = {
|
||||
type: "confirm",
|
||||
text: truncateTemplateText(text, 240), // LINE limit
|
||||
actions: [confirmAction, cancelAction],
|
||||
actions: [normalizeLineAction(confirmAction), normalizeLineAction(cancelAction)],
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -128,12 +150,13 @@ export function createButtonTemplate(
|
||||
type: "buttons",
|
||||
...(normalizedTitle ? { title: truncateTemplateText(normalizedTitle, 40) } : {}), // LINE limit
|
||||
text: truncateTemplateText(text, textLimit),
|
||||
actions: actions.slice(0, 4), // LINE limit: max 4 actions
|
||||
actions: actions.slice(0, 4).map((action) => normalizeLineAction(action)), // LINE limit: max 4 actions
|
||||
thumbnailImageUrl: options?.thumbnailImageUrl,
|
||||
imageAspectRatio: options?.imageAspectRatio ?? "rectangle",
|
||||
imageSize: options?.imageSize ?? "cover",
|
||||
imageBackgroundColor: options?.imageBackgroundColor,
|
||||
defaultAction: options?.defaultAction,
|
||||
defaultAction:
|
||||
options?.defaultAction === undefined ? undefined : normalizeLineAction(options.defaultAction),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -158,7 +181,7 @@ export function createTemplateCarousel(
|
||||
): TemplateMessage {
|
||||
const template: CarouselTemplate = {
|
||||
type: "carousel",
|
||||
columns: columns.slice(0, 10), // LINE limit: max 10 columns
|
||||
columns: columns.slice(0, 10).map(normalizeCarouselColumnActions), // LINE limit: max 10 columns
|
||||
imageAspectRatio: options?.imageAspectRatio ?? "rectangle",
|
||||
imageSize: options?.imageSize ?? "cover",
|
||||
};
|
||||
@@ -189,10 +212,11 @@ export function createCarouselColumn(params: {
|
||||
return {
|
||||
title: truncateOptionalTemplateText(params.title, 40),
|
||||
text: truncateTemplateText(params.text, textLimit),
|
||||
actions: params.actions.slice(0, 3), // LINE limit: max 3 actions per column
|
||||
actions: params.actions.slice(0, 3).map((action) => normalizeLineAction(action)), // LINE limit: max 3 actions per column
|
||||
thumbnailImageUrl: params.thumbnailImageUrl,
|
||||
imageBackgroundColor: params.imageBackgroundColor,
|
||||
defaultAction: params.defaultAction,
|
||||
defaultAction:
|
||||
params.defaultAction === undefined ? undefined : normalizeLineAction(params.defaultAction),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -205,7 +229,7 @@ export function createImageCarousel(
|
||||
): TemplateMessage {
|
||||
const template: ImageCarouselTemplate = {
|
||||
type: "image_carousel",
|
||||
columns: columns.slice(0, 10), // LINE limit: max 10 columns
|
||||
columns: columns.slice(0, 10).map(normalizeImageCarouselColumnAction), // LINE limit: max 10 columns
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -221,7 +245,7 @@ export function createImageCarousel(
|
||||
export function createImageCarouselColumn(imageUrl: string, action: Action): ImageCarouselColumn {
|
||||
return {
|
||||
imageUrl,
|
||||
action,
|
||||
action: normalizeLineAction(action, 12),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user