diff --git a/extensions/line/src/actions.ts b/extensions/line/src/actions.ts index 5fa37557dba3..b26ed538cc5d 100644 --- a/extensions/line/src/actions.ts +++ b/extensions/line/src/actions.ts @@ -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 & { + [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 { + 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)[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 = { ...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, - }; + }); } diff --git a/extensions/line/src/flex-templates/basic-cards.ts b/extensions/line/src/flex-templates/basic-cards.ts index 8b29d71f89d9..707ac1786641 100644 --- a/extensions/line/src/flex-templates/basic-cards.ts +++ b/extensions/line/src/flex-templates/basic-cards.ts @@ -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, diff --git a/extensions/line/src/flex-templates/media-control-cards.ts b/extensions/line/src/flex-templates/media-control-cards.ts index 30c0bf17017a..e73594c9bb35 100644 --- a/extensions/line/src/flex-templates/media-control-cards.ts +++ b/extensions/line/src/flex-templates/media-control-cards.ts @@ -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", diff --git a/extensions/line/src/flex-templates/schedule-cards.ts b/extensions/line/src/flex-templates/schedule-cards.ts index 1480f3d8bc9e..94c25ae6190d 100644 --- a/extensions/line/src/flex-templates/schedule-cards.ts +++ b/extensions/line/src/flex-templates/schedule-cards.ts @@ -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), }, }; } diff --git a/extensions/line/src/message-cards.test.ts b/extensions/line/src/message-cards.test.ts index 4fbbb7b2ef36..2c623315d0f6 100644 --- a/extensions/line/src/message-cards.test.ts +++ b/extensions/line/src/message-cards.test.ts @@ -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; @@ -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( diff --git a/extensions/line/src/reply-payload-transform.test.ts b/extensions/line/src/reply-payload-transform.test.ts index 2bb6113c738d..93c1f1ab6457 100644 --- a/extensions/line/src/reply-payload-transform.test.ts +++ b/extensions/line/src/reply-payload-transform.test.ts @@ -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 = [ diff --git a/extensions/line/src/rich-menu.test.ts b/extensions/line/src/rich-menu.test.ts index add025814f3b..149da705240d 100644 --- a/extensions/line/src/rich-menu.test.ts +++ b/extensions/line/src/rich-menu.test.ts @@ -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); diff --git a/extensions/line/src/send.test.ts b/extensions/line/src/send.test.ts index b787beaef3e8..3b93d3071bf6 100644 --- a/extensions/line/src/send.test.ts +++ b/extensions/line/src/send.test.ts @@ -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 }>; + }; + const replied = replyMessageMock.mock.calls[0]?.[0] as { + messages: Array<{ contents: Record }>; + }; + 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", diff --git a/extensions/line/src/send.ts b/extensions/line/src/send.ts index 6bf63d6e97b7..61772e884463 100644 --- a/extensions/line/src/send.ts +++ b/extensions/line/src/send.ts @@ -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 { const { account, client } = createLineMessagingClient(opts); + const normalizedMessages = messages.map(normalizeLineMessageActions); await client.replyMessage({ replyToken, - messages, + messages: normalizedMessages, }); recordLineOutboundActivity(account.accountId); diff --git a/extensions/line/src/template-messages.ts b/extensions/line/src/template-messages.ts index 0492af1b22fa..bc9c8d2b0199 100644 --- a/extensions/line/src/template-messages.ts +++ b/extensions/line/src/template-messages.ts @@ -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), }; }