Skip to content

Commit 3880ab1

Browse files
committed
fix(line): cover action truncation sibling surfaces
1 parent 5453fdf commit 3880ab1

8 files changed

Lines changed: 111 additions & 27 deletions

File tree

extensions/line/src/actions.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,24 @@ import type { messagingApi } from "@line/bot-sdk";
33
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
44

55
export type Action = messagingApi.Action;
6+
export const LINE_ACTION_LABEL_LIMIT = 20;
7+
export const LINE_ACTION_DATA_LIMIT = 300;
8+
9+
export function truncateLineActionLabel(label: string, limit = LINE_ACTION_LABEL_LIMIT): string {
10+
return truncateUtf16Safe(label, limit);
11+
}
12+
13+
export function truncateLineActionData(data: string): string {
14+
return truncateUtf16Safe(data, LINE_ACTION_DATA_LIMIT);
15+
}
616

717
/**
818
* Create a message action (sends text when tapped)
919
*/
1020
export function messageAction(label: string, text?: string): Action {
1121
return {
1222
type: "message",
13-
label: truncateUtf16Safe(label, 20),
23+
label: truncateLineActionLabel(label),
1424
text: text ?? label,
1525
};
1626
}
@@ -21,7 +31,7 @@ export function messageAction(label: string, text?: string): Action {
2131
export function uriAction(label: string, uri: string): Action {
2232
return {
2333
type: "uri",
24-
label: truncateUtf16Safe(label, 20),
34+
label: truncateLineActionLabel(label),
2535
uri,
2636
};
2737
}
@@ -32,9 +42,9 @@ export function uriAction(label: string, uri: string): Action {
3242
export function postbackAction(label: string, data: string, displayText?: string): Action {
3343
return {
3444
type: "postback",
35-
label: truncateUtf16Safe(label, 20),
36-
data: truncateUtf16Safe(data, 300),
37-
displayText: displayText === undefined ? undefined : truncateUtf16Safe(displayText, 300),
45+
label: truncateLineActionLabel(label),
46+
data: truncateLineActionData(data),
47+
displayText: displayText === undefined ? undefined : truncateLineActionData(displayText),
3848
};
3949
}
4050

@@ -53,8 +63,8 @@ export function datetimePickerAction(
5363
): Action {
5464
return {
5565
type: "datetimepicker",
56-
label: truncateUtf16Safe(label, 20),
57-
data: truncateUtf16Safe(data, 300),
66+
label: truncateLineActionLabel(label),
67+
data: truncateLineActionData(data),
5868
mode,
5969
initial: options?.initial,
6070
max: options?.max,

extensions/line/src/card-command.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
33
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
44
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
5+
import { messageAction, postbackAction, uriAction } from "./actions.js";
56
import {
67
createActionCard,
78
createImageCard,
@@ -62,22 +63,17 @@ function parseActions(actionsStr: string | undefined): CardAction[] {
6263
if (actionData.startsWith("http://") || actionData.startsWith("https://")) {
6364
results.push({
6465
label,
65-
action: { type: "uri", label: label.slice(0, 20), uri: actionData },
66+
action: uriAction(label, actionData),
6667
});
6768
} else if (actionData.includes("=")) {
6869
results.push({
6970
label,
70-
action: {
71-
type: "postback",
72-
label: label.slice(0, 20),
73-
data: actionData.slice(0, 300),
74-
displayText: label,
75-
},
71+
action: postbackAction(label, actionData, label),
7672
});
7773
} else {
7874
results.push({
7975
label,
80-
action: { type: "message", label: label.slice(0, 20), text: actionData },
76+
action: messageAction(label, actionData),
8177
});
8278
}
8379
}

extensions/line/src/flex-templates/media-control-cards.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Line plugin module implements media control cards behavior.
2+
import { truncateLineActionLabel } from "../actions.js";
23
import type {
34
FlexBox,
45
FlexBubble,
@@ -233,7 +234,7 @@ export function createMediaPlayerCard(params: {
233234
type: "button",
234235
action: {
235236
type: "postback",
236-
label: action.label.slice(0, 15),
237+
label: truncateLineActionLabel(action.label, 15),
237238
data: action.data,
238239
},
239240
style: "secondary",
@@ -518,7 +519,7 @@ export function createDeviceControlCard(params: {
518519
type: "button",
519520
action: {
520521
type: "postback",
521-
label: buttonLabel.slice(0, 18),
522+
label: truncateLineActionLabel(buttonLabel, 18),
522523
data: ctrl.data,
523524
},
524525
style: ctrl.style ?? "secondary",

extensions/line/src/markdown-to-line.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
processLineMessage,
99
convertTableToFlexBubble,
1010
convertCodeBlockToFlexBubble,
11+
convertLinksToFlexBubble,
1112
hasMarkdownToConvert,
1213
} from "./markdown-to-line.js";
1314

@@ -132,6 +133,18 @@ describe("extractLinks", () => {
132133
});
133134
});
134135

136+
describe("convertLinksToFlexBubble", () => {
137+
it("truncates link button labels without leaving lone surrogates", () => {
138+
const bubble = convertLinksToFlexBubble([
139+
{ text: "1234567890123456789😀", url: "https://example.com" },
140+
]);
141+
const footer = bubble.footer as { contents: Array<{ action: { label: string } }> };
142+
143+
expect(footer.contents[0].action.label).toBe("1234567890123456789");
144+
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(footer.contents[0].action.label)).toBe(false);
145+
});
146+
});
147+
135148
describe("stripMarkdown", () => {
136149
it("strips inline markdown marker variants", () => {
137150
const cases = [

extensions/line/src/markdown-to-line.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Line plugin module implements markdown to line behavior.
22
import type { messagingApi } from "@line/bot-sdk";
33
import { stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
4+
import { uriAction } from "./actions.js";
45
import { createReceiptCard, toFlexMessage, type FlexBubble } from "./flex-templates.js";
56
export { stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
67

@@ -305,11 +306,7 @@ export interface MarkdownLink {
305306
export function convertLinksToFlexBubble(links: MarkdownLink[]): FlexBubble {
306307
const buttons: FlexComponent[] = links.slice(0, 4).map((link, index) => ({
307308
type: "button",
308-
action: {
309-
type: "uri",
310-
label: link.text.slice(0, 20), // LINE button label limit
311-
uri: link.url,
312-
},
309+
action: uriAction(link.text, link.url),
313310
style: index === 0 ? "primary" : "secondary",
314311
margin: index > 0 ? "sm" : undefined,
315312
}));

extensions/line/src/message-cards.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Line tests cover message cards plugin behavior.
22
import { describe, expect, it } from "vitest";
33
import { datetimePickerAction, postbackAction, uriAction } from "./actions.js";
4+
import { registerLineCardCommand } from "./card-command.js";
45
import {
56
createActionCard,
67
createCarousel,
@@ -9,6 +10,7 @@ import {
910
createImageCard,
1011
createInfoCard,
1112
createListCard,
13+
createMediaPlayerCard,
1214
} from "./flex-templates.js";
1315
import {
1416
createConfirmTemplate,
@@ -338,4 +340,62 @@ describe("action label/data surrogate-safe truncation", () => {
338340
expect(action.data).toBe("d".repeat(299));
339341
expect(loneHighSurrogate.test(action.data)).toBe(false);
340342
});
343+
344+
it("/card action command uses surrogate-safe labels and postback data", async () => {
345+
const registerCommand = (command: unknown) => {
346+
const { handler } = command as {
347+
handler: (ctx: { args: string; channel: string }) => Promise<unknown>;
348+
};
349+
return handler({
350+
channel: "line",
351+
args: `action "Menu" "Body" --actions "${labelWithEmoji}|k=${"d".repeat(297)}😀"`,
352+
});
353+
};
354+
const result = (await registerCommandWithHandler(registerCommand)) as {
355+
channelData: {
356+
line: {
357+
flexMessage: {
358+
contents: { footer: { contents: Array<{ action: { label: string; data: string } }> } };
359+
};
360+
};
361+
};
362+
};
363+
const action = result.channelData.line.flexMessage.contents.footer.contents[0].action;
364+
365+
expect(action.label).toBe("1234567890123456789");
366+
expect(loneHighSurrogate.test(action.label)).toBe(false);
367+
expect(action.data).toBe(`k=${"d".repeat(297)}`);
368+
expect(loneHighSurrogate.test(action.data)).toBe(false);
369+
});
370+
371+
it("media control postback labels truncate on surrogate boundaries", () => {
372+
const card = createMediaPlayerCard({
373+
title: "Track",
374+
controls: {
375+
play: { data: "play" },
376+
},
377+
extraActions: [{ label: `${"x".repeat(14)}😀`, data: "extra" }],
378+
});
379+
const footer = card.footer as {
380+
contents: Array<{ contents?: Array<{ action?: { data?: string; label: string } }> }>;
381+
};
382+
const extraAction = footer.contents
383+
.flatMap((content) => content.contents ?? [])
384+
.find((button) => button.action?.data === "extra")?.action;
385+
386+
expect(extraAction?.label).toBe("x".repeat(14));
387+
expect(loneHighSurrogate.test(extraAction?.label ?? "")).toBe(false);
388+
});
341389
});
390+
391+
async function registerCommandWithHandler(
392+
runHandler: (command: unknown) => Promise<unknown>,
393+
): Promise<unknown> {
394+
let result: unknown;
395+
registerLineCardCommand({
396+
registerCommand(command: unknown) {
397+
result = runHandler(command);
398+
},
399+
} as never);
400+
return result;
401+
}

extensions/line/src/send.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,16 @@ describe("LINE send helpers", () => {
156156
expect(quickReply.items).toHaveLength(13);
157157
});
158158

159+
it("truncates quick reply labels without leaving lone surrogates", () => {
160+
const label = "1234567890123456789😀";
161+
const quickReply = sendModule.createQuickReplyItems([label]);
162+
const item = quickReply.items?.[0] as { action: { label: string; text: string } } | undefined;
163+
164+
expect(item?.action.label).toBe("1234567890123456789");
165+
expect(item?.action.text).toBe(label);
166+
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(item?.action.label ?? "")).toBe(false);
167+
});
168+
159169
it("pushes images via normalized LINE target", async () => {
160170
const result = await sendModule.pushImageMessage(
161171
"line:user:U123",

extensions/line/src/send.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
55
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
66
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
77
import { resolveLineAccount } from "./accounts.js";
8+
import { messageAction } from "./actions.js";
89
import { resolveLineChannelAccessToken } from "./channel-access-token.js";
910
import { validateLineMediaUrl } from "./outbound-media.js";
1011
import { createLineSendReceipt } from "./send-receipt.js";
@@ -454,11 +455,7 @@ export async function pushTextMessageWithQuickReplies(
454455
export function createQuickReplyItems(labels: string[]): QuickReply {
455456
const items: QuickReplyItem[] = labels.slice(0, 13).map((label) => ({
456457
type: "action",
457-
action: {
458-
type: "message",
459-
label: label.slice(0, 20),
460-
text: label,
461-
},
458+
action: messageAction(label, label),
462459
}));
463460
return { items };
464461
}

0 commit comments

Comments
 (0)