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:
Eden
2026-07-27 11:46:02 +08:00
committed by GitHub
parent a05cfd4756
commit 0c35fd578b
10 changed files with 1099 additions and 106 deletions
+379 -16
View File
@@ -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),
},
};
}
+422 -33
View File
@@ -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 = [
+7 -3
View File
@@ -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);
+212 -2
View File
@@ -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",
+5 -3
View File
@@ -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);
+33 -9
View File
@@ -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),
};
}