@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,586 @@
1
+ import type { webhook } from "@line/bot-sdk";
2
+ import { recordChannelActivity } from "autobot/plugin-sdk/channel-activity-runtime";
3
+ import {
4
+ formatInboundEnvelope,
5
+ formatLocationText,
6
+ resolveInboundSessionEnvelopeContext,
7
+ toLocationContext,
8
+ } from "autobot/plugin-sdk/channel-inbound";
9
+ import type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
10
+ import {
11
+ ensureConfiguredBindingRouteReady,
12
+ resolvePinnedMainDmOwnerFromAllowlist,
13
+ resolveConfiguredBindingRoute,
14
+ resolveRuntimeConversationBindingRoute,
15
+ } from "autobot/plugin-sdk/conversation-runtime";
16
+ import { finalizeInboundContext } from "autobot/plugin-sdk/reply-dispatch-runtime";
17
+ import { createChannelHistoryWindow, type HistoryEntry } from "autobot/plugin-sdk/reply-history";
18
+ import { resolveAgentRoute, resolveInboundLastRouteSessionKey } from "autobot/plugin-sdk/routing";
19
+ import { logVerbose, shouldLogVerbose } from "autobot/plugin-sdk/runtime-env";
20
+ import { normalizeOptionalString } from "autobot/plugin-sdk/string-coerce-runtime";
21
+ import { normalizeAllowFrom } from "./bot-access.js";
22
+ import { resolveLineGroupConfigEntry } from "./group-keys.js";
23
+ import type { ResolvedLineAccount } from "./types.js";
24
+
25
+ type EventSource = webhook.Source | undefined;
26
+ type MessageEvent = webhook.MessageEvent;
27
+ type PostbackEvent = webhook.PostbackEvent;
28
+ type StickerEventMessage = webhook.StickerMessageContent;
29
+
30
+ interface MediaRef {
31
+ path: string;
32
+ contentType?: string;
33
+ }
34
+
35
+ interface BuildLineMessageContextParams {
36
+ event: MessageEvent;
37
+ allMedia: MediaRef[];
38
+ cfg: AutoBotConfig;
39
+ account: ResolvedLineAccount;
40
+ commandAuthorized: boolean;
41
+ groupHistories?: Map<string, HistoryEntry[]>;
42
+ historyLimit?: number;
43
+ }
44
+
45
+ type LineSourceInfo = {
46
+ userId?: string;
47
+ groupId?: string;
48
+ roomId?: string;
49
+ isGroup: boolean;
50
+ };
51
+
52
+ export function getLineSourceInfo(source: EventSource): LineSourceInfo {
53
+ if (!source) {
54
+ return { userId: undefined, groupId: undefined, roomId: undefined, isGroup: false };
55
+ }
56
+ const userId =
57
+ source.type === "user"
58
+ ? source.userId
59
+ : source.type === "group"
60
+ ? source.userId
61
+ : source.type === "room"
62
+ ? source.userId
63
+ : undefined;
64
+ const groupId = source.type === "group" ? source.groupId : undefined;
65
+ const roomId = source.type === "room" ? source.roomId : undefined;
66
+ const isGroup = source.type === "group" || source.type === "room";
67
+
68
+ return { userId, groupId, roomId, isGroup };
69
+ }
70
+
71
+ function buildPeerId(source: EventSource): string {
72
+ if (!source) {
73
+ return "unknown";
74
+ }
75
+ const groupKey =
76
+ normalizeOptionalString(source.type === "group" ? source.groupId : undefined) ??
77
+ normalizeOptionalString(source.type === "room" ? source.roomId : undefined);
78
+ if (groupKey) {
79
+ return groupKey;
80
+ }
81
+ if (source.type === "user" && source.userId) {
82
+ return source.userId;
83
+ }
84
+ return "unknown";
85
+ }
86
+
87
+ async function resolveLineInboundRoute(params: {
88
+ source: EventSource;
89
+ cfg: AutoBotConfig;
90
+ account: ResolvedLineAccount;
91
+ }): Promise<{
92
+ userId?: string;
93
+ groupId?: string;
94
+ roomId?: string;
95
+ isGroup: boolean;
96
+ peerId: string;
97
+ route: ReturnType<typeof resolveAgentRoute>;
98
+ }> {
99
+ recordChannelActivity({
100
+ channel: "line",
101
+ accountId: params.account.accountId,
102
+ direction: "inbound",
103
+ });
104
+
105
+ const { userId, groupId, roomId, isGroup } = getLineSourceInfo(params.source);
106
+ const peerId = buildPeerId(params.source);
107
+ let route = resolveAgentRoute({
108
+ cfg: params.cfg,
109
+ channel: "line",
110
+ accountId: params.account.accountId,
111
+ peer: {
112
+ kind: isGroup ? "group" : "direct",
113
+ id: peerId,
114
+ },
115
+ });
116
+
117
+ const configuredRoute = resolveConfiguredBindingRoute({
118
+ cfg: params.cfg,
119
+ route,
120
+ conversation: {
121
+ channel: "line",
122
+ accountId: params.account.accountId,
123
+ conversationId: peerId,
124
+ },
125
+ });
126
+ let configuredBinding = configuredRoute.bindingResolution;
127
+ const configuredBindingSessionKey = configuredRoute.boundSessionKey ?? "";
128
+ route = configuredRoute.route;
129
+
130
+ const runtimeRoute = resolveRuntimeConversationBindingRoute({
131
+ route,
132
+ conversation: {
133
+ channel: "line",
134
+ accountId: params.account.accountId,
135
+ conversationId: peerId,
136
+ },
137
+ });
138
+ route = runtimeRoute.route;
139
+ if (runtimeRoute.bindingRecord) {
140
+ configuredBinding = null;
141
+ logVerbose(
142
+ runtimeRoute.boundSessionKey
143
+ ? `line: routed via bound conversation ${peerId} -> ${runtimeRoute.boundSessionKey}`
144
+ : `line: plugin-bound conversation ${peerId}`,
145
+ );
146
+ }
147
+
148
+ if (configuredBinding) {
149
+ const ensured = await ensureConfiguredBindingRouteReady({
150
+ cfg: params.cfg,
151
+ bindingResolution: configuredBinding,
152
+ });
153
+ if (!ensured.ok) {
154
+ logVerbose(
155
+ `line: configured ACP binding unavailable for ${peerId} -> ${configuredBindingSessionKey}: ${ensured.error}`,
156
+ );
157
+ throw new Error(`Configured ACP binding unavailable: ${ensured.error}`);
158
+ }
159
+ logVerbose(
160
+ `line: using configured ACP binding for ${peerId} -> ${configuredBindingSessionKey}`,
161
+ );
162
+ }
163
+
164
+ return { userId, groupId, roomId, isGroup, peerId, route };
165
+ }
166
+
167
+ const STICKER_PACKAGES: Record<string, string> = {
168
+ "1": "Moon & James",
169
+ "2": "Cony & Brown",
170
+ "3": "Brown & Friends",
171
+ "4": "Moon Special",
172
+ "789": "LINE Characters",
173
+ "6136": "Cony's Happy Life",
174
+ "6325": "Brown's Life",
175
+ "6359": "Choco",
176
+ "6362": "Sally",
177
+ "6370": "Edward",
178
+ "11537": "Cony",
179
+ "11538": "Brown",
180
+ "11539": "Moon",
181
+ };
182
+
183
+ function describeStickerKeywords(sticker: StickerEventMessage): string {
184
+ const keywords = (sticker as StickerEventMessage & { keywords?: string[] }).keywords;
185
+ if (keywords && keywords.length > 0) {
186
+ return keywords.slice(0, 3).join(", ");
187
+ }
188
+
189
+ const stickerText = (sticker as StickerEventMessage & { text?: string }).text;
190
+ if (stickerText) {
191
+ return stickerText;
192
+ }
193
+
194
+ return "";
195
+ }
196
+
197
+ function extractMessageText(message: MessageEvent["message"]): string {
198
+ if (message.type === "text") {
199
+ return message.text;
200
+ }
201
+ if (message.type === "location") {
202
+ const loc = message;
203
+ return (
204
+ formatLocationText({
205
+ latitude: loc.latitude,
206
+ longitude: loc.longitude,
207
+ name: loc.title,
208
+ address: loc.address,
209
+ }) ?? ""
210
+ );
211
+ }
212
+ if (message.type === "sticker") {
213
+ const sticker = message;
214
+ const packageName = STICKER_PACKAGES[sticker.packageId] ?? "sticker";
215
+ const keywords = describeStickerKeywords(sticker);
216
+
217
+ if (keywords) {
218
+ return `[Sent a ${packageName} sticker: ${keywords}]`;
219
+ }
220
+ return `[Sent a ${packageName} sticker]`;
221
+ }
222
+ return "";
223
+ }
224
+
225
+ function extractMediaPlaceholder(message: MessageEvent["message"]): string {
226
+ switch (message.type) {
227
+ case "image":
228
+ return "<media:image>";
229
+ case "video":
230
+ return "<media:video>";
231
+ case "audio":
232
+ return "<media:audio>";
233
+ case "file":
234
+ return "<media:document>";
235
+ default:
236
+ return "";
237
+ }
238
+ }
239
+
240
+ type LineRouteInfo = ReturnType<typeof resolveAgentRoute>;
241
+ type LineSourceInfoWithPeerId = LineSourceInfo & { peerId: string };
242
+
243
+ function resolveLineConversationLabel(params: {
244
+ isGroup: boolean;
245
+ groupId?: string;
246
+ roomId?: string;
247
+ senderLabel: string;
248
+ }): string {
249
+ return params.isGroup
250
+ ? params.groupId
251
+ ? `group:${params.groupId}`
252
+ : params.roomId
253
+ ? `room:${params.roomId}`
254
+ : "unknown-group"
255
+ : params.senderLabel;
256
+ }
257
+
258
+ function resolveLineAddresses(params: {
259
+ isGroup: boolean;
260
+ groupId?: string;
261
+ roomId?: string;
262
+ userId?: string;
263
+ peerId: string;
264
+ }): { fromAddress: string; toAddress: string; originatingTo: string } {
265
+ const fromAddress = params.isGroup
266
+ ? params.groupId
267
+ ? `line:group:${params.groupId}`
268
+ : params.roomId
269
+ ? `line:room:${params.roomId}`
270
+ : `line:${params.peerId}`
271
+ : `line:${params.userId ?? params.peerId}`;
272
+ const toAddress = params.isGroup ? fromAddress : `line:${params.userId ?? params.peerId}`;
273
+ const originatingTo = params.isGroup ? fromAddress : `line:${params.userId ?? params.peerId}`;
274
+ return { fromAddress, toAddress, originatingTo };
275
+ }
276
+
277
+ async function finalizeLineInboundContext(params: {
278
+ cfg: AutoBotConfig;
279
+ account: ResolvedLineAccount;
280
+ event: MessageEvent | PostbackEvent;
281
+ route: LineRouteInfo;
282
+ source: LineSourceInfoWithPeerId;
283
+ rawBody: string;
284
+ timestamp: number;
285
+ messageSid: string;
286
+ commandAuthorized: boolean;
287
+ media: {
288
+ firstPath: string | undefined;
289
+ firstContentType?: string;
290
+ paths?: string[];
291
+ types?: string[];
292
+ };
293
+ locationContext?: ReturnType<typeof toLocationContext>;
294
+ verboseLog: { kind: "inbound" | "postback"; mediaCount?: number };
295
+ inboundHistory?: Pick<HistoryEntry, "sender" | "body" | "timestamp">[];
296
+ }) {
297
+ const { fromAddress, toAddress, originatingTo } = resolveLineAddresses({
298
+ isGroup: params.source.isGroup,
299
+ groupId: params.source.groupId,
300
+ roomId: params.source.roomId,
301
+ userId: params.source.userId,
302
+ peerId: params.source.peerId,
303
+ });
304
+
305
+ const senderId = params.source.userId ?? "unknown";
306
+ const senderLabel = params.source.userId ? `user:${params.source.userId}` : "unknown";
307
+ const conversationLabel = resolveLineConversationLabel({
308
+ isGroup: params.source.isGroup,
309
+ groupId: params.source.groupId,
310
+ roomId: params.source.roomId,
311
+ senderLabel,
312
+ });
313
+
314
+ const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
315
+ cfg: params.cfg,
316
+ agentId: params.route.agentId,
317
+ sessionKey: params.route.sessionKey,
318
+ });
319
+
320
+ const body = formatInboundEnvelope({
321
+ channel: "LINE",
322
+ from: conversationLabel,
323
+ timestamp: params.timestamp,
324
+ body: params.rawBody,
325
+ chatType: params.source.isGroup ? "group" : "direct",
326
+ sender: {
327
+ id: senderId,
328
+ },
329
+ previousTimestamp,
330
+ envelope: envelopeOptions,
331
+ });
332
+
333
+ const ctxPayload = finalizeInboundContext({
334
+ Body: body,
335
+ BodyForAgent: params.rawBody,
336
+ RawBody: params.rawBody,
337
+ CommandBody: params.rawBody,
338
+ From: fromAddress,
339
+ To: toAddress,
340
+ SessionKey: params.route.sessionKey,
341
+ AccountId: params.route.accountId,
342
+ ChatType: params.source.isGroup ? "group" : "direct",
343
+ ConversationLabel: conversationLabel,
344
+ GroupSubject: params.source.isGroup
345
+ ? (params.source.groupId ?? params.source.roomId)
346
+ : undefined,
347
+ SenderId: senderId,
348
+ Provider: "line",
349
+ Surface: "line",
350
+ MessageSid: params.messageSid,
351
+ Timestamp: params.timestamp,
352
+ MediaPath: params.media.firstPath,
353
+ MediaType: params.media.firstContentType,
354
+ MediaUrl: params.media.firstPath,
355
+ MediaPaths: params.media.paths,
356
+ MediaUrls: params.media.paths,
357
+ MediaTypes: params.media.types,
358
+ ...params.locationContext,
359
+ CommandAuthorized: params.commandAuthorized,
360
+ OriginatingChannel: "line" as const,
361
+ OriginatingTo: originatingTo,
362
+ GroupSystemPrompt: params.source.isGroup
363
+ ? normalizeOptionalString(
364
+ resolveLineGroupConfigEntry(params.account.config.groups, {
365
+ groupId: params.source.groupId,
366
+ roomId: params.source.roomId,
367
+ })?.systemPrompt,
368
+ )
369
+ : undefined,
370
+ InboundHistory: params.inboundHistory,
371
+ });
372
+
373
+ const pinnedMainDmOwner = !params.source.isGroup
374
+ ? resolvePinnedMainDmOwnerFromAllowlist({
375
+ dmScope: params.cfg.session?.dmScope,
376
+ allowFrom: params.account.config.allowFrom,
377
+ normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0],
378
+ })
379
+ : null;
380
+ const inboundLastRouteSessionKey = resolveInboundLastRouteSessionKey({
381
+ route: params.route,
382
+ sessionKey: params.route.sessionKey,
383
+ });
384
+ if (shouldLogVerbose()) {
385
+ const preview = body.slice(0, 200).replace(/\n/g, "\\n");
386
+ const mediaInfo =
387
+ params.verboseLog.kind === "inbound" && (params.verboseLog.mediaCount ?? 0) > 1
388
+ ? ` mediaCount=${params.verboseLog.mediaCount}`
389
+ : "";
390
+ const label = params.verboseLog.kind === "inbound" ? "line inbound" : "line postback";
391
+ logVerbose(
392
+ `${label}: from=${ctxPayload.From} len=${body.length}${mediaInfo} preview="${preview}"`,
393
+ );
394
+ }
395
+
396
+ return {
397
+ ctxPayload,
398
+ replyToken: (params.event as { replyToken: string }).replyToken,
399
+ turn: {
400
+ storePath,
401
+ record: {
402
+ updateLastRoute: !params.source.isGroup
403
+ ? {
404
+ sessionKey: inboundLastRouteSessionKey,
405
+ channel: "line",
406
+ to: params.source.userId ?? params.source.peerId,
407
+ accountId: params.route.accountId,
408
+ mainDmOwnerPin:
409
+ inboundLastRouteSessionKey === params.route.mainSessionKey &&
410
+ pinnedMainDmOwner &&
411
+ params.source.userId
412
+ ? {
413
+ ownerRecipient: pinnedMainDmOwner,
414
+ senderRecipient: params.source.userId,
415
+ onSkip: ({
416
+ ownerRecipient,
417
+ senderRecipient,
418
+ }: {
419
+ ownerRecipient: string;
420
+ senderRecipient: string;
421
+ }) => {
422
+ logVerbose(
423
+ `line: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
424
+ );
425
+ },
426
+ }
427
+ : undefined,
428
+ }
429
+ : undefined,
430
+ onRecordError: (err: unknown) => {
431
+ logVerbose(`line: failed updating session meta: ${String(err)}`);
432
+ },
433
+ },
434
+ },
435
+ };
436
+ }
437
+
438
+ export async function buildLineMessageContext(params: BuildLineMessageContextParams) {
439
+ const { event, allMedia, cfg, account, commandAuthorized, groupHistories, historyLimit } = params;
440
+
441
+ const source = event.source;
442
+ const { userId, groupId, roomId, isGroup, peerId, route } = await resolveLineInboundRoute({
443
+ source,
444
+ cfg,
445
+ account,
446
+ });
447
+
448
+ const message = event.message;
449
+ const messageId = message.id;
450
+ const timestamp = event.timestamp;
451
+
452
+ const textContent = extractMessageText(message);
453
+ const placeholder = extractMediaPlaceholder(message);
454
+
455
+ let rawBody = textContent || placeholder;
456
+ if (!rawBody && allMedia.length > 0) {
457
+ rawBody = `<media:image>${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`;
458
+ }
459
+
460
+ if (!rawBody && allMedia.length === 0) {
461
+ return null;
462
+ }
463
+
464
+ let locationContext: ReturnType<typeof toLocationContext> | undefined;
465
+ if (message.type === "location") {
466
+ const loc = message;
467
+ locationContext = toLocationContext({
468
+ latitude: loc.latitude,
469
+ longitude: loc.longitude,
470
+ name: loc.title,
471
+ address: loc.address,
472
+ });
473
+ }
474
+
475
+ const historyKey = isGroup ? peerId : undefined;
476
+ const inboundHistory =
477
+ historyKey && groupHistories && (historyLimit ?? 0) > 0
478
+ ? createChannelHistoryWindow({ historyMap: groupHistories }).buildInboundHistory({
479
+ historyKey,
480
+ limit: historyLimit ?? 0,
481
+ })
482
+ : undefined;
483
+
484
+ const finalized = await finalizeLineInboundContext({
485
+ cfg,
486
+ account,
487
+ event,
488
+ route,
489
+ source: { userId, groupId, roomId, isGroup, peerId },
490
+ rawBody,
491
+ timestamp,
492
+ messageSid: messageId,
493
+ commandAuthorized,
494
+ media: {
495
+ firstPath: allMedia[0]?.path,
496
+ firstContentType: allMedia[0]?.contentType,
497
+ paths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
498
+ types:
499
+ allMedia.length > 0
500
+ ? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
501
+ : undefined,
502
+ },
503
+ locationContext,
504
+ verboseLog: { kind: "inbound", mediaCount: allMedia.length },
505
+ inboundHistory,
506
+ });
507
+
508
+ return {
509
+ ctxPayload: finalized.ctxPayload,
510
+ turn: finalized.turn,
511
+ event,
512
+ userId,
513
+ groupId,
514
+ roomId,
515
+ isGroup,
516
+ route,
517
+ replyToken: event.replyToken,
518
+ accountId: account.accountId,
519
+ };
520
+ }
521
+
522
+ export async function buildLinePostbackContext(params: {
523
+ event: PostbackEvent;
524
+ cfg: AutoBotConfig;
525
+ account: ResolvedLineAccount;
526
+ commandAuthorized: boolean;
527
+ }) {
528
+ const { event, cfg, account, commandAuthorized } = params;
529
+
530
+ const source = event.source;
531
+ const { userId, groupId, roomId, isGroup, peerId, route } = await resolveLineInboundRoute({
532
+ source,
533
+ cfg,
534
+ account,
535
+ });
536
+
537
+ const timestamp = event.timestamp;
538
+ const rawData = event.postback?.data?.trim() ?? "";
539
+ if (!rawData) {
540
+ return null;
541
+ }
542
+ let rawBody = rawData;
543
+ if (rawData.includes("line.action=")) {
544
+ const searchParams = new URLSearchParams(rawData);
545
+ const action = searchParams.get("line.action") ?? "";
546
+ const device = searchParams.get("line.device");
547
+ rawBody = device ? `line action ${action} device ${device}` : `line action ${action}`;
548
+ }
549
+
550
+ const messageSid = event.replyToken ? `postback:${event.replyToken}` : `postback:${timestamp}`;
551
+ const finalized = await finalizeLineInboundContext({
552
+ cfg,
553
+ account,
554
+ event,
555
+ route,
556
+ source: { userId, groupId, roomId, isGroup, peerId },
557
+ rawBody,
558
+ timestamp,
559
+ messageSid,
560
+ commandAuthorized,
561
+ media: {
562
+ firstPath: "",
563
+ firstContentType: undefined,
564
+ paths: undefined,
565
+ types: undefined,
566
+ },
567
+ verboseLog: { kind: "postback" },
568
+ });
569
+
570
+ return {
571
+ ctxPayload: finalized.ctxPayload,
572
+ turn: finalized.turn,
573
+ event,
574
+ userId,
575
+ groupId,
576
+ roomId,
577
+ isGroup,
578
+ route,
579
+ replyToken: event.replyToken,
580
+ accountId: account.accountId,
581
+ };
582
+ }
583
+
584
+ type LineMessageContext = NonNullable<Awaited<ReturnType<typeof buildLineMessageContext>>>;
585
+ type LinePostbackContext = NonNullable<Awaited<ReturnType<typeof buildLinePostbackContext>>>;
586
+ export type LineInboundContext = LineMessageContext | LinePostbackContext;
package/src/bot.ts ADDED
@@ -0,0 +1,70 @@
1
+ import type { webhook } from "@line/bot-sdk";
2
+ import type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
3
+ import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "autobot/plugin-sdk/reply-history";
4
+ import { getRuntimeConfig } from "autobot/plugin-sdk/runtime-config-snapshot";
5
+ import {
6
+ createNonExitingRuntime,
7
+ logVerbose,
8
+ type RuntimeEnv,
9
+ } from "autobot/plugin-sdk/runtime-env";
10
+ import { resolveLineAccount } from "./accounts.js";
11
+ import { createLineWebhookReplayCache, handleLineWebhookEvents } from "./bot-handlers.js";
12
+ import type { LineInboundContext } from "./bot-message-context.js";
13
+ import type { ResolvedLineAccount } from "./types.js";
14
+
15
+ interface LineBotOptions {
16
+ channelAccessToken: string;
17
+ channelSecret: string;
18
+ accountId?: string;
19
+ runtime?: RuntimeEnv;
20
+ config?: AutoBotConfig;
21
+ mediaMaxMb?: number;
22
+ onMessage?: (ctx: LineInboundContext) => Promise<void>;
23
+ }
24
+
25
+ interface LineBot {
26
+ handleWebhook: (body: webhook.CallbackRequest) => Promise<void>;
27
+ account: ResolvedLineAccount;
28
+ }
29
+
30
+ export function createLineBot(opts: LineBotOptions): LineBot {
31
+ const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime();
32
+
33
+ const cfg = opts.config ?? getRuntimeConfig();
34
+ const account = resolveLineAccount({
35
+ cfg,
36
+ accountId: opts.accountId,
37
+ });
38
+
39
+ const mediaMaxBytes = (opts.mediaMaxMb ?? account.config.mediaMaxMb ?? 10) * 1024 * 1024;
40
+
41
+ const processMessage =
42
+ opts.onMessage ??
43
+ (async () => {
44
+ logVerbose("line: no message handler configured");
45
+ });
46
+ const replayCache = createLineWebhookReplayCache();
47
+ const groupHistories = new Map<string, HistoryEntry[]>();
48
+
49
+ const handleWebhook = async (body: webhook.CallbackRequest): Promise<void> => {
50
+ if (!body.events || body.events.length === 0) {
51
+ return;
52
+ }
53
+
54
+ await handleLineWebhookEvents(body.events, {
55
+ cfg,
56
+ account,
57
+ runtime,
58
+ mediaMaxBytes,
59
+ processMessage,
60
+ replayCache,
61
+ groupHistories,
62
+ historyLimit: cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
63
+ });
64
+ };
65
+
66
+ return {
67
+ handleWebhook,
68
+ account,
69
+ };
70
+ }