@openclaw/line 2026.5.2-beta.2 → 2026.5.3-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/accounts-UXAvdBo_.js +105 -0
  2. package/dist/api.js +11 -0
  3. package/dist/basic-cards-7DTG6E-A.js +307 -0
  4. package/dist/card-command-UlPc6Vuc.js +240 -0
  5. package/dist/channel-Rls9s60K.js +601 -0
  6. package/dist/channel-plugin-api.js +2 -0
  7. package/dist/channel.runtime-bXoj8_Sl.js +4 -0
  8. package/dist/contract-api.js +2 -0
  9. package/dist/index.js +45 -0
  10. package/dist/markdown-to-line-CCWK6PJh.js +789 -0
  11. package/dist/monitor-B4_RxHc0.js +1523 -0
  12. package/dist/monitor.runtime-DyxYlrM9.js +2 -0
  13. package/dist/outbound-media-BfhBmWq5.js +55 -0
  14. package/dist/outbound.runtime-B-luYwEB.js +2 -0
  15. package/dist/probe-D9BPzb2n.js +30 -0
  16. package/dist/probe.runtime-N91OWW1J.js +2 -0
  17. package/dist/quick-reply-fallback-HskUUWsn.js +55 -0
  18. package/dist/reply-payload-transform-CFGTs9ln.js +713 -0
  19. package/dist/runtime-api.js +277 -0
  20. package/dist/schedule-cards-BwQChTdM.js +359 -0
  21. package/dist/secret-contract-api.js +5 -0
  22. package/dist/setup-api.js +2 -0
  23. package/dist/setup-entry.js +11 -0
  24. package/dist/setup-surface-DCH7CE-y.js +281 -0
  25. package/package.json +14 -6
  26. package/api.ts +0 -11
  27. package/channel-plugin-api.ts +0 -1
  28. package/contract-api.ts +0 -5
  29. package/index.ts +0 -54
  30. package/runtime-api.ts +0 -187
  31. package/secret-contract-api.ts +0 -4
  32. package/setup-api.ts +0 -2
  33. package/setup-entry.ts +0 -9
  34. package/src/account-helpers.ts +0 -16
  35. package/src/accounts.test.ts +0 -290
  36. package/src/accounts.ts +0 -187
  37. package/src/actions.ts +0 -61
  38. package/src/auto-reply-delivery.test.ts +0 -248
  39. package/src/auto-reply-delivery.ts +0 -200
  40. package/src/bindings.ts +0 -65
  41. package/src/bot-access.ts +0 -48
  42. package/src/bot-handlers.test.ts +0 -1089
  43. package/src/bot-handlers.ts +0 -642
  44. package/src/bot-message-context.test.ts +0 -405
  45. package/src/bot-message-context.ts +0 -581
  46. package/src/bot.ts +0 -70
  47. package/src/card-command.ts +0 -347
  48. package/src/channel-access-token.ts +0 -14
  49. package/src/channel-api.ts +0 -17
  50. package/src/channel-setup-status.contract.test.ts +0 -70
  51. package/src/channel-shared.ts +0 -48
  52. package/src/channel.logout.test.ts +0 -145
  53. package/src/channel.runtime.ts +0 -3
  54. package/src/channel.sendPayload.test.ts +0 -514
  55. package/src/channel.setup.ts +0 -11
  56. package/src/channel.status.test.ts +0 -63
  57. package/src/channel.ts +0 -154
  58. package/src/config-adapter.ts +0 -29
  59. package/src/config-schema.ts +0 -57
  60. package/src/download.test.ts +0 -133
  61. package/src/download.ts +0 -87
  62. package/src/flex-templates/basic-cards.ts +0 -395
  63. package/src/flex-templates/common.ts +0 -20
  64. package/src/flex-templates/media-control-cards.ts +0 -555
  65. package/src/flex-templates/message.ts +0 -13
  66. package/src/flex-templates/schedule-cards.ts +0 -467
  67. package/src/flex-templates/types.ts +0 -22
  68. package/src/flex-templates.ts +0 -32
  69. package/src/gateway.ts +0 -129
  70. package/src/group-keys.test.ts +0 -123
  71. package/src/group-keys.ts +0 -65
  72. package/src/group-policy.ts +0 -22
  73. package/src/markdown-to-line.test.ts +0 -348
  74. package/src/markdown-to-line.ts +0 -416
  75. package/src/message-cards.test.ts +0 -204
  76. package/src/monitor.lifecycle.test.ts +0 -421
  77. package/src/monitor.runtime.ts +0 -1
  78. package/src/monitor.ts +0 -506
  79. package/src/outbound-media.test.ts +0 -189
  80. package/src/outbound-media.ts +0 -120
  81. package/src/outbound.runtime.ts +0 -12
  82. package/src/outbound.ts +0 -356
  83. package/src/probe.contract.test.ts +0 -9
  84. package/src/probe.runtime.ts +0 -1
  85. package/src/probe.ts +0 -34
  86. package/src/quick-reply-fallback.ts +0 -10
  87. package/src/reply-chunks.test.ts +0 -179
  88. package/src/reply-chunks.ts +0 -110
  89. package/src/reply-payload-transform.test.ts +0 -387
  90. package/src/reply-payload-transform.ts +0 -317
  91. package/src/rich-menu.test.ts +0 -310
  92. package/src/rich-menu.ts +0 -326
  93. package/src/runtime.ts +0 -32
  94. package/src/send.test.ts +0 -346
  95. package/src/send.ts +0 -489
  96. package/src/setup-core.ts +0 -149
  97. package/src/setup-runtime-api.ts +0 -9
  98. package/src/setup-surface.test.ts +0 -474
  99. package/src/setup-surface.ts +0 -227
  100. package/src/signature.test.ts +0 -34
  101. package/src/signature.ts +0 -24
  102. package/src/status.ts +0 -37
  103. package/src/template-messages.ts +0 -333
  104. package/src/types.ts +0 -128
  105. package/src/webhook-node.test.ts +0 -513
  106. package/src/webhook-node.ts +0 -131
  107. package/src/webhook-utils.ts +0 -10
  108. package/src/webhook.ts +0 -111
  109. package/tsconfig.json +0 -16
@@ -1,317 +0,0 @@
1
- import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
2
- import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
3
- import {
4
- createAgendaCard,
5
- createAppleTvRemoteCard,
6
- createDeviceControlCard,
7
- createEventCard,
8
- createMediaPlayerCard,
9
- } from "./flex-templates.js";
10
- import type { LineChannelData } from "./types.js";
11
-
12
- /**
13
- * Parse LINE-specific directives from text and extract them into ReplyPayload fields.
14
- *
15
- * Supported directives:
16
- * - [[quick_replies: option1, option2, option3]]
17
- * - [[location: title | address | latitude | longitude]]
18
- * - [[confirm: question | yes_label | no_label]]
19
- * - [[buttons: title | text | btn1:data1, btn2:data2]]
20
- * - [[media_player: title | artist | source | imageUrl | playing/paused]]
21
- * - [[event: title | date | time | location | description]]
22
- * - [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
23
- * - [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
24
- * - [[appletv_remote: name | status]]
25
- */
26
- export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
27
- let text = payload.text;
28
- if (!text) {
29
- return payload;
30
- }
31
-
32
- const result: ReplyPayload = { ...payload };
33
- const lineData: LineChannelData = {
34
- ...(result.channelData?.line as LineChannelData | undefined),
35
- };
36
- const toSlug = (value: string): string =>
37
- normalizeLowercaseStringOrEmpty(value)
38
- .replace(/[^a-z0-9]+/g, "_")
39
- .replace(/^_+|_+$/g, "") || "device";
40
- const lineActionData = (action: string, extras?: Record<string, string>): string => {
41
- const base = [`line.action=${encodeURIComponent(action)}`];
42
- if (extras) {
43
- for (const [key, value] of Object.entries(extras)) {
44
- base.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
45
- }
46
- }
47
- return base.join("&");
48
- };
49
-
50
- const quickRepliesMatch = text.match(/\[\[quick_replies:\s*([^\]]+)\]\]/i);
51
- if (quickRepliesMatch) {
52
- const options = quickRepliesMatch[1]
53
- .split(",")
54
- .map((s) => s.trim())
55
- .filter(Boolean);
56
- if (options.length > 0) {
57
- lineData.quickReplies = [...(lineData.quickReplies || []), ...options];
58
- }
59
- text = text.replace(quickRepliesMatch[0], "").trim();
60
- }
61
-
62
- const locationMatch = text.match(/\[\[location:\s*([^\]]+)\]\]/i);
63
- if (locationMatch && !lineData.location) {
64
- const parts = locationMatch[1].split("|").map((s) => s.trim());
65
- if (parts.length >= 4) {
66
- const [title, address, latStr, lonStr] = parts;
67
- const latitude = Number.parseFloat(latStr);
68
- const longitude = Number.parseFloat(lonStr);
69
- if (!Number.isNaN(latitude) && !Number.isNaN(longitude)) {
70
- lineData.location = {
71
- title: title || "Location",
72
- address: address || "",
73
- latitude,
74
- longitude,
75
- };
76
- }
77
- }
78
- text = text.replace(locationMatch[0], "").trim();
79
- }
80
-
81
- const confirmMatch = text.match(/\[\[confirm:\s*([^\]]+)\]\]/i);
82
- if (confirmMatch && !lineData.templateMessage) {
83
- const parts = confirmMatch[1].split("|").map((s) => s.trim());
84
- if (parts.length >= 3) {
85
- const [question, yesPart, noPart] = parts;
86
- const [yesLabel, yesData] = yesPart.includes(":")
87
- ? yesPart.split(":").map((s) => s.trim())
88
- : [yesPart, normalizeLowercaseStringOrEmpty(yesPart)];
89
- const [noLabel, noData] = noPart.includes(":")
90
- ? noPart.split(":").map((s) => s.trim())
91
- : [noPart, normalizeLowercaseStringOrEmpty(noPart)];
92
-
93
- lineData.templateMessage = {
94
- type: "confirm",
95
- text: question,
96
- confirmLabel: yesLabel,
97
- confirmData: yesData,
98
- cancelLabel: noLabel,
99
- cancelData: noData,
100
- altText: question,
101
- };
102
- }
103
- text = text.replace(confirmMatch[0], "").trim();
104
- }
105
-
106
- const buttonsMatch = text.match(/\[\[buttons:\s*([^\]]+)\]\]/i);
107
- if (buttonsMatch && !lineData.templateMessage) {
108
- const parts = buttonsMatch[1].split("|").map((s) => s.trim());
109
- if (parts.length >= 3) {
110
- const [title, bodyText, actionsStr] = parts;
111
-
112
- const actions = actionsStr.split(",").map((actionStr) => {
113
- const trimmed = actionStr.trim();
114
- const colonIndex = (() => {
115
- const index = trimmed.indexOf(":");
116
- if (index === -1) {
117
- return -1;
118
- }
119
- const lower = normalizeLowercaseStringOrEmpty(trimmed);
120
- if (lower.startsWith("http://") || lower.startsWith("https://")) {
121
- return -1;
122
- }
123
- return index;
124
- })();
125
-
126
- let label: string;
127
- let data: string;
128
-
129
- if (colonIndex === -1) {
130
- label = trimmed;
131
- data = trimmed;
132
- } else {
133
- label = trimmed.slice(0, colonIndex).trim();
134
- data = trimmed.slice(colonIndex + 1).trim();
135
- }
136
-
137
- if (data.startsWith("http://") || data.startsWith("https://")) {
138
- return { type: "uri" as const, label, uri: data };
139
- }
140
- if (data.includes("=")) {
141
- return { type: "postback" as const, label, data };
142
- }
143
- return { type: "message" as const, label, data: data || label };
144
- });
145
-
146
- if (actions.length > 0) {
147
- lineData.templateMessage = {
148
- type: "buttons",
149
- title,
150
- text: bodyText,
151
- actions: actions.slice(0, 4),
152
- altText: `${title}: ${bodyText}`,
153
- };
154
- }
155
- }
156
- text = text.replace(buttonsMatch[0], "").trim();
157
- }
158
-
159
- const mediaPlayerMatch = text.match(/\[\[media_player:\s*([^\]]+)\]\]/i);
160
- if (mediaPlayerMatch && !lineData.flexMessage) {
161
- const parts = mediaPlayerMatch[1].split("|").map((s) => s.trim());
162
- if (parts.length >= 1) {
163
- const [title, artist, source, imageUrl, statusStr] = parts;
164
- const isPlaying = normalizeLowercaseStringOrEmpty(statusStr) === "playing";
165
- const validImageUrl = imageUrl?.startsWith("https://") ? imageUrl : undefined;
166
- const deviceKey = toSlug(source || title || "media");
167
- const card = createMediaPlayerCard({
168
- title: title || "Unknown Track",
169
- subtitle: artist || undefined,
170
- source: source || undefined,
171
- imageUrl: validImageUrl,
172
- isPlaying: statusStr ? isPlaying : undefined,
173
- controls: {
174
- previous: { data: lineActionData("previous", { "line.device": deviceKey }) },
175
- play: { data: lineActionData("play", { "line.device": deviceKey }) },
176
- pause: { data: lineActionData("pause", { "line.device": deviceKey }) },
177
- next: { data: lineActionData("next", { "line.device": deviceKey }) },
178
- },
179
- });
180
-
181
- lineData.flexMessage = {
182
- altText: `🎵 ${title}${artist ? ` - ${artist}` : ""}`,
183
- contents: card,
184
- };
185
- }
186
- text = text.replace(mediaPlayerMatch[0], "").trim();
187
- }
188
-
189
- const eventMatch = text.match(/\[\[event:\s*([^\]]+)\]\]/i);
190
- if (eventMatch && !lineData.flexMessage) {
191
- const parts = eventMatch[1].split("|").map((s) => s.trim());
192
- if (parts.length >= 2) {
193
- const [title, date, time, location, description] = parts;
194
-
195
- const card = createEventCard({
196
- title: title || "Event",
197
- date: date || "TBD",
198
- time: time || undefined,
199
- location: location || undefined,
200
- description: description || undefined,
201
- });
202
-
203
- lineData.flexMessage = {
204
- altText: `📅 ${title} - ${date}${time ? ` ${time}` : ""}`,
205
- contents: card,
206
- };
207
- }
208
- text = text.replace(eventMatch[0], "").trim();
209
- }
210
-
211
- const appleTvMatch = text.match(/\[\[appletv_remote:\s*([^\]]+)\]\]/i);
212
- if (appleTvMatch && !lineData.flexMessage) {
213
- const parts = appleTvMatch[1].split("|").map((s) => s.trim());
214
- if (parts.length >= 1) {
215
- const [deviceName, status] = parts;
216
- const deviceKey = toSlug(deviceName || "apple_tv");
217
-
218
- const card = createAppleTvRemoteCard({
219
- deviceName: deviceName || "Apple TV",
220
- status: status || undefined,
221
- actionData: {
222
- up: lineActionData("up", { "line.device": deviceKey }),
223
- down: lineActionData("down", { "line.device": deviceKey }),
224
- left: lineActionData("left", { "line.device": deviceKey }),
225
- right: lineActionData("right", { "line.device": deviceKey }),
226
- select: lineActionData("select", { "line.device": deviceKey }),
227
- menu: lineActionData("menu", { "line.device": deviceKey }),
228
- home: lineActionData("home", { "line.device": deviceKey }),
229
- play: lineActionData("play", { "line.device": deviceKey }),
230
- pause: lineActionData("pause", { "line.device": deviceKey }),
231
- volumeUp: lineActionData("volume_up", { "line.device": deviceKey }),
232
- volumeDown: lineActionData("volume_down", { "line.device": deviceKey }),
233
- mute: lineActionData("mute", { "line.device": deviceKey }),
234
- },
235
- });
236
-
237
- lineData.flexMessage = {
238
- altText: `📺 ${deviceName || "Apple TV"} Remote`,
239
- contents: card,
240
- };
241
- }
242
- text = text.replace(appleTvMatch[0], "").trim();
243
- }
244
-
245
- const agendaMatch = text.match(/\[\[agenda:\s*([^\]]+)\]\]/i);
246
- if (agendaMatch && !lineData.flexMessage) {
247
- const parts = agendaMatch[1].split("|").map((s) => s.trim());
248
- if (parts.length >= 2) {
249
- const [title, eventsStr] = parts;
250
- const events = eventsStr.split(",").map((eventStr) => {
251
- const trimmed = eventStr.trim();
252
- const colonIdx = trimmed.lastIndexOf(":");
253
- if (colonIdx > 0) {
254
- return {
255
- title: trimmed.slice(0, colonIdx).trim(),
256
- time: trimmed.slice(colonIdx + 1).trim(),
257
- };
258
- }
259
- return { title: trimmed };
260
- });
261
-
262
- const card = createAgendaCard({
263
- title: title || "Agenda",
264
- events,
265
- });
266
-
267
- lineData.flexMessage = {
268
- altText: `📋 ${title} (${events.length} events)`,
269
- contents: card,
270
- };
271
- }
272
- text = text.replace(agendaMatch[0], "").trim();
273
- }
274
-
275
- const deviceMatch = text.match(/\[\[device:\s*([^\]]+)\]\]/i);
276
- if (deviceMatch && !lineData.flexMessage) {
277
- const parts = deviceMatch[1].split("|").map((s) => s.trim());
278
- if (parts.length >= 1) {
279
- const [deviceName, deviceType, status, controlsStr] = parts;
280
- const deviceKey = toSlug(deviceName || "device");
281
- const controls = controlsStr
282
- ? controlsStr.split(",").map((ctrlStr) => {
283
- const [label, data] = ctrlStr.split(":").map((s) => s.trim());
284
- const action = data || normalizeLowercaseStringOrEmpty(label).replace(/\s+/g, "_");
285
- return { label, data: lineActionData(action, { "line.device": deviceKey }) };
286
- })
287
- : [];
288
-
289
- const card = createDeviceControlCard({
290
- deviceName: deviceName || "Device",
291
- deviceType: deviceType || undefined,
292
- status: status || undefined,
293
- controls,
294
- });
295
-
296
- lineData.flexMessage = {
297
- altText: `📱 ${deviceName}${status ? `: ${status}` : ""}`,
298
- contents: card,
299
- };
300
- }
301
- text = text.replace(deviceMatch[0], "").trim();
302
- }
303
-
304
- text = text.replace(/\n{3,}/g, "\n\n").trim();
305
-
306
- result.text = text || undefined;
307
- if (Object.keys(lineData).length > 0) {
308
- result.channelData = { ...result.channelData, line: lineData };
309
- }
310
- return result;
311
- }
312
-
313
- export function hasLineDirectives(text: string): boolean {
314
- return /\[\[(quick_replies|location|confirm|buttons|media_player|event|agenda|device|appletv_remote):/i.test(
315
- text,
316
- );
317
- }
@@ -1,310 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
5
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
- import {
7
- createDefaultMenuConfig,
8
- createGridLayout,
9
- datetimePickerAction,
10
- messageAction,
11
- postbackAction,
12
- uploadRichMenuImage,
13
- uriAction,
14
- } from "./rich-menu.js";
15
-
16
- const { setRichMenuImageMock, MessagingApiBlobClientMock } = vi.hoisted(() => {
17
- const setRichMenuImageMock = vi.fn();
18
- const MessagingApiBlobClientMock = vi.fn(function () {
19
- return { setRichMenuImage: setRichMenuImageMock };
20
- });
21
- return { setRichMenuImageMock, MessagingApiBlobClientMock };
22
- });
23
-
24
- vi.mock("@line/bot-sdk", () => ({
25
- messagingApi: { MessagingApiBlobClient: MessagingApiBlobClientMock },
26
- }));
27
-
28
- describe("messageAction", () => {
29
- it("creates message actions with explicit or default text", () => {
30
- const cases = [
31
- { name: "explicit text", label: "Help", text: "/help", expectedText: "/help" },
32
- { name: "defaults to label", label: "Click", text: undefined, expectedText: "Click" },
33
- ] as const;
34
- for (const testCase of cases) {
35
- const action = testCase.text
36
- ? messageAction(testCase.label, testCase.text)
37
- : messageAction(testCase.label);
38
- expect(action.type, testCase.name).toBe("message");
39
- expect(action.label, testCase.name).toBe(testCase.label);
40
- expect((action as { text: string }).text, testCase.name).toBe(testCase.expectedText);
41
- }
42
- });
43
- });
44
-
45
- describe("uriAction", () => {
46
- it("creates a URI action", () => {
47
- const action = uriAction("Open", "https://example.com");
48
-
49
- expect(action.type).toBe("uri");
50
- expect(action.label).toBe("Open");
51
- expect((action as { uri: string }).uri).toBe("https://example.com");
52
- });
53
- });
54
-
55
- describe("action label truncation", () => {
56
- it.each([
57
- {
58
- createAction: () => messageAction("This is a very long label text"),
59
- expectedLabel: "This is a very long ",
60
- },
61
- {
62
- createAction: () => uriAction("Click here to visit our website", "https://example.com"),
63
- expectedLabel: "Click here to visit ",
64
- },
65
- ])("truncates labels to 20 characters", ({ createAction, expectedLabel }) => {
66
- const action = createAction();
67
- expect(action.label).toBe(expectedLabel);
68
- expect((action.label ?? "").length).toBe(20);
69
- });
70
- });
71
-
72
- describe("postbackAction", () => {
73
- it("creates a postback action", () => {
74
- const action = postbackAction("Select", "action=select&item=1", "Selected item 1");
75
-
76
- expect(action.type).toBe("postback");
77
- expect(action.label).toBe("Select");
78
- expect((action as { data: string }).data).toBe("action=select&item=1");
79
- expect((action as { displayText: string }).displayText).toBe("Selected item 1");
80
- });
81
-
82
- it("applies postback payload truncation and displayText behavior", () => {
83
- const truncatedData = postbackAction("Test", "x".repeat(400));
84
- expect((truncatedData as { data: string }).data.length).toBe(300);
85
-
86
- const truncatedDisplay = postbackAction("Test", "data", "y".repeat(400));
87
- expect((truncatedDisplay as { displayText: string }).displayText?.length).toBe(300);
88
-
89
- const noDisplayText = postbackAction("Test", "data");
90
- expect((noDisplayText as { displayText?: string }).displayText).toBeUndefined();
91
- });
92
- });
93
-
94
- describe("datetimePickerAction", () => {
95
- it("creates picker actions for all supported modes", () => {
96
- const cases = [
97
- { label: "Pick date", data: "date_picked", mode: "date" as const },
98
- { label: "Pick time", data: "time_picked", mode: "time" as const },
99
- { label: "Pick datetime", data: "datetime_picked", mode: "datetime" as const },
100
- ];
101
- for (const testCase of cases) {
102
- const action = datetimePickerAction(testCase.label, testCase.data, testCase.mode);
103
- expect(action.type).toBe("datetimepicker");
104
- expect(action.label).toBe(testCase.label);
105
- expect((action as { mode: string }).mode).toBe(testCase.mode);
106
- expect((action as { data: string }).data).toBe(testCase.data);
107
- }
108
- });
109
-
110
- it("includes initial/min/max when provided", () => {
111
- const action = datetimePickerAction("Pick", "data", "date", {
112
- initial: "2024-06-15",
113
- min: "2024-01-01",
114
- max: "2024-12-31",
115
- });
116
-
117
- expect((action as { initial: string }).initial).toBe("2024-06-15");
118
- expect((action as { min: string }).min).toBe("2024-01-01");
119
- expect((action as { max: string }).max).toBe("2024-12-31");
120
- });
121
- });
122
-
123
- describe("createGridLayout", () => {
124
- function createSixSimpleActions() {
125
- return [
126
- messageAction("A1"),
127
- messageAction("A2"),
128
- messageAction("A3"),
129
- messageAction("A4"),
130
- messageAction("A5"),
131
- messageAction("A6"),
132
- ] as [
133
- ReturnType<typeof messageAction>,
134
- ReturnType<typeof messageAction>,
135
- ReturnType<typeof messageAction>,
136
- ReturnType<typeof messageAction>,
137
- ReturnType<typeof messageAction>,
138
- ReturnType<typeof messageAction>,
139
- ];
140
- }
141
-
142
- it("computes expected 2x3 layout for supported menu heights", () => {
143
- const actions = createSixSimpleActions();
144
- const cases = [
145
- { height: 1686, firstRowY: 0, secondRowY: 843, rowHeight: 843 },
146
- { height: 843, firstRowY: 0, secondRowY: 421, rowHeight: 421 },
147
- ] as const;
148
- for (const testCase of cases) {
149
- const areas = createGridLayout(testCase.height, actions);
150
- expect(areas.length).toBe(6);
151
- expect(areas[0]?.bounds.y).toBe(testCase.firstRowY);
152
- expect(areas[0]?.bounds.height).toBe(testCase.rowHeight);
153
- expect(areas[3]?.bounds.y).toBe(testCase.secondRowY);
154
- expect(areas[0]?.bounds.x).toBe(0);
155
- expect(areas[1]?.bounds.x).toBe(833);
156
- expect(areas[2]?.bounds.x).toBe(1666);
157
- }
158
- });
159
-
160
- it("assigns correct actions to areas", () => {
161
- const actions = [
162
- messageAction("Help", "/help"),
163
- messageAction("Status", "/status"),
164
- messageAction("Settings", "/settings"),
165
- messageAction("About", "/about"),
166
- messageAction("Feedback", "/feedback"),
167
- messageAction("Contact", "/contact"),
168
- ] as [
169
- ReturnType<typeof messageAction>,
170
- ReturnType<typeof messageAction>,
171
- ReturnType<typeof messageAction>,
172
- ReturnType<typeof messageAction>,
173
- ReturnType<typeof messageAction>,
174
- ReturnType<typeof messageAction>,
175
- ];
176
-
177
- const areas = createGridLayout(843, actions);
178
-
179
- expect((areas[0].action as { text: string }).text).toBe("/help");
180
- expect((areas[1].action as { text: string }).text).toBe("/status");
181
- expect((areas[2].action as { text: string }).text).toBe("/settings");
182
- expect((areas[3].action as { text: string }).text).toBe("/about");
183
- expect((areas[4].action as { text: string }).text).toBe("/feedback");
184
- expect((areas[5].action as { text: string }).text).toBe("/contact");
185
- });
186
- });
187
-
188
- describe("createDefaultMenuConfig", () => {
189
- it("creates a valid default menu configuration", () => {
190
- const config = createDefaultMenuConfig();
191
-
192
- expect(config.size.width).toBe(2500);
193
- expect(config.size.height).toBe(843);
194
- expect(config.selected).toBe(false);
195
- expect(config.name).toBe("Default Menu");
196
- expect(config.chatBarText).toBe("Menu");
197
- expect(config.areas.length).toBe(6);
198
- });
199
-
200
- it("has valid area bounds", () => {
201
- const config = createDefaultMenuConfig();
202
-
203
- for (const area of config.areas) {
204
- expect(area.bounds.x).toBeGreaterThanOrEqual(0);
205
- expect(area.bounds.y).toBeGreaterThanOrEqual(0);
206
- expect(area.bounds.width).toBeGreaterThan(0);
207
- expect(area.bounds.height).toBeGreaterThan(0);
208
- expect(area.bounds.x + area.bounds.width).toBeLessThanOrEqual(2500);
209
- expect(area.bounds.y + area.bounds.height).toBeLessThanOrEqual(843);
210
- }
211
- });
212
-
213
- it("uses message actions with expected default commands", () => {
214
- const config = createDefaultMenuConfig();
215
-
216
- for (const area of config.areas) {
217
- expect(area.action.type).toBe("message");
218
- }
219
- const commands = config.areas.map((a) => (a.action as { text: string }).text);
220
- expect(commands).toContain("/help");
221
- expect(commands).toContain("/status");
222
- expect(commands).toContain("/settings");
223
- });
224
- });
225
-
226
- const richMenuUploadCfg: OpenClawConfig = {
227
- channels: {
228
- line: {
229
- channelAccessToken: "line-token",
230
- channelSecret: "line-secret",
231
- },
232
- },
233
- };
234
-
235
- describe("uploadRichMenuImage", () => {
236
- let tempRoot: string;
237
-
238
- beforeEach(async () => {
239
- tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-line-rich-menu-"));
240
- setRichMenuImageMock.mockReset();
241
- MessagingApiBlobClientMock.mockClear();
242
- });
243
-
244
- afterEach(async () => {
245
- await fs.rm(tempRoot, { recursive: true, force: true });
246
- });
247
-
248
- it("loads local image paths through approved media localRoots", async () => {
249
- const workspaceDir = path.join(tempRoot, "workspace");
250
- await fs.mkdir(workspaceDir, { recursive: true });
251
- const imagePath = path.join(workspaceDir, "menu.png");
252
- const imageBytes = Buffer.from([
253
- 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00,
254
- ]);
255
- await fs.writeFile(imagePath, imageBytes);
256
-
257
- await uploadRichMenuImage("rich-menu-1", imagePath, {
258
- cfg: richMenuUploadCfg,
259
- mediaLocalRoots: [workspaceDir],
260
- });
261
-
262
- expect(MessagingApiBlobClientMock).toHaveBeenCalledWith({ channelAccessToken: "line-token" });
263
- expect(setRichMenuImageMock).toHaveBeenCalledOnce();
264
- const [richMenuId, blob] = setRichMenuImageMock.mock.calls[0] ?? [];
265
- expect(richMenuId).toBe("rich-menu-1");
266
- expect(blob).toBeInstanceOf(Blob);
267
- expect((blob as Blob).type).toBe("image/png");
268
- await expect((blob as Blob).arrayBuffer()).resolves.toEqual(
269
- imageBytes.buffer.slice(imageBytes.byteOffset, imageBytes.byteOffset + imageBytes.byteLength),
270
- );
271
- });
272
-
273
- it("rejects local image paths outside approved media localRoots before uploading", async () => {
274
- const workspaceDir = path.join(tempRoot, "workspace");
275
- const outsideDir = path.join(tempRoot, "outside");
276
- await fs.mkdir(workspaceDir, { recursive: true });
277
- await fs.mkdir(outsideDir, { recursive: true });
278
- const outsideImagePath = path.join(outsideDir, "menu.jpg");
279
- await fs.writeFile(outsideImagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
280
-
281
- await expect(
282
- uploadRichMenuImage("rich-menu-1", outsideImagePath, {
283
- cfg: richMenuUploadCfg,
284
- mediaLocalRoots: [workspaceDir],
285
- }),
286
- ).rejects.toThrow(/Local media path is not under an allowed directory/i);
287
-
288
- expect(setRichMenuImageMock).not.toHaveBeenCalled();
289
- });
290
-
291
- it("preserves extension-based content-type fallback for approved local paths", async () => {
292
- const workspaceDir = path.join(tempRoot, "workspace");
293
- await fs.mkdir(workspaceDir, { recursive: true });
294
- const imagePath = path.join(workspaceDir, "menu.jpg");
295
- const imageBytes = Buffer.from("placeholder image bytes");
296
- await fs.writeFile(imagePath, imageBytes);
297
-
298
- await uploadRichMenuImage("rich-menu-2", imagePath, {
299
- cfg: richMenuUploadCfg,
300
- mediaLocalRoots: [workspaceDir],
301
- });
302
-
303
- expect(setRichMenuImageMock).toHaveBeenCalledOnce();
304
- const blob = setRichMenuImageMock.mock.calls[0]?.[1] as Blob;
305
- expect(blob.type).toBe("image/jpeg");
306
- await expect(blob.arrayBuffer()).resolves.toEqual(
307
- imageBytes.buffer.slice(imageBytes.byteOffset, imageBytes.byteOffset + imageBytes.byteLength),
308
- );
309
- });
310
- });