@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,467 @@
1
+ import { attachFooterText } from "./common.js";
2
+ import type { Action, FlexBox, FlexBubble, FlexComponent, FlexText } from "./types.js";
3
+
4
+ function buildTitleSubtitleHeader(params: { title: string; subtitle?: string }): FlexComponent[] {
5
+ const { title, subtitle } = params;
6
+ const headerContents: FlexComponent[] = [
7
+ {
8
+ type: "text",
9
+ text: title,
10
+ weight: "bold",
11
+ size: "xl",
12
+ color: "#111111",
13
+ wrap: true,
14
+ } as FlexText,
15
+ ];
16
+
17
+ if (subtitle) {
18
+ headerContents.push({
19
+ type: "text",
20
+ text: subtitle,
21
+ size: "sm",
22
+ color: "#888888",
23
+ margin: "sm",
24
+ wrap: true,
25
+ } as FlexText);
26
+ }
27
+
28
+ return headerContents;
29
+ }
30
+
31
+ function buildCardHeaderSections(headerContents: FlexComponent[]): FlexComponent[] {
32
+ return [
33
+ {
34
+ type: "box",
35
+ layout: "vertical",
36
+ contents: headerContents,
37
+ paddingBottom: "lg",
38
+ } as FlexBox,
39
+ {
40
+ type: "separator",
41
+ color: "#EEEEEE",
42
+ },
43
+ ];
44
+ }
45
+
46
+ function createMegaBubbleWithFooter(params: {
47
+ bodyContents: FlexComponent[];
48
+ footer?: string;
49
+ }): FlexBubble {
50
+ const bubble: FlexBubble = {
51
+ type: "bubble",
52
+ size: "mega",
53
+ body: {
54
+ type: "box",
55
+ layout: "vertical",
56
+ contents: params.bodyContents,
57
+ paddingAll: "xl",
58
+ backgroundColor: "#FFFFFF",
59
+ },
60
+ };
61
+
62
+ if (params.footer) {
63
+ attachFooterText(bubble, params.footer);
64
+ }
65
+
66
+ return bubble;
67
+ }
68
+
69
+ /**
70
+ * Create a receipt/summary card (for orders, transactions, data tables)
71
+ *
72
+ * Editorial design: Clean table layout with alternating row backgrounds,
73
+ * prominent total section, and clear visual hierarchy.
74
+ */
75
+ export function createReceiptCard(params: {
76
+ title: string;
77
+ subtitle?: string;
78
+ items: Array<{ name: string; value: string; highlight?: boolean }>;
79
+ total?: { label: string; value: string };
80
+ footer?: string;
81
+ }): FlexBubble {
82
+ const { title, subtitle, items, total, footer } = params;
83
+
84
+ const itemRows: FlexComponent[] = items.slice(0, 12).map(
85
+ (item, index) =>
86
+ ({
87
+ type: "box",
88
+ layout: "horizontal",
89
+ contents: [
90
+ {
91
+ type: "text",
92
+ text: item.name,
93
+ size: "sm",
94
+ color: item.highlight ? "#111111" : "#666666",
95
+ weight: item.highlight ? "bold" : "regular",
96
+ flex: 3,
97
+ wrap: true,
98
+ } as FlexText,
99
+ {
100
+ type: "text",
101
+ text: item.value,
102
+ size: "sm",
103
+ color: item.highlight ? "#06C755" : "#333333",
104
+ weight: item.highlight ? "bold" : "regular",
105
+ flex: 2,
106
+ align: "end",
107
+ wrap: true,
108
+ } as FlexText,
109
+ ],
110
+ paddingAll: "md",
111
+ backgroundColor: index % 2 === 0 ? "#FFFFFF" : "#FAFAFA",
112
+ }) as FlexBox,
113
+ );
114
+
115
+ // Header section
116
+ const headerContents = buildTitleSubtitleHeader({ title, subtitle });
117
+
118
+ const bodyContents: FlexComponent[] = [
119
+ ...buildCardHeaderSections(headerContents),
120
+ {
121
+ type: "box",
122
+ layout: "vertical",
123
+ contents: itemRows,
124
+ margin: "md",
125
+ cornerRadius: "md",
126
+ borderWidth: "light",
127
+ borderColor: "#EEEEEE",
128
+ } as FlexBox,
129
+ ];
130
+
131
+ // Total section with emphasis
132
+ if (total) {
133
+ bodyContents.push({
134
+ type: "box",
135
+ layout: "horizontal",
136
+ contents: [
137
+ {
138
+ type: "text",
139
+ text: total.label,
140
+ size: "lg",
141
+ weight: "bold",
142
+ color: "#111111",
143
+ flex: 2,
144
+ } as FlexText,
145
+ {
146
+ type: "text",
147
+ text: total.value,
148
+ size: "xl",
149
+ weight: "bold",
150
+ color: "#06C755",
151
+ flex: 2,
152
+ align: "end",
153
+ } as FlexText,
154
+ ],
155
+ margin: "xl",
156
+ paddingAll: "lg",
157
+ backgroundColor: "#F0FDF4",
158
+ cornerRadius: "lg",
159
+ } as FlexBox);
160
+ }
161
+
162
+ return createMegaBubbleWithFooter({ bodyContents, footer });
163
+ }
164
+
165
+ /**
166
+ * Create a calendar event card (for meetings, appointments, reminders)
167
+ *
168
+ * Editorial design: Date as hero, strong typographic hierarchy,
169
+ * color-blocked zones, full text wrapping for readability.
170
+ */
171
+ export function createEventCard(params: {
172
+ title: string;
173
+ date: string;
174
+ time?: string;
175
+ location?: string;
176
+ description?: string;
177
+ calendar?: string;
178
+ isAllDay?: boolean;
179
+ action?: Action;
180
+ }): FlexBubble {
181
+ const { title, date, time, location, description, calendar, isAllDay, action } = params;
182
+
183
+ // Hero date block - the most important information
184
+ const dateBlock: FlexBox = {
185
+ type: "box",
186
+ layout: "vertical",
187
+ contents: [
188
+ {
189
+ type: "text",
190
+ text: date.toUpperCase(),
191
+ size: "sm",
192
+ weight: "bold",
193
+ color: "#06C755",
194
+ wrap: true,
195
+ } as FlexText,
196
+ {
197
+ type: "text",
198
+ text: isAllDay ? "ALL DAY" : (time ?? ""),
199
+ size: "xxl",
200
+ weight: "bold",
201
+ color: "#111111",
202
+ wrap: true,
203
+ margin: "xs",
204
+ } as FlexText,
205
+ ],
206
+ paddingBottom: "lg",
207
+ borderWidth: "none",
208
+ };
209
+
210
+ // If no time and not all day, hide the time display
211
+ if (!time && !isAllDay) {
212
+ dateBlock.contents = [
213
+ {
214
+ type: "text",
215
+ text: date,
216
+ size: "xl",
217
+ weight: "bold",
218
+ color: "#111111",
219
+ wrap: true,
220
+ } as FlexText,
221
+ ];
222
+ }
223
+
224
+ // Event title with accent bar
225
+ const titleBlock: FlexBox = {
226
+ type: "box",
227
+ layout: "horizontal",
228
+ contents: [
229
+ {
230
+ type: "box",
231
+ layout: "vertical",
232
+ contents: [],
233
+ width: "4px",
234
+ backgroundColor: "#06C755",
235
+ cornerRadius: "2px",
236
+ } as FlexBox,
237
+ {
238
+ type: "box",
239
+ layout: "vertical",
240
+ contents: [
241
+ {
242
+ type: "text",
243
+ text: title,
244
+ size: "lg",
245
+ weight: "bold",
246
+ color: "#1a1a1a",
247
+ wrap: true,
248
+ } as FlexText,
249
+ ...(calendar
250
+ ? [
251
+ {
252
+ type: "text",
253
+ text: calendar,
254
+ size: "xs",
255
+ color: "#888888",
256
+ margin: "sm",
257
+ wrap: true,
258
+ } as FlexText,
259
+ ]
260
+ : []),
261
+ ],
262
+ flex: 1,
263
+ paddingStart: "lg",
264
+ } as FlexBox,
265
+ ],
266
+ paddingTop: "lg",
267
+ paddingBottom: "lg",
268
+ borderWidth: "light",
269
+ borderColor: "#EEEEEE",
270
+ };
271
+
272
+ const bodyContents: FlexComponent[] = [dateBlock, titleBlock];
273
+
274
+ // Details section (location + description) in subtle background
275
+ const hasDetails = location || description;
276
+ if (hasDetails) {
277
+ const detailItems: FlexComponent[] = [];
278
+
279
+ if (location) {
280
+ detailItems.push({
281
+ type: "box",
282
+ layout: "horizontal",
283
+ contents: [
284
+ {
285
+ type: "text",
286
+ text: "📍",
287
+ size: "sm",
288
+ flex: 0,
289
+ } as FlexText,
290
+ {
291
+ type: "text",
292
+ text: location,
293
+ size: "sm",
294
+ color: "#444444",
295
+ margin: "md",
296
+ flex: 1,
297
+ wrap: true,
298
+ } as FlexText,
299
+ ],
300
+ alignItems: "flex-start",
301
+ } as FlexBox);
302
+ }
303
+
304
+ if (description) {
305
+ detailItems.push({
306
+ type: "text",
307
+ text: description,
308
+ size: "sm",
309
+ color: "#666666",
310
+ wrap: true,
311
+ margin: location ? "lg" : "none",
312
+ } as FlexText);
313
+ }
314
+
315
+ bodyContents.push({
316
+ type: "box",
317
+ layout: "vertical",
318
+ contents: detailItems,
319
+ margin: "lg",
320
+ paddingAll: "lg",
321
+ backgroundColor: "#F8F9FA",
322
+ cornerRadius: "lg",
323
+ } as FlexBox);
324
+ }
325
+
326
+ return {
327
+ type: "bubble",
328
+ size: "mega",
329
+ body: {
330
+ type: "box",
331
+ layout: "vertical",
332
+ contents: bodyContents,
333
+ paddingAll: "xl",
334
+ backgroundColor: "#FFFFFF",
335
+ action,
336
+ },
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Create a calendar agenda card showing multiple events
342
+ *
343
+ * Editorial timeline design: Time-focused left column with event details
344
+ * on the right. Visual accent bars indicate event priority/recency.
345
+ */
346
+ export function createAgendaCard(params: {
347
+ title: string;
348
+ subtitle?: string;
349
+ events: Array<{
350
+ title: string;
351
+ time?: string;
352
+ location?: string;
353
+ calendar?: string;
354
+ isNow?: boolean;
355
+ }>;
356
+ footer?: string;
357
+ }): FlexBubble {
358
+ const { title, subtitle, events, footer } = params;
359
+
360
+ // Header with title and optional subtitle
361
+ const headerContents = buildTitleSubtitleHeader({ title, subtitle });
362
+
363
+ // Event timeline items
364
+ const eventItems: FlexComponent[] = events.slice(0, 6).map((event, index) => {
365
+ const isActive = event.isNow || index === 0;
366
+ const accentColor = isActive ? "#06C755" : "#E5E5E5";
367
+
368
+ // Time column (fixed width)
369
+ const timeColumn: FlexBox = {
370
+ type: "box",
371
+ layout: "vertical",
372
+ contents: [
373
+ {
374
+ type: "text",
375
+ text: event.time ?? "—",
376
+ size: "sm",
377
+ weight: isActive ? "bold" : "regular",
378
+ color: isActive ? "#06C755" : "#666666",
379
+ align: "end",
380
+ wrap: true,
381
+ } as FlexText,
382
+ ],
383
+ width: "65px",
384
+ justifyContent: "flex-start",
385
+ };
386
+
387
+ // Accent dot
388
+ const dotColumn: FlexBox = {
389
+ type: "box",
390
+ layout: "vertical",
391
+ contents: [
392
+ {
393
+ type: "box",
394
+ layout: "vertical",
395
+ contents: [],
396
+ width: "10px",
397
+ height: "10px",
398
+ backgroundColor: accentColor,
399
+ cornerRadius: "5px",
400
+ } as FlexBox,
401
+ ],
402
+ width: "24px",
403
+ alignItems: "center",
404
+ justifyContent: "flex-start",
405
+ paddingTop: "xs",
406
+ };
407
+
408
+ // Event details column
409
+ const detailContents: FlexComponent[] = [
410
+ {
411
+ type: "text",
412
+ text: event.title,
413
+ size: "md",
414
+ weight: "bold",
415
+ color: "#1a1a1a",
416
+ wrap: true,
417
+ } as FlexText,
418
+ ];
419
+
420
+ // Secondary info line
421
+ const secondaryParts: string[] = [];
422
+ if (event.location) {
423
+ secondaryParts.push(event.location);
424
+ }
425
+ if (event.calendar) {
426
+ secondaryParts.push(event.calendar);
427
+ }
428
+
429
+ if (secondaryParts.length > 0) {
430
+ detailContents.push({
431
+ type: "text",
432
+ text: secondaryParts.join(" · "),
433
+ size: "xs",
434
+ color: "#888888",
435
+ wrap: true,
436
+ margin: "xs",
437
+ } as FlexText);
438
+ }
439
+
440
+ const detailColumn: FlexBox = {
441
+ type: "box",
442
+ layout: "vertical",
443
+ contents: detailContents,
444
+ flex: 1,
445
+ };
446
+
447
+ return {
448
+ type: "box",
449
+ layout: "horizontal",
450
+ contents: [timeColumn, dotColumn, detailColumn],
451
+ margin: index > 0 ? "xl" : undefined,
452
+ alignItems: "flex-start",
453
+ } as FlexBox;
454
+ });
455
+
456
+ const bodyContents: FlexComponent[] = [
457
+ ...buildCardHeaderSections(headerContents),
458
+ {
459
+ type: "box",
460
+ layout: "vertical",
461
+ contents: eventItems,
462
+ paddingTop: "xl",
463
+ } as FlexBox,
464
+ ];
465
+
466
+ return createMegaBubbleWithFooter({ bodyContents, footer });
467
+ }
@@ -0,0 +1,22 @@
1
+ import type { messagingApi } from "@line/bot-sdk";
2
+
3
+ export type FlexContainer = messagingApi.FlexContainer;
4
+ export type FlexBubble = messagingApi.FlexBubble;
5
+ export type FlexCarousel = messagingApi.FlexCarousel;
6
+ export type FlexBox = messagingApi.FlexBox;
7
+ export type FlexText = messagingApi.FlexText;
8
+ export type FlexImage = messagingApi.FlexImage;
9
+ export type FlexButton = messagingApi.FlexButton;
10
+ export type FlexComponent = messagingApi.FlexComponent;
11
+ export type Action = messagingApi.Action;
12
+
13
+ export interface ListItem {
14
+ title: string;
15
+ subtitle?: string;
16
+ action?: Action;
17
+ }
18
+
19
+ export interface CardAction {
20
+ label: string;
21
+ action: Action;
22
+ }
@@ -0,0 +1,32 @@
1
+ export {
2
+ createActionCard,
3
+ createCarousel,
4
+ createImageCard,
5
+ createInfoCard,
6
+ createListCard,
7
+ createNotificationBubble,
8
+ } from "./flex-templates/basic-cards.js";
9
+ export {
10
+ createAgendaCard,
11
+ createEventCard,
12
+ createReceiptCard,
13
+ } from "./flex-templates/schedule-cards.js";
14
+ export {
15
+ createAppleTvRemoteCard,
16
+ createDeviceControlCard,
17
+ createMediaPlayerCard,
18
+ } from "./flex-templates/media-control-cards.js";
19
+ export { toFlexMessage } from "./flex-templates/message.js";
20
+
21
+ export type {
22
+ CardAction,
23
+ FlexBox,
24
+ FlexBubble,
25
+ FlexButton,
26
+ FlexCarousel,
27
+ FlexComponent,
28
+ FlexContainer,
29
+ FlexImage,
30
+ FlexText,
31
+ ListItem,
32
+ } from "./flex-templates/types.js";
package/src/gateway.ts ADDED
@@ -0,0 +1,129 @@
1
+ import { createLazyRuntimeModule } from "autobot/plugin-sdk/lazy-runtime";
2
+ import { resolveLineAccount } from "./accounts.js";
3
+ import {
4
+ clearAccountEntryFields,
5
+ DEFAULT_ACCOUNT_ID,
6
+ type ChannelPlugin,
7
+ type LineConfig,
8
+ type AutoBotConfig,
9
+ type ResolvedLineAccount,
10
+ } from "./channel-api.js";
11
+ import { getLineRuntime } from "./runtime.js";
12
+
13
+ const loadLineProbeRuntime = createLazyRuntimeModule(() => import("./probe.runtime.js"));
14
+ const loadLineMonitorRuntime = createLazyRuntimeModule(() => import("./monitor.runtime.js"));
15
+
16
+ export const lineGatewayAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["gateway"]> = {
17
+ startAccount: async (ctx) => {
18
+ const account = ctx.account;
19
+ const token = account.channelAccessToken.trim();
20
+ const secret = account.channelSecret.trim();
21
+ if (!token) {
22
+ throw new Error(
23
+ `LINE webhook mode requires a non-empty channel access token for account "${account.accountId}".`,
24
+ );
25
+ }
26
+ if (!secret) {
27
+ throw new Error(
28
+ `LINE webhook mode requires a non-empty channel secret for account "${account.accountId}".`,
29
+ );
30
+ }
31
+
32
+ let lineBotLabel = "";
33
+ try {
34
+ const probe = await (await loadLineProbeRuntime()).probeLineBot(token, 2500);
35
+ const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
36
+ if (displayName) {
37
+ lineBotLabel = ` (${displayName})`;
38
+ }
39
+ } catch (err) {
40
+ if (getLineRuntime().logging.shouldLogVerbose()) {
41
+ ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
42
+ }
43
+ }
44
+
45
+ ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
46
+
47
+ const monitorLineProvider =
48
+ getLineRuntime().channel.line?.monitorLineProvider ??
49
+ (await loadLineMonitorRuntime()).monitorLineProvider;
50
+
51
+ return await monitorLineProvider({
52
+ channelAccessToken: token,
53
+ channelSecret: secret,
54
+ accountId: account.accountId,
55
+ config: ctx.cfg,
56
+ runtime: ctx.runtime,
57
+ abortSignal: ctx.abortSignal,
58
+ webhookPath: account.config.webhookPath,
59
+ });
60
+ },
61
+ logoutAccount: async ({ accountId, cfg }) => {
62
+ const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
63
+ const nextCfg = { ...cfg } as AutoBotConfig;
64
+ const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
65
+ const nextLine = { ...lineConfig };
66
+ let cleared = false;
67
+ let changed = false;
68
+
69
+ if (accountId === DEFAULT_ACCOUNT_ID) {
70
+ if (
71
+ nextLine.channelAccessToken ||
72
+ nextLine.channelSecret ||
73
+ nextLine.tokenFile ||
74
+ nextLine.secretFile
75
+ ) {
76
+ delete nextLine.channelAccessToken;
77
+ delete nextLine.channelSecret;
78
+ delete nextLine.tokenFile;
79
+ delete nextLine.secretFile;
80
+ cleared = true;
81
+ changed = true;
82
+ }
83
+ }
84
+
85
+ const accountCleanup = clearAccountEntryFields({
86
+ accounts: nextLine.accounts,
87
+ accountId,
88
+ fields: ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"],
89
+ markClearedOnFieldPresence: true,
90
+ });
91
+ if (accountCleanup.changed) {
92
+ changed = true;
93
+ if (accountCleanup.cleared) {
94
+ cleared = true;
95
+ }
96
+ if (accountCleanup.nextAccounts) {
97
+ nextLine.accounts = accountCleanup.nextAccounts;
98
+ } else {
99
+ delete nextLine.accounts;
100
+ }
101
+ }
102
+
103
+ if (changed) {
104
+ if (Object.keys(nextLine).length > 0) {
105
+ nextCfg.channels = { ...nextCfg.channels, line: nextLine };
106
+ } else {
107
+ const nextChannels = { ...nextCfg.channels };
108
+ delete (nextChannels as Record<string, unknown>).line;
109
+ if (Object.keys(nextChannels).length > 0) {
110
+ nextCfg.channels = nextChannels;
111
+ } else {
112
+ delete nextCfg.channels;
113
+ }
114
+ }
115
+ await getLineRuntime().config.replaceConfigFile({
116
+ nextConfig: nextCfg,
117
+ afterWrite: { mode: "auto" },
118
+ });
119
+ }
120
+
121
+ const resolved = resolveLineAccount({
122
+ cfg: changed ? nextCfg : cfg,
123
+ accountId,
124
+ });
125
+ const loggedOut = resolved.tokenSource === "none";
126
+
127
+ return { cleared, envToken: Boolean(envToken), loggedOut };
128
+ },
129
+ };
@@ -0,0 +1,65 @@
1
+ import { normalizeAccountId } from "autobot/plugin-sdk/account-id";
2
+ import type { AutoBotConfig } from "autobot/plugin-sdk/account-resolution";
3
+ import { resolveAccountEntry } from "autobot/plugin-sdk/account-resolution";
4
+ import type { LineConfig, LineGroupConfig } from "./types.js";
5
+
6
+ export function resolveLineGroupLookupIds(groupId?: string | null): string[] {
7
+ const normalized = groupId?.trim();
8
+ if (!normalized) {
9
+ return [];
10
+ }
11
+ if (normalized.startsWith("group:") || normalized.startsWith("room:")) {
12
+ const rawId = normalized.split(":").slice(1).join(":");
13
+ return rawId ? [rawId, normalized] : [normalized];
14
+ }
15
+ return [normalized, `group:${normalized}`, `room:${normalized}`];
16
+ }
17
+
18
+ export function resolveLineGroupConfigEntry<T>(
19
+ groups: Record<string, T | undefined> | undefined,
20
+ params: { groupId?: string | null; roomId?: string | null },
21
+ ): T | undefined {
22
+ if (!groups) {
23
+ return undefined;
24
+ }
25
+ for (const candidate of resolveLineGroupLookupIds(params.groupId)) {
26
+ const hit = groups[candidate];
27
+ if (hit) {
28
+ return hit;
29
+ }
30
+ }
31
+ for (const candidate of resolveLineGroupLookupIds(params.roomId)) {
32
+ const hit = groups[candidate];
33
+ if (hit) {
34
+ return hit;
35
+ }
36
+ }
37
+ return groups["*"];
38
+ }
39
+
40
+ export function resolveLineGroupsConfig(
41
+ cfg: AutoBotConfig,
42
+ accountId?: string | null,
43
+ ): Record<string, LineGroupConfig | undefined> | undefined {
44
+ const lineConfig = cfg.channels?.line as LineConfig | undefined;
45
+ if (!lineConfig) {
46
+ return undefined;
47
+ }
48
+ const normalizedAccountId = normalizeAccountId(accountId);
49
+ const accountGroups = resolveAccountEntry(lineConfig.accounts, normalizedAccountId)?.groups;
50
+ return accountGroups ?? lineConfig.groups;
51
+ }
52
+
53
+ export function resolveExactLineGroupConfigKey(params: {
54
+ cfg: AutoBotConfig;
55
+ accountId?: string | null;
56
+ groupId?: string | null;
57
+ }): string | undefined {
58
+ const groups = resolveLineGroupsConfig(params.cfg, params.accountId);
59
+ if (!groups) {
60
+ return undefined;
61
+ }
62
+ return resolveLineGroupLookupIds(params.groupId).find((candidate) =>
63
+ Object.hasOwn(groups, candidate),
64
+ );
65
+ }