@gakr-gakr/line 0.1.0

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 (66) hide show
  1. package/api.ts +11 -0
  2. package/autobot.plugin.json +15 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +5 -0
  5. package/index.ts +54 -0
  6. package/package.json +60 -0
  7. package/runtime-api.ts +182 -0
  8. package/secret-contract-api.ts +4 -0
  9. package/setup-api.ts +2 -0
  10. package/setup-entry.ts +9 -0
  11. package/src/account-helpers.ts +16 -0
  12. package/src/accounts.ts +187 -0
  13. package/src/actions.ts +61 -0
  14. package/src/auto-reply-delivery.ts +200 -0
  15. package/src/bindings.ts +65 -0
  16. package/src/bot-access.ts +30 -0
  17. package/src/bot-handlers.ts +620 -0
  18. package/src/bot-message-context.ts +586 -0
  19. package/src/bot.ts +70 -0
  20. package/src/card-command.ts +347 -0
  21. package/src/channel-access-token.ts +14 -0
  22. package/src/channel-api.ts +17 -0
  23. package/src/channel-shared.ts +48 -0
  24. package/src/channel.runtime.ts +3 -0
  25. package/src/channel.setup.ts +11 -0
  26. package/src/channel.ts +155 -0
  27. package/src/config-adapter.ts +29 -0
  28. package/src/config-schema.ts +81 -0
  29. package/src/download.ts +34 -0
  30. package/src/flex-templates/basic-cards.ts +395 -0
  31. package/src/flex-templates/common.ts +20 -0
  32. package/src/flex-templates/media-control-cards.ts +555 -0
  33. package/src/flex-templates/message.ts +13 -0
  34. package/src/flex-templates/schedule-cards.ts +467 -0
  35. package/src/flex-templates/types.ts +22 -0
  36. package/src/flex-templates.ts +32 -0
  37. package/src/gateway.ts +129 -0
  38. package/src/group-keys.ts +65 -0
  39. package/src/group-policy.ts +22 -0
  40. package/src/markdown-to-line.ts +416 -0
  41. package/src/monitor-durable.ts +37 -0
  42. package/src/monitor.runtime.ts +1 -0
  43. package/src/monitor.ts +507 -0
  44. package/src/outbound-media.ts +120 -0
  45. package/src/outbound.runtime.ts +12 -0
  46. package/src/outbound.ts +427 -0
  47. package/src/probe.runtime.ts +1 -0
  48. package/src/probe.ts +34 -0
  49. package/src/quick-reply-fallback.ts +10 -0
  50. package/src/reply-chunks.ts +110 -0
  51. package/src/reply-payload-transform.ts +317 -0
  52. package/src/rich-menu.ts +326 -0
  53. package/src/runtime.ts +32 -0
  54. package/src/send-receipt.ts +32 -0
  55. package/src/send.ts +531 -0
  56. package/src/setup-core.ts +149 -0
  57. package/src/setup-runtime-api.ts +9 -0
  58. package/src/setup-surface.ts +229 -0
  59. package/src/signature.ts +24 -0
  60. package/src/status.ts +37 -0
  61. package/src/template-messages.ts +333 -0
  62. package/src/types.ts +130 -0
  63. package/src/webhook-node.ts +155 -0
  64. package/src/webhook-utils.ts +10 -0
  65. package/src/webhook.ts +135 -0
  66. package/tsconfig.json +16 -0
@@ -0,0 +1,317 @@
1
+ import type { ReplyPayload } from "autobot/plugin-sdk/reply-runtime";
2
+ import { normalizeLowercaseStringOrEmpty } from "autobot/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,326 @@
1
+ import { messagingApi } from "@line/bot-sdk";
2
+ import { getAgentScopedMediaLocalRoots } from "autobot/plugin-sdk/agent-media-payload";
3
+ import type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
4
+ import { mimeTypeFromFilePath } from "autobot/plugin-sdk/media-mime";
5
+ import { logVerbose } from "autobot/plugin-sdk/runtime-env";
6
+ import { loadWebMediaRaw } from "autobot/plugin-sdk/web-media";
7
+ import { resolveLineAccount } from "./accounts.js";
8
+ import { datetimePickerAction, messageAction, postbackAction, uriAction } from "./actions.js";
9
+ import { resolveLineChannelAccessToken } from "./channel-access-token.js";
10
+
11
+ type RichMenuRequest = messagingApi.RichMenuRequest;
12
+ type RichMenuResponse = messagingApi.RichMenuResponse;
13
+ type RichMenuArea = messagingApi.RichMenuArea;
14
+ type Action = messagingApi.Action;
15
+ const USER_BATCH_SIZE = 500;
16
+
17
+ export interface RichMenuSize {
18
+ width: 2500;
19
+ height: 1686 | 843;
20
+ }
21
+
22
+ export interface RichMenuAreaRequest {
23
+ bounds: {
24
+ x: number;
25
+ y: number;
26
+ width: number;
27
+ height: number;
28
+ };
29
+ action: Action;
30
+ }
31
+
32
+ export interface CreateRichMenuParams {
33
+ size: RichMenuSize;
34
+ selected?: boolean;
35
+ name: string;
36
+ chatBarText: string;
37
+ areas: RichMenuAreaRequest[];
38
+ }
39
+
40
+ interface RichMenuOpts {
41
+ cfg: AutoBotConfig;
42
+ channelAccessToken?: string;
43
+ accountId?: string;
44
+ verbose?: boolean;
45
+ mediaLocalRoots?: readonly string[];
46
+ }
47
+
48
+ function getClient(opts: RichMenuOpts): messagingApi.MessagingApiClient {
49
+ const account = resolveLineAccount({
50
+ cfg: opts.cfg,
51
+ accountId: opts.accountId,
52
+ });
53
+ const token = resolveLineChannelAccessToken(opts.channelAccessToken, account);
54
+
55
+ return new messagingApi.MessagingApiClient({
56
+ channelAccessToken: token,
57
+ });
58
+ }
59
+
60
+ function getBlobClient(opts: RichMenuOpts): messagingApi.MessagingApiBlobClient {
61
+ const account = resolveLineAccount({
62
+ cfg: opts.cfg,
63
+ accountId: opts.accountId,
64
+ });
65
+ const token = resolveLineChannelAccessToken(opts.channelAccessToken, account);
66
+
67
+ return new messagingApi.MessagingApiBlobClient({
68
+ channelAccessToken: token,
69
+ });
70
+ }
71
+
72
+ function chunkUserIds(userIds: string[]): string[][] {
73
+ const batches: string[][] = [];
74
+ for (let i = 0; i < userIds.length; i += USER_BATCH_SIZE) {
75
+ batches.push(userIds.slice(i, i + USER_BATCH_SIZE));
76
+ }
77
+ return batches;
78
+ }
79
+
80
+ export async function createRichMenu(
81
+ menu: CreateRichMenuParams,
82
+ opts: RichMenuOpts,
83
+ ): Promise<string> {
84
+ const client = getClient(opts);
85
+
86
+ const richMenuRequest: RichMenuRequest = {
87
+ size: menu.size,
88
+ selected: menu.selected ?? false,
89
+ name: menu.name.slice(0, 300),
90
+ chatBarText: menu.chatBarText.slice(0, 14),
91
+ areas: menu.areas as RichMenuArea[],
92
+ };
93
+
94
+ const response = await client.createRichMenu(richMenuRequest);
95
+
96
+ if (opts.verbose) {
97
+ logVerbose(`line: created rich menu ${response.richMenuId}`);
98
+ }
99
+
100
+ return response.richMenuId;
101
+ }
102
+
103
+ export async function uploadRichMenuImage(
104
+ richMenuId: string,
105
+ imagePath: string,
106
+ opts: RichMenuOpts,
107
+ ): Promise<void> {
108
+ const blobClient = getBlobClient(opts);
109
+
110
+ const media = await loadWebMediaRaw(imagePath, {
111
+ localRoots: opts.mediaLocalRoots ?? getAgentScopedMediaLocalRoots(opts.cfg),
112
+ });
113
+ const contentType =
114
+ media.contentType === "image/png" || media.contentType === "image/jpeg"
115
+ ? media.contentType
116
+ : mimeTypeFromFilePath(imagePath) === "image/png"
117
+ ? "image/png"
118
+ : "image/jpeg";
119
+
120
+ const imageBytes = new ArrayBuffer(media.buffer.byteLength);
121
+ new Uint8Array(imageBytes).set(media.buffer);
122
+ await blobClient.setRichMenuImage(richMenuId, new Blob([imageBytes], { type: contentType }));
123
+
124
+ if (opts.verbose) {
125
+ logVerbose(`line: uploaded image to rich menu ${richMenuId}`);
126
+ }
127
+ }
128
+
129
+ export async function setDefaultRichMenu(richMenuId: string, opts: RichMenuOpts): Promise<void> {
130
+ const client = getClient(opts);
131
+ await client.setDefaultRichMenu(richMenuId);
132
+
133
+ if (opts.verbose) {
134
+ logVerbose(`line: set default rich menu to ${richMenuId}`);
135
+ }
136
+ }
137
+
138
+ export async function cancelDefaultRichMenu(opts: RichMenuOpts): Promise<void> {
139
+ const client = getClient(opts);
140
+ await client.cancelDefaultRichMenu();
141
+
142
+ if (opts.verbose) {
143
+ logVerbose("line: cancelled default rich menu");
144
+ }
145
+ }
146
+
147
+ export async function getDefaultRichMenuId(opts: RichMenuOpts): Promise<string | null> {
148
+ const client = getClient(opts);
149
+
150
+ try {
151
+ const response = await client.getDefaultRichMenuId();
152
+ return response.richMenuId ?? null;
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ export async function linkRichMenuToUser(
159
+ userId: string,
160
+ richMenuId: string,
161
+ opts: RichMenuOpts,
162
+ ): Promise<void> {
163
+ const client = getClient(opts);
164
+ await client.linkRichMenuIdToUser(userId, richMenuId);
165
+
166
+ if (opts.verbose) {
167
+ logVerbose(`line: linked rich menu ${richMenuId} to user ${userId}`);
168
+ }
169
+ }
170
+
171
+ export async function linkRichMenuToUsers(
172
+ userIds: string[],
173
+ richMenuId: string,
174
+ opts: RichMenuOpts,
175
+ ): Promise<void> {
176
+ const client = getClient(opts);
177
+
178
+ for (const batch of chunkUserIds(userIds)) {
179
+ await client.linkRichMenuIdToUsers({
180
+ richMenuId,
181
+ userIds: batch,
182
+ });
183
+ }
184
+
185
+ if (opts.verbose) {
186
+ logVerbose(`line: linked rich menu ${richMenuId} to ${userIds.length} users`);
187
+ }
188
+ }
189
+
190
+ export async function unlinkRichMenuFromUser(userId: string, opts: RichMenuOpts): Promise<void> {
191
+ const client = getClient(opts);
192
+ await client.unlinkRichMenuIdFromUser(userId);
193
+
194
+ if (opts.verbose) {
195
+ logVerbose(`line: unlinked rich menu from user ${userId}`);
196
+ }
197
+ }
198
+
199
+ export async function unlinkRichMenuFromUsers(
200
+ userIds: string[],
201
+ opts: RichMenuOpts,
202
+ ): Promise<void> {
203
+ const client = getClient(opts);
204
+
205
+ for (const batch of chunkUserIds(userIds)) {
206
+ await client.unlinkRichMenuIdFromUsers({
207
+ userIds: batch,
208
+ });
209
+ }
210
+
211
+ if (opts.verbose) {
212
+ logVerbose(`line: unlinked rich menu from ${userIds.length} users`);
213
+ }
214
+ }
215
+
216
+ export async function getRichMenuIdOfUser(
217
+ userId: string,
218
+ opts: RichMenuOpts,
219
+ ): Promise<string | null> {
220
+ const client = getClient(opts);
221
+
222
+ try {
223
+ const response = await client.getRichMenuIdOfUser(userId);
224
+ return response.richMenuId ?? null;
225
+ } catch {
226
+ return null;
227
+ }
228
+ }
229
+
230
+ export async function getRichMenuList(opts: RichMenuOpts): Promise<RichMenuResponse[]> {
231
+ const client = getClient(opts);
232
+ const response = await client.getRichMenuList();
233
+ return response.richmenus ?? [];
234
+ }
235
+
236
+ export async function getRichMenu(
237
+ richMenuId: string,
238
+ opts: RichMenuOpts,
239
+ ): Promise<RichMenuResponse | null> {
240
+ const client = getClient(opts);
241
+
242
+ try {
243
+ return await client.getRichMenu(richMenuId);
244
+ } catch {
245
+ return null;
246
+ }
247
+ }
248
+
249
+ export async function deleteRichMenu(richMenuId: string, opts: RichMenuOpts): Promise<void> {
250
+ const client = getClient(opts);
251
+ await client.deleteRichMenu(richMenuId);
252
+
253
+ if (opts.verbose) {
254
+ logVerbose(`line: deleted rich menu ${richMenuId}`);
255
+ }
256
+ }
257
+
258
+ export async function createRichMenuAlias(
259
+ richMenuId: string,
260
+ aliasId: string,
261
+ opts: RichMenuOpts,
262
+ ): Promise<void> {
263
+ const client = getClient(opts);
264
+
265
+ await client.createRichMenuAlias({
266
+ richMenuId,
267
+ richMenuAliasId: aliasId,
268
+ });
269
+
270
+ if (opts.verbose) {
271
+ logVerbose(`line: created alias ${aliasId} for rich menu ${richMenuId}`);
272
+ }
273
+ }
274
+
275
+ export async function deleteRichMenuAlias(aliasId: string, opts: RichMenuOpts): Promise<void> {
276
+ const client = getClient(opts);
277
+ await client.deleteRichMenuAlias(aliasId);
278
+
279
+ if (opts.verbose) {
280
+ logVerbose(`line: deleted alias ${aliasId}`);
281
+ }
282
+ }
283
+
284
+ export function createGridLayout(
285
+ height: 1686 | 843,
286
+ actions: [Action, Action, Action, Action, Action, Action],
287
+ ): RichMenuAreaRequest[] {
288
+ const colWidth = Math.floor(2500 / 3);
289
+ const rowHeight = Math.floor(height / 2);
290
+
291
+ return [
292
+ { bounds: { x: 0, y: 0, width: colWidth, height: rowHeight }, action: actions[0] },
293
+ { bounds: { x: colWidth, y: 0, width: colWidth, height: rowHeight }, action: actions[1] },
294
+ { bounds: { x: colWidth * 2, y: 0, width: colWidth, height: rowHeight }, action: actions[2] },
295
+ { bounds: { x: 0, y: rowHeight, width: colWidth, height: rowHeight }, action: actions[3] },
296
+ {
297
+ bounds: { x: colWidth, y: rowHeight, width: colWidth, height: rowHeight },
298
+ action: actions[4],
299
+ },
300
+ {
301
+ bounds: { x: colWidth * 2, y: rowHeight, width: colWidth, height: rowHeight },
302
+ action: actions[5],
303
+ },
304
+ ];
305
+ }
306
+
307
+ export { datetimePickerAction, messageAction, postbackAction, uriAction };
308
+
309
+ export function createDefaultMenuConfig(): CreateRichMenuParams {
310
+ return {
311
+ size: { width: 2500, height: 843 },
312
+ selected: false,
313
+ name: "Default Menu",
314
+ chatBarText: "Menu",
315
+ areas: createGridLayout(843, [
316
+ messageAction("Help", "/help"),
317
+ messageAction("Status", "/status"),
318
+ messageAction("Settings", "/settings"),
319
+ messageAction("About", "/about"),
320
+ messageAction("Feedback", "/feedback"),
321
+ messageAction("Contact", "/contact"),
322
+ ]),
323
+ };
324
+ }
325
+
326
+ export type { RichMenuRequest, RichMenuResponse, RichMenuArea };
package/src/runtime.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { PluginRuntime } from "autobot/plugin-sdk/core";
2
+ import { createPluginRuntimeStore } from "autobot/plugin-sdk/runtime-store";
3
+
4
+ type LineChannelRuntime = {
5
+ buildTemplateMessageFromPayload?: typeof import("./template-messages.js").buildTemplateMessageFromPayload;
6
+ createQuickReplyItems?: typeof import("./send.js").createQuickReplyItems;
7
+ monitorLineProvider?: typeof import("./monitor.js").monitorLineProvider;
8
+ pushFlexMessage?: typeof import("./send.js").pushFlexMessage;
9
+ pushLocationMessage?: typeof import("./send.js").pushLocationMessage;
10
+ pushMessageLine?: typeof import("./send.js").pushMessageLine;
11
+ pushMessagesLine?: typeof import("./send.js").pushMessagesLine;
12
+ pushTemplateMessage?: typeof import("./send.js").pushTemplateMessage;
13
+ pushTextMessageWithQuickReplies?: typeof import("./send.js").pushTextMessageWithQuickReplies;
14
+ resolveLineAccount?: typeof import("./accounts.js").resolveLineAccount;
15
+ sendMessageLine?: typeof import("./send.js").sendMessageLine;
16
+ };
17
+
18
+ type LineRuntime = PluginRuntime & {
19
+ channel: PluginRuntime["channel"] & {
20
+ line?: LineChannelRuntime;
21
+ };
22
+ };
23
+
24
+ const {
25
+ setRuntime: setLineRuntime,
26
+ clearRuntime: clearLineRuntime,
27
+ getRuntime: getLineRuntime,
28
+ } = createPluginRuntimeStore<LineRuntime>({
29
+ pluginId: "line",
30
+ errorMessage: "LINE runtime not initialized - plugin not registered",
31
+ });
32
+ export { clearLineRuntime, getLineRuntime, setLineRuntime };
@@ -0,0 +1,32 @@
1
+ import {
2
+ createMessageReceiptFromOutboundResults,
3
+ type MessageReceipt,
4
+ type MessageReceiptPartKind,
5
+ } from "autobot/plugin-sdk/channel-message";
6
+
7
+ export function createLineSendReceipt(params: {
8
+ messageId: string;
9
+ chatId: string;
10
+ kind?: MessageReceiptPartKind;
11
+ messageCount?: number;
12
+ }): MessageReceipt {
13
+ const messageId = params.messageId.trim();
14
+ const chatId = params.chatId.trim();
15
+ return createMessageReceiptFromOutboundResults({
16
+ results: messageId
17
+ ? [
18
+ {
19
+ channel: "line",
20
+ messageId,
21
+ chatId,
22
+ conversationId: chatId,
23
+ meta: {
24
+ messageCount: params.messageCount ?? 1,
25
+ },
26
+ },
27
+ ]
28
+ : [],
29
+ ...(chatId ? { threadId: chatId } : {}),
30
+ kind: params.kind ?? "unknown",
31
+ });
32
+ }