@kodelyth/line 2026.5.39 → 2026.5.42
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.
- package/api.ts +11 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +5 -0
- package/dist/accounts-CD4A1FE7.js +105 -0
- package/dist/api.js +11 -0
- package/dist/basic-cards-BISytiSa.js +307 -0
- package/dist/card-command-dQBX3fVN.js +240 -0
- package/dist/channel-DV5h44-j.js +649 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/channel.runtime-Cc-v3szZ.js +4 -0
- package/dist/contract-api.js +2 -0
- package/dist/index.js +45 -0
- package/dist/markdown-to-line-CC3BU6CC.js +810 -0
- package/dist/monitor-Ci8Hg8ay.js +1485 -0
- package/dist/monitor.runtime-t6-QvlDB.js +2 -0
- package/dist/outbound.runtime-D1CxEvcL.js +2 -0
- package/dist/probe-BPSs_A_8.js +30 -0
- package/dist/probe.runtime-7u2o9QN5.js +2 -0
- package/dist/reply-payload-transform-CDuBzoT4.js +855 -0
- package/dist/runtime-api.js +291 -0
- package/dist/schedule-cards-D-yZMHDE.js +359 -0
- package/dist/secret-contract-api.js +5 -0
- package/dist/setup-api.js +2 -0
- package/dist/setup-entry.js +11 -0
- package/dist/setup-surface-CHfQ6Z4i.js +282 -0
- package/index.ts +53 -0
- package/klaw.plugin.json +2 -329
- package/package.json +4 -4
- package/runtime-api.ts +179 -0
- package/secret-contract-api.ts +4 -0
- package/setup-api.ts +2 -0
- package/setup-entry.ts +9 -0
- package/src/account-helpers.ts +16 -0
- package/src/accounts.test.ts +288 -0
- package/src/accounts.ts +187 -0
- package/src/actions.ts +61 -0
- package/src/auto-reply-delivery.test.ts +253 -0
- package/src/auto-reply-delivery.ts +200 -0
- package/src/bindings.ts +65 -0
- package/src/bot-access.ts +30 -0
- package/src/bot-handlers.test.ts +1094 -0
- package/src/bot-handlers.ts +620 -0
- package/src/bot-message-context.test.ts +420 -0
- package/src/bot-message-context.ts +586 -0
- package/src/bot.ts +66 -0
- package/src/card-command.ts +347 -0
- package/src/channel-access-token.ts +14 -0
- package/src/channel-api.ts +17 -0
- package/src/channel-setup-status.contract.test.ts +70 -0
- package/src/channel-shared.ts +48 -0
- package/src/channel.logout.test.ts +145 -0
- package/src/channel.runtime.ts +3 -0
- package/src/channel.sendPayload.test.ts +659 -0
- package/src/channel.setup.ts +11 -0
- package/src/channel.status.test.ts +63 -0
- package/src/channel.ts +155 -0
- package/src/config-adapter.ts +29 -0
- package/src/config-schema.test.ts +53 -0
- package/src/config-schema.ts +81 -0
- package/src/download.test.ts +164 -0
- package/src/download.ts +34 -0
- package/src/flex-templates/basic-cards.ts +395 -0
- package/src/flex-templates/common.ts +20 -0
- package/src/flex-templates/media-control-cards.ts +555 -0
- package/src/flex-templates/message.ts +13 -0
- package/src/flex-templates/schedule-cards.ts +467 -0
- package/src/flex-templates/types.ts +22 -0
- package/src/flex-templates.ts +32 -0
- package/src/gateway.ts +129 -0
- package/src/group-keys.test.ts +123 -0
- package/src/group-keys.ts +65 -0
- package/src/group-policy.ts +22 -0
- package/src/markdown-to-line.test.ts +348 -0
- package/src/markdown-to-line.ts +416 -0
- package/src/message-cards.test.ts +204 -0
- package/src/monitor-durable.test.ts +57 -0
- package/src/monitor-durable.ts +37 -0
- package/src/monitor.lifecycle.test.ts +499 -0
- package/src/monitor.runtime.ts +1 -0
- package/src/monitor.ts +507 -0
- package/src/outbound-media.test.ts +194 -0
- package/src/outbound-media.ts +120 -0
- package/src/outbound.runtime.ts +12 -0
- package/src/outbound.ts +427 -0
- package/src/probe.contract.test.ts +9 -0
- package/src/probe.runtime.ts +1 -0
- package/src/probe.ts +34 -0
- package/src/quick-reply-fallback.ts +10 -0
- package/src/reply-chunks.test.ts +180 -0
- package/src/reply-chunks.ts +110 -0
- package/src/reply-payload-transform.test.ts +392 -0
- package/src/reply-payload-transform.ts +317 -0
- package/src/rich-menu.test.ts +315 -0
- package/src/rich-menu.ts +326 -0
- package/src/runtime.ts +32 -0
- package/src/send-receipt.ts +32 -0
- package/src/send.test.ts +453 -0
- package/src/send.ts +531 -0
- package/src/setup-core.ts +149 -0
- package/src/setup-runtime-api.ts +9 -0
- package/src/setup-surface.test.ts +481 -0
- package/src/setup-surface.ts +229 -0
- package/src/signature.test.ts +34 -0
- package/src/signature.ts +24 -0
- package/src/status.ts +37 -0
- package/src/template-messages.ts +333 -0
- package/src/types.ts +130 -0
- package/src/webhook-node.test.ts +598 -0
- package/src/webhook-node.ts +155 -0
- package/src/webhook-utils.ts +10 -0
- package/src/webhook.ts +135 -0
- package/tsconfig.json +16 -0
- package/api.js +0 -7
- package/channel-plugin-api.js +0 -7
- package/contract-api.js +0 -7
- package/index.js +0 -7
- package/runtime-api.js +0 -7
- package/secret-contract-api.js +0 -7
- package/setup-api.js +0 -7
- package/setup-entry.js +0 -7
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import type { ReplyPayload } from "klaw/plugin-sdk/reply-runtime";
|
|
2
|
+
import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-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
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { KlawConfig } from "klaw/plugin-sdk/config-contracts";
|
|
5
|
+
import { afterAll, 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
|
+
afterAll(() => {
|
|
29
|
+
vi.doUnmock("@line/bot-sdk");
|
|
30
|
+
vi.resetModules();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("messageAction", () => {
|
|
34
|
+
it("creates message actions with explicit or default text", () => {
|
|
35
|
+
const cases = [
|
|
36
|
+
{ name: "explicit text", label: "Help", text: "/help", expectedText: "/help" },
|
|
37
|
+
{ name: "defaults to label", label: "Click", text: undefined, expectedText: "Click" },
|
|
38
|
+
] as const;
|
|
39
|
+
for (const testCase of cases) {
|
|
40
|
+
const action = testCase.text
|
|
41
|
+
? messageAction(testCase.label, testCase.text)
|
|
42
|
+
: messageAction(testCase.label);
|
|
43
|
+
expect(action.type, testCase.name).toBe("message");
|
|
44
|
+
expect(action.label, testCase.name).toBe(testCase.label);
|
|
45
|
+
expect((action as { text: string }).text, testCase.name).toBe(testCase.expectedText);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("uriAction", () => {
|
|
51
|
+
it("creates a URI action", () => {
|
|
52
|
+
const action = uriAction("Open", "https://example.com");
|
|
53
|
+
|
|
54
|
+
expect(action.type).toBe("uri");
|
|
55
|
+
expect(action.label).toBe("Open");
|
|
56
|
+
expect((action as { uri: string }).uri).toBe("https://example.com");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("action label truncation", () => {
|
|
61
|
+
it.each([
|
|
62
|
+
{
|
|
63
|
+
createAction: () => messageAction("This is a very long label text"),
|
|
64
|
+
expectedLabel: "This is a very long ",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
createAction: () => uriAction("Click here to visit our website", "https://example.com"),
|
|
68
|
+
expectedLabel: "Click here to visit ",
|
|
69
|
+
},
|
|
70
|
+
])("truncates labels to 20 characters", ({ createAction, expectedLabel }) => {
|
|
71
|
+
const action = createAction();
|
|
72
|
+
expect(action.label).toBe(expectedLabel);
|
|
73
|
+
expect((action.label ?? "").length).toBe(20);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("postbackAction", () => {
|
|
78
|
+
it("creates a postback action", () => {
|
|
79
|
+
const action = postbackAction("Select", "action=select&item=1", "Selected item 1");
|
|
80
|
+
|
|
81
|
+
expect(action.type).toBe("postback");
|
|
82
|
+
expect(action.label).toBe("Select");
|
|
83
|
+
expect((action as { data: string }).data).toBe("action=select&item=1");
|
|
84
|
+
expect((action as { displayText: string }).displayText).toBe("Selected item 1");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("applies postback payload truncation and displayText behavior", () => {
|
|
88
|
+
const truncatedData = postbackAction("Test", "x".repeat(400));
|
|
89
|
+
expect((truncatedData as { data: string }).data.length).toBe(300);
|
|
90
|
+
|
|
91
|
+
const truncatedDisplay = postbackAction("Test", "data", "y".repeat(400));
|
|
92
|
+
expect((truncatedDisplay as { displayText: string }).displayText?.length).toBe(300);
|
|
93
|
+
|
|
94
|
+
const noDisplayText = postbackAction("Test", "data");
|
|
95
|
+
expect((noDisplayText as { displayText?: string }).displayText).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("datetimePickerAction", () => {
|
|
100
|
+
it("creates picker actions for all supported modes", () => {
|
|
101
|
+
const cases = [
|
|
102
|
+
{ label: "Pick date", data: "date_picked", mode: "date" as const },
|
|
103
|
+
{ label: "Pick time", data: "time_picked", mode: "time" as const },
|
|
104
|
+
{ label: "Pick datetime", data: "datetime_picked", mode: "datetime" as const },
|
|
105
|
+
];
|
|
106
|
+
for (const testCase of cases) {
|
|
107
|
+
const action = datetimePickerAction(testCase.label, testCase.data, testCase.mode);
|
|
108
|
+
expect(action.type).toBe("datetimepicker");
|
|
109
|
+
expect(action.label).toBe(testCase.label);
|
|
110
|
+
expect((action as { mode: string }).mode).toBe(testCase.mode);
|
|
111
|
+
expect((action as { data: string }).data).toBe(testCase.data);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("includes initial/min/max when provided", () => {
|
|
116
|
+
const action = datetimePickerAction("Pick", "data", "date", {
|
|
117
|
+
initial: "2024-06-15",
|
|
118
|
+
min: "2024-01-01",
|
|
119
|
+
max: "2024-12-31",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect((action as { initial: string }).initial).toBe("2024-06-15");
|
|
123
|
+
expect((action as { min: string }).min).toBe("2024-01-01");
|
|
124
|
+
expect((action as { max: string }).max).toBe("2024-12-31");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("createGridLayout", () => {
|
|
129
|
+
function createSixSimpleActions() {
|
|
130
|
+
return [
|
|
131
|
+
messageAction("A1"),
|
|
132
|
+
messageAction("A2"),
|
|
133
|
+
messageAction("A3"),
|
|
134
|
+
messageAction("A4"),
|
|
135
|
+
messageAction("A5"),
|
|
136
|
+
messageAction("A6"),
|
|
137
|
+
] as [
|
|
138
|
+
ReturnType<typeof messageAction>,
|
|
139
|
+
ReturnType<typeof messageAction>,
|
|
140
|
+
ReturnType<typeof messageAction>,
|
|
141
|
+
ReturnType<typeof messageAction>,
|
|
142
|
+
ReturnType<typeof messageAction>,
|
|
143
|
+
ReturnType<typeof messageAction>,
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
it("computes expected 2x3 layout for supported menu heights", () => {
|
|
148
|
+
const actions = createSixSimpleActions();
|
|
149
|
+
const cases = [
|
|
150
|
+
{ height: 1686, firstRowY: 0, secondRowY: 843, rowHeight: 843 },
|
|
151
|
+
{ height: 843, firstRowY: 0, secondRowY: 421, rowHeight: 421 },
|
|
152
|
+
] as const;
|
|
153
|
+
for (const testCase of cases) {
|
|
154
|
+
const areas = createGridLayout(testCase.height, actions);
|
|
155
|
+
expect(areas.length).toBe(6);
|
|
156
|
+
expect(areas[0]?.bounds.y).toBe(testCase.firstRowY);
|
|
157
|
+
expect(areas[0]?.bounds.height).toBe(testCase.rowHeight);
|
|
158
|
+
expect(areas[3]?.bounds.y).toBe(testCase.secondRowY);
|
|
159
|
+
expect(areas[0]?.bounds.x).toBe(0);
|
|
160
|
+
expect(areas[1]?.bounds.x).toBe(833);
|
|
161
|
+
expect(areas[2]?.bounds.x).toBe(1666);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("assigns correct actions to areas", () => {
|
|
166
|
+
const actions = [
|
|
167
|
+
messageAction("Help", "/help"),
|
|
168
|
+
messageAction("Status", "/status"),
|
|
169
|
+
messageAction("Settings", "/settings"),
|
|
170
|
+
messageAction("About", "/about"),
|
|
171
|
+
messageAction("Feedback", "/feedback"),
|
|
172
|
+
messageAction("Contact", "/contact"),
|
|
173
|
+
] as [
|
|
174
|
+
ReturnType<typeof messageAction>,
|
|
175
|
+
ReturnType<typeof messageAction>,
|
|
176
|
+
ReturnType<typeof messageAction>,
|
|
177
|
+
ReturnType<typeof messageAction>,
|
|
178
|
+
ReturnType<typeof messageAction>,
|
|
179
|
+
ReturnType<typeof messageAction>,
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
const areas = createGridLayout(843, actions);
|
|
183
|
+
|
|
184
|
+
expect((areas[0].action as { text: string }).text).toBe("/help");
|
|
185
|
+
expect((areas[1].action as { text: string }).text).toBe("/status");
|
|
186
|
+
expect((areas[2].action as { text: string }).text).toBe("/settings");
|
|
187
|
+
expect((areas[3].action as { text: string }).text).toBe("/about");
|
|
188
|
+
expect((areas[4].action as { text: string }).text).toBe("/feedback");
|
|
189
|
+
expect((areas[5].action as { text: string }).text).toBe("/contact");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("createDefaultMenuConfig", () => {
|
|
194
|
+
it("creates a valid default menu configuration", () => {
|
|
195
|
+
const config = createDefaultMenuConfig();
|
|
196
|
+
|
|
197
|
+
expect(config.size.width).toBe(2500);
|
|
198
|
+
expect(config.size.height).toBe(843);
|
|
199
|
+
expect(config.selected).toBe(false);
|
|
200
|
+
expect(config.name).toBe("Default Menu");
|
|
201
|
+
expect(config.chatBarText).toBe("Menu");
|
|
202
|
+
expect(config.areas.length).toBe(6);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("has valid area bounds", () => {
|
|
206
|
+
const config = createDefaultMenuConfig();
|
|
207
|
+
|
|
208
|
+
for (const area of config.areas) {
|
|
209
|
+
expect(area.bounds.x).toBeGreaterThanOrEqual(0);
|
|
210
|
+
expect(area.bounds.y).toBeGreaterThanOrEqual(0);
|
|
211
|
+
expect(area.bounds.width).toBeGreaterThan(0);
|
|
212
|
+
expect(area.bounds.height).toBeGreaterThan(0);
|
|
213
|
+
expect(area.bounds.x + area.bounds.width).toBeLessThanOrEqual(2500);
|
|
214
|
+
expect(area.bounds.y + area.bounds.height).toBeLessThanOrEqual(843);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("uses message actions with expected default commands", () => {
|
|
219
|
+
const config = createDefaultMenuConfig();
|
|
220
|
+
|
|
221
|
+
for (const area of config.areas) {
|
|
222
|
+
expect(area.action.type).toBe("message");
|
|
223
|
+
}
|
|
224
|
+
const commands = config.areas.map((a) => (a.action as { text: string }).text);
|
|
225
|
+
expect(commands).toContain("/help");
|
|
226
|
+
expect(commands).toContain("/status");
|
|
227
|
+
expect(commands).toContain("/settings");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const richMenuUploadCfg: KlawConfig = {
|
|
232
|
+
channels: {
|
|
233
|
+
line: {
|
|
234
|
+
channelAccessToken: "line-token",
|
|
235
|
+
channelSecret: "line-secret",
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
describe("uploadRichMenuImage", () => {
|
|
241
|
+
let tempRoot: string;
|
|
242
|
+
|
|
243
|
+
beforeEach(async () => {
|
|
244
|
+
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "klaw-line-rich-menu-"));
|
|
245
|
+
setRichMenuImageMock.mockReset();
|
|
246
|
+
MessagingApiBlobClientMock.mockClear();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
afterEach(async () => {
|
|
250
|
+
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("loads local image paths through approved media localRoots", async () => {
|
|
254
|
+
const workspaceDir = path.join(tempRoot, "workspace");
|
|
255
|
+
await fs.mkdir(workspaceDir, { recursive: true });
|
|
256
|
+
const imagePath = path.join(workspaceDir, "menu.png");
|
|
257
|
+
const imageBytes = Buffer.from([
|
|
258
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00,
|
|
259
|
+
]);
|
|
260
|
+
await fs.writeFile(imagePath, imageBytes);
|
|
261
|
+
|
|
262
|
+
await uploadRichMenuImage("rich-menu-1", imagePath, {
|
|
263
|
+
cfg: richMenuUploadCfg,
|
|
264
|
+
mediaLocalRoots: [workspaceDir],
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
expect(MessagingApiBlobClientMock).toHaveBeenCalledWith({ channelAccessToken: "line-token" });
|
|
268
|
+
expect(setRichMenuImageMock).toHaveBeenCalledOnce();
|
|
269
|
+
const [richMenuId, blob] = setRichMenuImageMock.mock.calls[0] ?? [];
|
|
270
|
+
expect(richMenuId).toBe("rich-menu-1");
|
|
271
|
+
expect(blob).toBeInstanceOf(Blob);
|
|
272
|
+
expect((blob as Blob).type).toBe("image/png");
|
|
273
|
+
await expect((blob as Blob).arrayBuffer()).resolves.toEqual(
|
|
274
|
+
imageBytes.buffer.slice(imageBytes.byteOffset, imageBytes.byteOffset + imageBytes.byteLength),
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("rejects local image paths outside approved media localRoots before uploading", async () => {
|
|
279
|
+
const workspaceDir = path.join(tempRoot, "workspace");
|
|
280
|
+
const outsideDir = path.join(tempRoot, "outside");
|
|
281
|
+
await fs.mkdir(workspaceDir, { recursive: true });
|
|
282
|
+
await fs.mkdir(outsideDir, { recursive: true });
|
|
283
|
+
const outsideImagePath = path.join(outsideDir, "menu.jpg");
|
|
284
|
+
await fs.writeFile(outsideImagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
|
285
|
+
|
|
286
|
+
await expect(
|
|
287
|
+
uploadRichMenuImage("rich-menu-1", outsideImagePath, {
|
|
288
|
+
cfg: richMenuUploadCfg,
|
|
289
|
+
mediaLocalRoots: [workspaceDir],
|
|
290
|
+
}),
|
|
291
|
+
).rejects.toThrow(/Local media path is not under an allowed directory/i);
|
|
292
|
+
|
|
293
|
+
expect(setRichMenuImageMock).not.toHaveBeenCalled();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("preserves extension-based content-type fallback for approved local paths", async () => {
|
|
297
|
+
const workspaceDir = path.join(tempRoot, "workspace");
|
|
298
|
+
await fs.mkdir(workspaceDir, { recursive: true });
|
|
299
|
+
const imagePath = path.join(workspaceDir, "menu.jpg");
|
|
300
|
+
const imageBytes = Buffer.from("placeholder image bytes");
|
|
301
|
+
await fs.writeFile(imagePath, imageBytes);
|
|
302
|
+
|
|
303
|
+
await uploadRichMenuImage("rich-menu-2", imagePath, {
|
|
304
|
+
cfg: richMenuUploadCfg,
|
|
305
|
+
mediaLocalRoots: [workspaceDir],
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(setRichMenuImageMock).toHaveBeenCalledOnce();
|
|
309
|
+
const blob = setRichMenuImageMock.mock.calls[0]?.[1] as Blob;
|
|
310
|
+
expect(blob.type).toBe("image/jpeg");
|
|
311
|
+
await expect(blob.arrayBuffer()).resolves.toEqual(
|
|
312
|
+
imageBytes.buffer.slice(imageBytes.byteOffset, imageBytes.byteOffset + imageBytes.byteLength),
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
});
|