@openclaw/line 2026.5.2-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/api.ts +11 -0
  2. package/channel-plugin-api.ts +1 -0
  3. package/contract-api.ts +5 -0
  4. package/index.ts +54 -0
  5. package/openclaw.plugin.json +342 -0
  6. package/package.json +58 -0
  7. package/runtime-api.ts +187 -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.test.ts +290 -0
  13. package/src/accounts.ts +187 -0
  14. package/src/actions.ts +61 -0
  15. package/src/auto-reply-delivery.test.ts +248 -0
  16. package/src/auto-reply-delivery.ts +200 -0
  17. package/src/bindings.ts +65 -0
  18. package/src/bot-access.ts +48 -0
  19. package/src/bot-handlers.test.ts +1089 -0
  20. package/src/bot-handlers.ts +642 -0
  21. package/src/bot-message-context.test.ts +405 -0
  22. package/src/bot-message-context.ts +581 -0
  23. package/src/bot.ts +70 -0
  24. package/src/card-command.ts +347 -0
  25. package/src/channel-access-token.ts +14 -0
  26. package/src/channel-api.ts +17 -0
  27. package/src/channel-setup-status.contract.test.ts +70 -0
  28. package/src/channel-shared.ts +48 -0
  29. package/src/channel.logout.test.ts +145 -0
  30. package/src/channel.runtime.ts +3 -0
  31. package/src/channel.sendPayload.test.ts +514 -0
  32. package/src/channel.setup.ts +11 -0
  33. package/src/channel.status.test.ts +63 -0
  34. package/src/channel.ts +154 -0
  35. package/src/config-adapter.ts +29 -0
  36. package/src/config-schema.ts +57 -0
  37. package/src/download.test.ts +133 -0
  38. package/src/download.ts +87 -0
  39. package/src/flex-templates/basic-cards.ts +395 -0
  40. package/src/flex-templates/common.ts +20 -0
  41. package/src/flex-templates/media-control-cards.ts +555 -0
  42. package/src/flex-templates/message.ts +13 -0
  43. package/src/flex-templates/schedule-cards.ts +467 -0
  44. package/src/flex-templates/types.ts +22 -0
  45. package/src/flex-templates.ts +32 -0
  46. package/src/gateway.ts +129 -0
  47. package/src/group-keys.test.ts +123 -0
  48. package/src/group-keys.ts +65 -0
  49. package/src/group-policy.ts +22 -0
  50. package/src/markdown-to-line.test.ts +348 -0
  51. package/src/markdown-to-line.ts +416 -0
  52. package/src/message-cards.test.ts +204 -0
  53. package/src/monitor.lifecycle.test.ts +421 -0
  54. package/src/monitor.runtime.ts +1 -0
  55. package/src/monitor.ts +506 -0
  56. package/src/outbound-media.test.ts +189 -0
  57. package/src/outbound-media.ts +120 -0
  58. package/src/outbound.runtime.ts +12 -0
  59. package/src/outbound.ts +356 -0
  60. package/src/probe.contract.test.ts +9 -0
  61. package/src/probe.runtime.ts +1 -0
  62. package/src/probe.ts +34 -0
  63. package/src/quick-reply-fallback.ts +10 -0
  64. package/src/reply-chunks.test.ts +179 -0
  65. package/src/reply-chunks.ts +110 -0
  66. package/src/reply-payload-transform.test.ts +387 -0
  67. package/src/reply-payload-transform.ts +317 -0
  68. package/src/rich-menu.test.ts +310 -0
  69. package/src/rich-menu.ts +326 -0
  70. package/src/runtime.ts +32 -0
  71. package/src/send.test.ts +346 -0
  72. package/src/send.ts +489 -0
  73. package/src/setup-core.ts +149 -0
  74. package/src/setup-runtime-api.ts +9 -0
  75. package/src/setup-surface.test.ts +474 -0
  76. package/src/setup-surface.ts +227 -0
  77. package/src/signature.test.ts +34 -0
  78. package/src/signature.ts +24 -0
  79. package/src/status.ts +37 -0
  80. package/src/template-messages.ts +333 -0
  81. package/src/types.ts +128 -0
  82. package/src/webhook-node.test.ts +513 -0
  83. package/src/webhook-node.ts +131 -0
  84. package/src/webhook-utils.ts +10 -0
  85. package/src/webhook.ts +111 -0
  86. package/tsconfig.json +16 -0
@@ -0,0 +1,1089 @@
1
+ import type { webhook } from "@line/bot-sdk";
2
+ import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
3
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import type { LineAccountConfig } from "./types.js";
5
+
6
+ type MessageEvent = webhook.MessageEvent;
7
+ type PostbackEvent = webhook.PostbackEvent;
8
+
9
+ // Avoid pulling in globals/pairing/media dependencies; this suite only asserts
10
+ // allowlist/groupPolicy gating and message-context wiring.
11
+ vi.mock("openclaw/plugin-sdk/channel-inbound", () => ({
12
+ buildMentionRegexes: () => [],
13
+ matchesMentionPatterns: () => false,
14
+ resolveInboundMentionDecision: (params: {
15
+ facts?: {
16
+ canDetectMention: boolean;
17
+ wasMentioned: boolean;
18
+ hasAnyMention?: boolean;
19
+ };
20
+ policy?: {
21
+ isGroup: boolean;
22
+ requireMention: boolean;
23
+ allowTextCommands: boolean;
24
+ hasControlCommand: boolean;
25
+ commandAuthorized: boolean;
26
+ };
27
+ isGroup?: boolean;
28
+ requireMention?: boolean;
29
+ canDetectMention?: boolean;
30
+ wasMentioned?: boolean;
31
+ hasAnyMention?: boolean;
32
+ allowTextCommands?: boolean;
33
+ hasControlCommand?: boolean;
34
+ commandAuthorized?: boolean;
35
+ }) => {
36
+ const facts =
37
+ "facts" in params && params.facts
38
+ ? params.facts
39
+ : {
40
+ canDetectMention: Boolean(params.canDetectMention),
41
+ wasMentioned: Boolean(params.wasMentioned),
42
+ hasAnyMention: params.hasAnyMention,
43
+ };
44
+ const policy =
45
+ "policy" in params && params.policy
46
+ ? params.policy
47
+ : {
48
+ isGroup: Boolean(params.isGroup),
49
+ requireMention: Boolean(params.requireMention),
50
+ allowTextCommands: Boolean(params.allowTextCommands),
51
+ hasControlCommand: Boolean(params.hasControlCommand),
52
+ commandAuthorized: Boolean(params.commandAuthorized),
53
+ };
54
+ return {
55
+ effectiveWasMentioned:
56
+ facts.wasMentioned ||
57
+ (policy.allowTextCommands &&
58
+ policy.hasControlCommand &&
59
+ policy.commandAuthorized &&
60
+ !facts.hasAnyMention),
61
+ shouldSkip:
62
+ policy.isGroup &&
63
+ policy.requireMention &&
64
+ facts.canDetectMention &&
65
+ !facts.wasMentioned &&
66
+ !(
67
+ policy.allowTextCommands &&
68
+ policy.hasControlCommand &&
69
+ policy.commandAuthorized &&
70
+ !facts.hasAnyMention
71
+ ),
72
+ shouldBypassMention:
73
+ policy.isGroup &&
74
+ policy.requireMention &&
75
+ !facts.wasMentioned &&
76
+ !facts.hasAnyMention &&
77
+ policy.allowTextCommands &&
78
+ policy.hasControlCommand &&
79
+ policy.commandAuthorized,
80
+ implicitMention: false,
81
+ matchedImplicitMentionKinds: [],
82
+ };
83
+ },
84
+ }));
85
+ vi.mock("openclaw/plugin-sdk/channel-pairing", () => ({
86
+ createChannelPairingChallengeIssuer:
87
+ ({ upsertPairingRequest }: { upsertPairingRequest: (args: unknown) => Promise<unknown> }) =>
88
+ async ({ senderId, onCreated }: { senderId: string; onCreated?: () => void }) => {
89
+ await upsertPairingRequest({ id: senderId, meta: {} });
90
+ onCreated?.();
91
+ },
92
+ }));
93
+ vi.mock("openclaw/plugin-sdk/command-auth", () => ({
94
+ hasControlCommand: (text: string) => text.trim().startsWith("!"),
95
+ resolveControlCommandGate: ({
96
+ hasControlCommand,
97
+ authorizers,
98
+ }: {
99
+ hasControlCommand: boolean;
100
+ authorizers: Array<{ configured: boolean; allowed: boolean }>;
101
+ }) => ({
102
+ commandAuthorized:
103
+ hasControlCommand && authorizers.some((entry) => entry.allowed || !entry.configured),
104
+ }),
105
+ }));
106
+ vi.mock("openclaw/plugin-sdk/runtime-group-policy", () => ({
107
+ resolveAllowlistProviderRuntimeGroupPolicy: ({
108
+ groupPolicy,
109
+ defaultGroupPolicy,
110
+ }: {
111
+ groupPolicy?: string;
112
+ defaultGroupPolicy: string;
113
+ }) => ({
114
+ groupPolicy: groupPolicy ?? defaultGroupPolicy,
115
+ providerMissingFallbackApplied: false,
116
+ }),
117
+ resolveDefaultGroupPolicy: (cfg: { channels?: { line?: { groupPolicy?: string } } }) =>
118
+ cfg.channels?.line?.groupPolicy ?? "open",
119
+ warnMissingProviderGroupPolicyFallbackOnce: () => {},
120
+ }));
121
+ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
122
+ danger: (text: string) => text,
123
+ logVerbose: () => {},
124
+ }));
125
+ vi.mock("openclaw/plugin-sdk/group-access", () => ({
126
+ evaluateMatchedGroupAccessForPolicy: ({
127
+ groupPolicy,
128
+ hasMatchInput,
129
+ allowlistConfigured,
130
+ allowlistMatched,
131
+ }: {
132
+ groupPolicy: string;
133
+ hasMatchInput: boolean;
134
+ allowlistConfigured: boolean;
135
+ allowlistMatched: boolean;
136
+ }) => {
137
+ if (groupPolicy === "disabled") {
138
+ return { allowed: false, reason: "disabled" };
139
+ }
140
+ if (groupPolicy !== "allowlist") {
141
+ return { allowed: true, reason: null };
142
+ }
143
+ if (!hasMatchInput) {
144
+ return { allowed: false, reason: "missing_match_input" };
145
+ }
146
+ if (!allowlistConfigured) {
147
+ return { allowed: false, reason: "empty_allowlist" };
148
+ }
149
+ if (!allowlistMatched) {
150
+ return { allowed: false, reason: "not_allowlisted" };
151
+ }
152
+ return { allowed: true, reason: null };
153
+ },
154
+ }));
155
+ vi.mock("openclaw/plugin-sdk/reply-history", () => ({
156
+ DEFAULT_GROUP_HISTORY_LIMIT: 20,
157
+ clearHistoryEntriesIfEnabled: ({
158
+ historyMap,
159
+ historyKey,
160
+ }: {
161
+ historyMap: Map<string, HistoryEntry[]>;
162
+ historyKey: string;
163
+ }) => {
164
+ historyMap.delete(historyKey);
165
+ },
166
+ recordPendingHistoryEntryIfEnabled: ({
167
+ historyMap,
168
+ historyKey,
169
+ limit,
170
+ entry,
171
+ }: {
172
+ historyMap: Map<string, HistoryEntry[]>;
173
+ historyKey: string;
174
+ limit: number;
175
+ entry: HistoryEntry;
176
+ }) => {
177
+ const existing = historyMap.get(historyKey) ?? [];
178
+ historyMap.set(historyKey, [...existing, entry].slice(-limit));
179
+ },
180
+ }));
181
+ vi.mock("openclaw/plugin-sdk/routing", () => ({
182
+ resolveAgentRoute: () => ({ agentId: "default" }),
183
+ }));
184
+
185
+ const { readAllowFromStoreMock, upsertPairingRequestMock } = vi.hoisted(() => ({
186
+ readAllowFromStoreMock: vi.fn(async () => [] as string[]),
187
+ upsertPairingRequestMock: vi.fn(async () => ({ code: "CODE", created: true })),
188
+ }));
189
+
190
+ vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
191
+ resolvePairingIdLabel: () => "lineUserId",
192
+ readChannelAllowFromStore: readAllowFromStoreMock,
193
+ upsertChannelPairingRequest: upsertPairingRequestMock,
194
+ }));
195
+
196
+ vi.mock("./download.js", () => ({
197
+ downloadLineMedia: async () => {
198
+ throw new Error("downloadLineMedia should not be called from bot-handlers tests");
199
+ },
200
+ }));
201
+
202
+ vi.mock("./send.js", () => ({
203
+ pushMessageLine: async () => {
204
+ throw new Error("pushMessageLine should not be called from bot-handlers tests");
205
+ },
206
+ replyMessageLine: async () => {
207
+ throw new Error("replyMessageLine should not be called from bot-handlers tests");
208
+ },
209
+ }));
210
+
211
+ const { buildLineMessageContextMock, buildLinePostbackContextMock } = vi.hoisted(() => ({
212
+ buildLineMessageContextMock: vi.fn(async () => ({
213
+ ctxPayload: { From: "line:group:group-1" },
214
+ replyToken: "reply-token",
215
+ route: { agentId: "default" },
216
+ isGroup: true,
217
+ accountId: "default",
218
+ })),
219
+ buildLinePostbackContextMock: vi.fn(async () => null as unknown),
220
+ }));
221
+
222
+ vi.mock("./bot-message-context.js", () => ({
223
+ buildLineMessageContext: buildLineMessageContextMock,
224
+ buildLinePostbackContext: buildLinePostbackContextMock,
225
+ getLineSourceInfo: (source: {
226
+ type?: string;
227
+ userId?: string;
228
+ groupId?: string;
229
+ roomId?: string;
230
+ }) => ({
231
+ userId: source.userId,
232
+ groupId: source.type === "group" ? source.groupId : undefined,
233
+ roomId: source.type === "room" ? source.roomId : undefined,
234
+ isGroup: source.type === "group" || source.type === "room",
235
+ }),
236
+ }));
237
+
238
+ let handleLineWebhookEvents: typeof import("./bot-handlers.js").handleLineWebhookEvents;
239
+ let createLineWebhookReplayCache: typeof import("./bot-handlers.js").createLineWebhookReplayCache;
240
+ let LineRetryableWebhookError: typeof import("./bot-handlers.js").LineRetryableWebhookError;
241
+ type LineWebhookContext = Parameters<typeof import("./bot-handlers.js").handleLineWebhookEvents>[1];
242
+
243
+ const createRuntime = () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() });
244
+
245
+ function createReplayMessageEvent(params: {
246
+ messageId: string;
247
+ groupId: string;
248
+ userId: string;
249
+ webhookEventId: string;
250
+ isRedelivery: boolean;
251
+ }) {
252
+ return {
253
+ type: "message",
254
+ message: { id: params.messageId, type: "text", text: "hello", quoteToken: "quote-token" },
255
+ replyToken: "reply-token",
256
+ timestamp: Date.now(),
257
+ source: { type: "group", groupId: params.groupId, userId: params.userId },
258
+ mode: "active",
259
+ webhookEventId: params.webhookEventId,
260
+ deliveryContext: { isRedelivery: params.isRedelivery },
261
+ } as MessageEvent;
262
+ }
263
+
264
+ function createTestMessageEvent(params: {
265
+ message: MessageEvent["message"];
266
+ source: MessageEvent["source"];
267
+ webhookEventId: string;
268
+ timestamp?: number;
269
+ replyToken?: string;
270
+ isRedelivery?: boolean;
271
+ }) {
272
+ return {
273
+ type: "message",
274
+ message: params.message,
275
+ replyToken: params.replyToken ?? "reply-token",
276
+ timestamp: params.timestamp ?? Date.now(),
277
+ source: params.source,
278
+ mode: "active",
279
+ webhookEventId: params.webhookEventId,
280
+ deliveryContext: { isRedelivery: params.isRedelivery ?? false },
281
+ } as MessageEvent;
282
+ }
283
+
284
+ function createLineWebhookTestContext(params: {
285
+ processMessage: LineWebhookContext["processMessage"];
286
+ groupPolicy?: LineAccountConfig["groupPolicy"];
287
+ dmPolicy?: LineAccountConfig["dmPolicy"];
288
+ requireMention?: boolean;
289
+ groupHistories?: Map<string, HistoryEntry[]>;
290
+ replayCache?: ReturnType<typeof createLineWebhookReplayCache>;
291
+ }): Parameters<typeof handleLineWebhookEvents>[1] {
292
+ const lineConfig = {
293
+ ...(params.groupPolicy ? { groupPolicy: params.groupPolicy } : {}),
294
+ ...(params.dmPolicy ? { dmPolicy: params.dmPolicy } : {}),
295
+ ...(params.dmPolicy === "open" ? { allowFrom: ["*"] } : {}),
296
+ };
297
+ return {
298
+ cfg: { channels: { line: lineConfig } },
299
+ account: {
300
+ accountId: "default",
301
+ enabled: true,
302
+ channelAccessToken: "token",
303
+ channelSecret: "secret",
304
+ tokenSource: "config",
305
+ config: {
306
+ ...lineConfig,
307
+ ...(params.requireMention === undefined
308
+ ? {}
309
+ : { groups: { "*": { requireMention: params.requireMention } } }),
310
+ },
311
+ },
312
+ runtime: createRuntime(),
313
+ mediaMaxBytes: 1,
314
+ processMessage: params.processMessage,
315
+ ...(params.groupHistories ? { groupHistories: params.groupHistories } : {}),
316
+ ...(params.replayCache ? { replayCache: params.replayCache } : {}),
317
+ };
318
+ }
319
+
320
+ function createOpenGroupReplayContext(
321
+ processMessage: LineWebhookContext["processMessage"],
322
+ replayCache: ReturnType<typeof createLineWebhookReplayCache>,
323
+ ): Parameters<typeof handleLineWebhookEvents>[1] {
324
+ return createLineWebhookTestContext({
325
+ processMessage,
326
+ groupPolicy: "open",
327
+ requireMention: false,
328
+ replayCache,
329
+ });
330
+ }
331
+
332
+ async function expectGroupMessageBlocked(params: {
333
+ processMessage: LineWebhookContext["processMessage"];
334
+ event: MessageEvent;
335
+ context: Parameters<typeof handleLineWebhookEvents>[1];
336
+ }) {
337
+ await handleLineWebhookEvents([params.event], params.context);
338
+ expect(params.processMessage).not.toHaveBeenCalled();
339
+ expect(buildLineMessageContextMock).not.toHaveBeenCalled();
340
+ }
341
+
342
+ async function expectRequireMentionGroupMessageProcessed(event: MessageEvent) {
343
+ const processMessage = vi.fn();
344
+ await handleLineWebhookEvents(
345
+ [event],
346
+ createLineWebhookTestContext({
347
+ processMessage,
348
+ groupPolicy: "open",
349
+ requireMention: true,
350
+ }),
351
+ );
352
+ expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
353
+ expect(processMessage).toHaveBeenCalledTimes(1);
354
+ }
355
+
356
+ async function startInflightReplayDuplicate(params: {
357
+ event: MessageEvent;
358
+ processMessage: LineWebhookContext["processMessage"];
359
+ }) {
360
+ const context = createOpenGroupReplayContext(
361
+ params.processMessage,
362
+ createLineWebhookReplayCache(),
363
+ );
364
+ const firstRun = handleLineWebhookEvents([params.event], context);
365
+ await Promise.resolve();
366
+ const secondRun = handleLineWebhookEvents([params.event], context);
367
+ return { firstRun, secondRun };
368
+ }
369
+
370
+ describe("handleLineWebhookEvents", () => {
371
+ beforeAll(async () => {
372
+ ({ handleLineWebhookEvents, createLineWebhookReplayCache, LineRetryableWebhookError } =
373
+ await import("./bot-handlers.js"));
374
+ });
375
+
376
+ beforeEach(() => {
377
+ buildLineMessageContextMock.mockReset();
378
+ buildLineMessageContextMock.mockImplementation(async () => ({
379
+ ctxPayload: { From: "line:group:group-1" },
380
+ replyToken: "reply-token",
381
+ route: { agentId: "default" },
382
+ isGroup: true,
383
+ accountId: "default",
384
+ }));
385
+ buildLinePostbackContextMock.mockReset();
386
+ buildLinePostbackContextMock.mockImplementation(async () => null as unknown);
387
+ readAllowFromStoreMock.mockReset();
388
+ readAllowFromStoreMock.mockImplementation(async () => [] as string[]);
389
+ upsertPairingRequestMock.mockReset();
390
+ upsertPairingRequestMock.mockImplementation(async () => ({ code: "CODE", created: true }));
391
+ });
392
+ it("blocks group messages when groupPolicy is disabled", async () => {
393
+ const processMessage = vi.fn();
394
+ const event = {
395
+ type: "message",
396
+ message: { id: "m1", type: "text", text: "hi" },
397
+ replyToken: "reply-token",
398
+ timestamp: Date.now(),
399
+ source: { type: "group", groupId: "group-1", userId: "user-1" },
400
+ mode: "active",
401
+ webhookEventId: "evt-1",
402
+ deliveryContext: { isRedelivery: false },
403
+ } as MessageEvent;
404
+
405
+ await handleLineWebhookEvents([event], {
406
+ cfg: { channels: { line: { groupPolicy: "disabled" } } },
407
+ account: {
408
+ accountId: "default",
409
+ enabled: true,
410
+ channelAccessToken: "token",
411
+ channelSecret: "secret",
412
+ tokenSource: "config",
413
+ config: { groupPolicy: "disabled" },
414
+ },
415
+ runtime: createRuntime(),
416
+ mediaMaxBytes: 1,
417
+ processMessage,
418
+ });
419
+
420
+ expect(processMessage).not.toHaveBeenCalled();
421
+ expect(buildLineMessageContextMock).not.toHaveBeenCalled();
422
+ });
423
+
424
+ it("blocks group messages when allowlist is empty", async () => {
425
+ const processMessage = vi.fn();
426
+ await expectGroupMessageBlocked({
427
+ processMessage,
428
+ event: createTestMessageEvent({
429
+ message: { id: "m2", type: "text", text: "hi", quoteToken: "quote-token" },
430
+ source: { type: "group", groupId: "group-1", userId: "user-2" },
431
+ webhookEventId: "evt-2",
432
+ }),
433
+ context: createLineWebhookTestContext({
434
+ processMessage,
435
+ groupPolicy: "allowlist",
436
+ }),
437
+ });
438
+ });
439
+
440
+ it("allows group messages when sender is in groupAllowFrom", async () => {
441
+ const processMessage = vi.fn();
442
+ const event = {
443
+ type: "message",
444
+ message: { id: "m3", type: "text", text: "hi" },
445
+ replyToken: "reply-token",
446
+ timestamp: Date.now(),
447
+ source: { type: "group", groupId: "group-1", userId: "user-3" },
448
+ mode: "active",
449
+ webhookEventId: "evt-3",
450
+ deliveryContext: { isRedelivery: false },
451
+ } as MessageEvent;
452
+
453
+ await handleLineWebhookEvents([event], {
454
+ cfg: {
455
+ channels: { line: { groupPolicy: "allowlist", groupAllowFrom: ["user-3"] } },
456
+ },
457
+ account: {
458
+ accountId: "default",
459
+ enabled: true,
460
+ channelAccessToken: "token",
461
+ channelSecret: "secret",
462
+ tokenSource: "config",
463
+ config: {
464
+ groupPolicy: "allowlist",
465
+ groupAllowFrom: ["user-3"],
466
+ groups: { "*": { requireMention: false } },
467
+ },
468
+ },
469
+ runtime: createRuntime(),
470
+ mediaMaxBytes: 1,
471
+ processMessage,
472
+ });
473
+
474
+ expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
475
+ expect(processMessage).toHaveBeenCalledTimes(1);
476
+ });
477
+
478
+ it("blocks group sender not in groupAllowFrom even when sender is paired in DM store", async () => {
479
+ readAllowFromStoreMock.mockResolvedValueOnce(["user-store"]);
480
+ const processMessage = vi.fn();
481
+ const event = {
482
+ type: "message",
483
+ message: { id: "m5", type: "text", text: "hi" },
484
+ replyToken: "reply-token",
485
+ timestamp: Date.now(),
486
+ source: { type: "group", groupId: "group-1", userId: "user-store" },
487
+ mode: "active",
488
+ webhookEventId: "evt-5",
489
+ deliveryContext: { isRedelivery: false },
490
+ } as MessageEvent;
491
+
492
+ await handleLineWebhookEvents([event], {
493
+ cfg: {
494
+ channels: { line: { groupPolicy: "allowlist", groupAllowFrom: ["user-group"] } },
495
+ },
496
+ account: {
497
+ accountId: "default",
498
+ enabled: true,
499
+ channelAccessToken: "token",
500
+ channelSecret: "secret",
501
+ tokenSource: "config",
502
+ config: { groupPolicy: "allowlist", groupAllowFrom: ["user-group"] },
503
+ },
504
+ runtime: createRuntime(),
505
+ mediaMaxBytes: 1,
506
+ processMessage,
507
+ });
508
+
509
+ expect(processMessage).not.toHaveBeenCalled();
510
+ expect(buildLineMessageContextMock).not.toHaveBeenCalled();
511
+ expect(readAllowFromStoreMock).toHaveBeenCalledWith("line", undefined, "default");
512
+ });
513
+
514
+ it("blocks group messages without sender id when groupPolicy is allowlist", async () => {
515
+ const processMessage = vi.fn();
516
+ const event = {
517
+ type: "message",
518
+ message: { id: "m5a", type: "text", text: "hi" },
519
+ replyToken: "reply-token",
520
+ timestamp: Date.now(),
521
+ source: { type: "group", groupId: "group-1" },
522
+ mode: "active",
523
+ webhookEventId: "evt-5a",
524
+ deliveryContext: { isRedelivery: false },
525
+ } as MessageEvent;
526
+
527
+ await handleLineWebhookEvents([event], {
528
+ cfg: {
529
+ channels: { line: { groupPolicy: "allowlist", groupAllowFrom: ["user-5"] } },
530
+ },
531
+ account: {
532
+ accountId: "default",
533
+ enabled: true,
534
+ channelAccessToken: "token",
535
+ channelSecret: "secret",
536
+ tokenSource: "config",
537
+ config: { groupPolicy: "allowlist", groupAllowFrom: ["user-5"] },
538
+ },
539
+ runtime: createRuntime(),
540
+ mediaMaxBytes: 1,
541
+ processMessage,
542
+ });
543
+
544
+ expect(processMessage).not.toHaveBeenCalled();
545
+ expect(buildLineMessageContextMock).not.toHaveBeenCalled();
546
+ });
547
+
548
+ it("does not authorize group messages from DM pairing-store entries when group allowlist is empty", async () => {
549
+ readAllowFromStoreMock.mockResolvedValueOnce(["user-5"]);
550
+ const processMessage = vi.fn();
551
+ await expectGroupMessageBlocked({
552
+ processMessage,
553
+ event: createTestMessageEvent({
554
+ message: { id: "m5b", type: "text", text: "hi", quoteToken: "quote-token" },
555
+ source: { type: "group", groupId: "group-1", userId: "user-5" },
556
+ webhookEventId: "evt-5b",
557
+ }),
558
+ context: {
559
+ cfg: { channels: { line: { groupPolicy: "allowlist" } } },
560
+ account: {
561
+ accountId: "default",
562
+ enabled: true,
563
+ channelAccessToken: "token",
564
+ channelSecret: "secret",
565
+ tokenSource: "config",
566
+ config: {
567
+ dmPolicy: "pairing",
568
+ allowFrom: [],
569
+ groupPolicy: "allowlist",
570
+ groupAllowFrom: [],
571
+ },
572
+ },
573
+ runtime: createRuntime(),
574
+ mediaMaxBytes: 1,
575
+ processMessage,
576
+ },
577
+ });
578
+ });
579
+
580
+ it("blocks group messages when wildcard group config disables groups", async () => {
581
+ const processMessage = vi.fn();
582
+ const event = {
583
+ type: "message",
584
+ message: { id: "m4", type: "text", text: "hi" },
585
+ replyToken: "reply-token",
586
+ timestamp: Date.now(),
587
+ source: { type: "group", groupId: "group-2", userId: "user-4" },
588
+ mode: "active",
589
+ webhookEventId: "evt-4",
590
+ deliveryContext: { isRedelivery: false },
591
+ } as MessageEvent;
592
+
593
+ await handleLineWebhookEvents([event], {
594
+ cfg: { channels: { line: { groupPolicy: "open" } } },
595
+ account: {
596
+ accountId: "default",
597
+ enabled: true,
598
+ channelAccessToken: "token",
599
+ channelSecret: "secret",
600
+ tokenSource: "config",
601
+ config: { groupPolicy: "open", groups: { "*": { enabled: false } } },
602
+ },
603
+ runtime: createRuntime(),
604
+ mediaMaxBytes: 1,
605
+ processMessage,
606
+ });
607
+
608
+ expect(processMessage).not.toHaveBeenCalled();
609
+ expect(buildLineMessageContextMock).not.toHaveBeenCalled();
610
+ });
611
+
612
+ it("scopes DM pairing requests to accountId", async () => {
613
+ const processMessage = vi.fn();
614
+ const event = {
615
+ type: "message",
616
+ message: { id: "m5", type: "text", text: "hi" },
617
+ replyToken: "reply-token",
618
+ timestamp: Date.now(),
619
+ source: { type: "user", userId: "user-5" },
620
+ mode: "active",
621
+ webhookEventId: "evt-5",
622
+ deliveryContext: { isRedelivery: false },
623
+ } as MessageEvent;
624
+
625
+ await handleLineWebhookEvents([event], {
626
+ cfg: { channels: { line: { dmPolicy: "pairing" } } },
627
+ account: {
628
+ accountId: "default",
629
+ enabled: true,
630
+ channelAccessToken: "token",
631
+ channelSecret: "secret",
632
+ tokenSource: "config",
633
+ config: { dmPolicy: "pairing", allowFrom: ["user-owner"] },
634
+ },
635
+ runtime: createRuntime(),
636
+ mediaMaxBytes: 1,
637
+ processMessage,
638
+ });
639
+
640
+ expect(processMessage).not.toHaveBeenCalled();
641
+ expect(upsertPairingRequestMock).toHaveBeenCalledWith(
642
+ expect.objectContaining({
643
+ channel: "line",
644
+ id: "user-5",
645
+ accountId: "default",
646
+ }),
647
+ );
648
+ });
649
+
650
+ it("does not authorize DM senders from another account's pairing-store entries", async () => {
651
+ const processMessage = vi.fn();
652
+ readAllowFromStoreMock.mockImplementation(async (...args: unknown[]) => {
653
+ const accountId = args[2] as string | undefined;
654
+ if (accountId === "work") {
655
+ return [];
656
+ }
657
+ return ["cross-account-user"];
658
+ });
659
+ upsertPairingRequestMock.mockResolvedValue({ code: "CODE", created: false });
660
+
661
+ const event = {
662
+ type: "message",
663
+ message: { id: "m6", type: "text", text: "hi" },
664
+ replyToken: "reply-token",
665
+ timestamp: Date.now(),
666
+ source: { type: "user", userId: "cross-account-user" },
667
+ mode: "active",
668
+ webhookEventId: "evt-6",
669
+ deliveryContext: { isRedelivery: false },
670
+ } as MessageEvent;
671
+
672
+ await handleLineWebhookEvents([event], {
673
+ cfg: { channels: { line: { dmPolicy: "pairing" } } },
674
+ account: {
675
+ accountId: "work",
676
+ enabled: true,
677
+ channelAccessToken: "token-work", // pragma: allowlist secret
678
+ channelSecret: "secret-work", // pragma: allowlist secret
679
+ tokenSource: "config",
680
+ config: { dmPolicy: "pairing" },
681
+ },
682
+ runtime: createRuntime(),
683
+ mediaMaxBytes: 1,
684
+ processMessage,
685
+ });
686
+
687
+ expect(readAllowFromStoreMock).toHaveBeenCalledWith("line", undefined, "work");
688
+ expect(processMessage).not.toHaveBeenCalled();
689
+ expect(upsertPairingRequestMock).toHaveBeenCalledWith(
690
+ expect.objectContaining({
691
+ channel: "line",
692
+ id: "cross-account-user",
693
+ accountId: "work",
694
+ }),
695
+ );
696
+ });
697
+
698
+ it("deduplicates replayed webhook events by webhookEventId before processing", async () => {
699
+ const processMessage = vi.fn();
700
+ const event = createReplayMessageEvent({
701
+ messageId: "m-replay",
702
+ groupId: "group-replay",
703
+ userId: "user-replay",
704
+ webhookEventId: "evt-replay-1",
705
+ isRedelivery: true,
706
+ });
707
+ const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache());
708
+
709
+ await handleLineWebhookEvents([event], context);
710
+ await handleLineWebhookEvents([event], context);
711
+
712
+ expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
713
+ expect(processMessage).toHaveBeenCalledTimes(1);
714
+ });
715
+
716
+ it("skips concurrent redeliveries while the first event is still processing", async () => {
717
+ let resolveFirst: (() => void) | undefined;
718
+ const firstDone = new Promise<void>((resolve) => {
719
+ resolveFirst = resolve;
720
+ });
721
+ const processMessage = vi.fn(async () => {
722
+ await firstDone;
723
+ });
724
+ const event = createReplayMessageEvent({
725
+ messageId: "m-inflight",
726
+ groupId: "group-inflight",
727
+ userId: "user-inflight",
728
+ webhookEventId: "evt-inflight-1",
729
+ isRedelivery: true,
730
+ });
731
+ const { firstRun, secondRun } = await startInflightReplayDuplicate({ event, processMessage });
732
+ resolveFirst?.();
733
+ await Promise.all([firstRun, secondRun]);
734
+
735
+ expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
736
+ expect(processMessage).toHaveBeenCalledTimes(1);
737
+ });
738
+
739
+ it("mirrors in-flight retryable replay failures so concurrent duplicates also fail", async () => {
740
+ let rejectFirst: ((err: Error) => void) | undefined;
741
+ const firstDone = new Promise<void>((_, reject) => {
742
+ rejectFirst = reject;
743
+ });
744
+ const processMessage = vi.fn(async () => {
745
+ await firstDone;
746
+ });
747
+ const event = createReplayMessageEvent({
748
+ messageId: "m-inflight-fail",
749
+ groupId: "group-inflight",
750
+ userId: "user-inflight",
751
+ webhookEventId: "evt-inflight-fail-1",
752
+ isRedelivery: true,
753
+ });
754
+ const { firstRun, secondRun } = await startInflightReplayDuplicate({ event, processMessage });
755
+ const firstFailure = expect(firstRun).rejects.toThrow("transient inflight failure");
756
+ const secondFailure = expect(secondRun).rejects.toThrow("transient inflight failure");
757
+ rejectFirst?.(new LineRetryableWebhookError("transient inflight failure"));
758
+
759
+ await Promise.all([firstFailure, secondFailure]);
760
+ expect(processMessage).toHaveBeenCalledTimes(1);
761
+ });
762
+
763
+ it("deduplicates redeliveries by LINE message id when webhookEventId changes", async () => {
764
+ const processMessage = vi.fn();
765
+ const event = {
766
+ type: "message",
767
+ message: { id: "m-dup-1", type: "text", text: "hello" },
768
+ replyToken: "reply-token",
769
+ timestamp: Date.now(),
770
+ source: { type: "group", groupId: "group-dup", userId: "user-dup" },
771
+ mode: "active",
772
+ webhookEventId: "evt-dup-1",
773
+ deliveryContext: { isRedelivery: false },
774
+ } as MessageEvent;
775
+
776
+ const context: Parameters<typeof handleLineWebhookEvents>[1] = {
777
+ cfg: {
778
+ channels: { line: { groupPolicy: "allowlist", groupAllowFrom: ["user-dup"] } },
779
+ },
780
+ account: {
781
+ accountId: "default",
782
+ enabled: true,
783
+ channelAccessToken: "token",
784
+ channelSecret: "secret",
785
+ tokenSource: "config",
786
+ config: {
787
+ groupPolicy: "allowlist",
788
+ groupAllowFrom: ["user-dup"],
789
+ groups: { "*": { requireMention: false } },
790
+ },
791
+ },
792
+ runtime: createRuntime(),
793
+ mediaMaxBytes: 1,
794
+ processMessage,
795
+ replayCache: createLineWebhookReplayCache(),
796
+ };
797
+
798
+ await handleLineWebhookEvents([event], context);
799
+ await handleLineWebhookEvents(
800
+ [
801
+ {
802
+ ...event,
803
+ webhookEventId: "evt-dup-redelivery",
804
+ deliveryContext: { isRedelivery: true },
805
+ } as MessageEvent,
806
+ ],
807
+ context,
808
+ );
809
+
810
+ expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
811
+ expect(processMessage).toHaveBeenCalledTimes(1);
812
+ });
813
+
814
+ it("deduplicates postback redeliveries by webhookEventId when replyToken changes", async () => {
815
+ const processMessage = vi.fn();
816
+ buildLinePostbackContextMock.mockResolvedValue({
817
+ ctxPayload: { From: "line:user:user-postback" },
818
+ route: { agentId: "default" },
819
+ isGroup: false,
820
+ accountId: "default",
821
+ });
822
+ const event = {
823
+ type: "postback",
824
+ postback: { data: "action=confirm" },
825
+ replyToken: "reply-token-1",
826
+ timestamp: Date.now(),
827
+ source: { type: "user", userId: "user-postback" },
828
+ mode: "active",
829
+ webhookEventId: "evt-postback-1",
830
+ deliveryContext: { isRedelivery: false },
831
+ } as PostbackEvent;
832
+
833
+ const context: Parameters<typeof handleLineWebhookEvents>[1] = {
834
+ cfg: { channels: { line: { dmPolicy: "open", allowFrom: ["*"] } } },
835
+ account: {
836
+ accountId: "default",
837
+ enabled: true,
838
+ channelAccessToken: "token",
839
+ channelSecret: "secret",
840
+ tokenSource: "config",
841
+ config: { dmPolicy: "open", allowFrom: ["*"] },
842
+ },
843
+ runtime: createRuntime(),
844
+ mediaMaxBytes: 1,
845
+ processMessage,
846
+ replayCache: createLineWebhookReplayCache(),
847
+ };
848
+
849
+ await handleLineWebhookEvents([event], context);
850
+ await handleLineWebhookEvents(
851
+ [
852
+ {
853
+ ...event,
854
+ replyToken: "reply-token-2",
855
+ deliveryContext: { isRedelivery: true },
856
+ } as PostbackEvent,
857
+ ],
858
+ context,
859
+ );
860
+
861
+ expect(buildLinePostbackContextMock).toHaveBeenCalledTimes(1);
862
+ expect(processMessage).toHaveBeenCalledTimes(1);
863
+ });
864
+
865
+ it("skips group messages by default when requireMention is not configured", async () => {
866
+ const processMessage = vi.fn();
867
+ const event = createTestMessageEvent({
868
+ message: { id: "m-default-skip", type: "text", text: "hi there", quoteToken: "q-default" },
869
+ source: { type: "group", groupId: "group-default", userId: "user-default" },
870
+ webhookEventId: "evt-default-skip",
871
+ });
872
+
873
+ await handleLineWebhookEvents(
874
+ [event],
875
+ createLineWebhookTestContext({
876
+ processMessage,
877
+ groupPolicy: "open",
878
+ }),
879
+ );
880
+
881
+ expect(processMessage).not.toHaveBeenCalled();
882
+ expect(buildLineMessageContextMock).not.toHaveBeenCalled();
883
+ });
884
+
885
+ it("records unmentioned group messages as pending history", async () => {
886
+ const processMessage = vi.fn();
887
+ const groupHistories = new Map<string, HistoryEntry[]>();
888
+ const event = createTestMessageEvent({
889
+ message: { id: "m-hist-1", type: "text", text: "hello history", quoteToken: "q-hist-1" },
890
+ timestamp: 1700000000000,
891
+ source: { type: "group", groupId: "group-hist-1", userId: "user-hist" },
892
+ webhookEventId: "evt-hist-1",
893
+ });
894
+
895
+ await handleLineWebhookEvents(
896
+ [event],
897
+ createLineWebhookTestContext({
898
+ processMessage,
899
+ groupPolicy: "open",
900
+ groupHistories,
901
+ }),
902
+ );
903
+
904
+ expect(processMessage).not.toHaveBeenCalled();
905
+ const entries = groupHistories.get("group-hist-1");
906
+ expect(entries).toHaveLength(1);
907
+ expect(entries?.[0]).toMatchObject({
908
+ sender: "user:user-hist",
909
+ body: "hello history",
910
+ timestamp: 1700000000000,
911
+ });
912
+ });
913
+
914
+ it("skips group messages without mention when requireMention is set", async () => {
915
+ const processMessage = vi.fn();
916
+ const event = createTestMessageEvent({
917
+ message: { id: "m-mention-1", type: "text", text: "hi there", quoteToken: "q-mention-1" },
918
+ source: { type: "group", groupId: "group-mention", userId: "user-mention" },
919
+ webhookEventId: "evt-mention-1",
920
+ });
921
+
922
+ await handleLineWebhookEvents(
923
+ [event],
924
+ createLineWebhookTestContext({
925
+ processMessage,
926
+ groupPolicy: "open",
927
+ requireMention: true,
928
+ }),
929
+ );
930
+
931
+ expect(processMessage).not.toHaveBeenCalled();
932
+ expect(buildLineMessageContextMock).not.toHaveBeenCalled();
933
+ });
934
+
935
+ it("processes group messages with bot mention when requireMention is set", async () => {
936
+ const processMessage = vi.fn();
937
+ // Simulate a LINE text message with mention.mentionees containing isSelf=true
938
+ const event = createTestMessageEvent({
939
+ message: {
940
+ id: "m-mention-2",
941
+ type: "text",
942
+ text: "@Bot hi there",
943
+ mention: {
944
+ mentionees: [{ index: 0, length: 4, type: "user", isSelf: true }],
945
+ },
946
+ } as unknown as MessageEvent["message"],
947
+ source: { type: "group", groupId: "group-mention", userId: "user-mention" },
948
+ webhookEventId: "evt-mention-2",
949
+ });
950
+
951
+ await handleLineWebhookEvents(
952
+ [event],
953
+ createLineWebhookTestContext({
954
+ processMessage,
955
+ groupPolicy: "open",
956
+ requireMention: true,
957
+ }),
958
+ );
959
+
960
+ expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
961
+ expect(processMessage).toHaveBeenCalledTimes(1);
962
+ });
963
+
964
+ it("processes group messages with @all mention when requireMention is set", async () => {
965
+ const event = createTestMessageEvent({
966
+ message: {
967
+ id: "m-mention-3",
968
+ type: "text",
969
+ text: "@All hi there",
970
+ mention: {
971
+ mentionees: [{ index: 0, length: 4, type: "all" }],
972
+ },
973
+ } as MessageEvent["message"],
974
+ source: { type: "group", groupId: "group-mention", userId: "user-mention" },
975
+ webhookEventId: "evt-mention-3",
976
+ });
977
+
978
+ await expectRequireMentionGroupMessageProcessed(event);
979
+ });
980
+
981
+ it("does not apply requireMention gating to DM messages", async () => {
982
+ const processMessage = vi.fn();
983
+ const event = createTestMessageEvent({
984
+ message: { id: "m-mention-dm", type: "text", text: "hi", quoteToken: "q-mention-dm" },
985
+ source: { type: "user", userId: "user-dm" },
986
+ webhookEventId: "evt-mention-dm",
987
+ });
988
+
989
+ await handleLineWebhookEvents(
990
+ [event],
991
+ createLineWebhookTestContext({
992
+ processMessage,
993
+ dmPolicy: "open",
994
+ requireMention: true,
995
+ }),
996
+ );
997
+
998
+ expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
999
+ expect(processMessage).toHaveBeenCalledTimes(1);
1000
+ });
1001
+
1002
+ it("allows non-text group messages through when requireMention is set (cannot detect mention)", async () => {
1003
+ // Image message -- LINE only carries mention metadata on text messages.
1004
+ const event = createTestMessageEvent({
1005
+ message: {
1006
+ id: "m-mention-img",
1007
+ type: "image",
1008
+ contentProvider: { type: "line" },
1009
+ quoteToken: "q-mention-img",
1010
+ },
1011
+ source: { type: "group", groupId: "group-1", userId: "user-img" },
1012
+ webhookEventId: "evt-mention-img",
1013
+ });
1014
+
1015
+ await expectRequireMentionGroupMessageProcessed(event);
1016
+ });
1017
+
1018
+ it("does not bypass mention gating when non-bot mention is present with control command", async () => {
1019
+ const processMessage = vi.fn();
1020
+ // Text message mentions another user (not bot) together with a control command.
1021
+ const event = createTestMessageEvent({
1022
+ message: {
1023
+ id: "m-mention-other",
1024
+ type: "text",
1025
+ text: "@other !status",
1026
+ mention: { mentionees: [{ index: 0, length: 6, type: "user", isSelf: false }] },
1027
+ } as unknown as MessageEvent["message"],
1028
+ source: { type: "group", groupId: "group-1", userId: "user-other" },
1029
+ webhookEventId: "evt-mention-other",
1030
+ });
1031
+
1032
+ await handleLineWebhookEvents(
1033
+ [event],
1034
+ createLineWebhookTestContext({
1035
+ processMessage,
1036
+ groupPolicy: "open",
1037
+ requireMention: true,
1038
+ }),
1039
+ );
1040
+
1041
+ // Should be skipped because there is a non-bot mention and the bot was not mentioned.
1042
+ expect(processMessage).not.toHaveBeenCalled();
1043
+ });
1044
+
1045
+ it("keeps replay cache committed after a non-retryable event failure", async () => {
1046
+ const processMessage = vi
1047
+ .fn()
1048
+ .mockRejectedValueOnce(new Error("transient failure"))
1049
+ .mockResolvedValueOnce(undefined);
1050
+ const event = createReplayMessageEvent({
1051
+ messageId: "m-fail-then-retry",
1052
+ groupId: "group-retry",
1053
+ userId: "user-retry",
1054
+ webhookEventId: "evt-fail-then-retry",
1055
+ isRedelivery: false,
1056
+ });
1057
+ const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache());
1058
+
1059
+ await expect(handleLineWebhookEvents([event], context)).rejects.toThrow("transient failure");
1060
+ await handleLineWebhookEvents([event], context);
1061
+
1062
+ expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
1063
+ expect(processMessage).toHaveBeenCalledTimes(1);
1064
+ expect(context.runtime.error).toHaveBeenCalledWith(
1065
+ expect.stringContaining("line: event handler failed: Error: transient failure"),
1066
+ );
1067
+ });
1068
+
1069
+ it("reopens replay after an explicit retryable event failure", async () => {
1070
+ const processMessage = vi
1071
+ .fn()
1072
+ .mockRejectedValueOnce(new LineRetryableWebhookError("retry me"))
1073
+ .mockResolvedValueOnce(undefined);
1074
+ const event = createReplayMessageEvent({
1075
+ messageId: "m-fail-then-retryable",
1076
+ groupId: "group-retry",
1077
+ userId: "user-retry",
1078
+ webhookEventId: "evt-fail-then-retryable",
1079
+ isRedelivery: false,
1080
+ });
1081
+ const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache());
1082
+
1083
+ await expect(handleLineWebhookEvents([event], context)).rejects.toThrow("retry me");
1084
+ await handleLineWebhookEvents([event], context);
1085
+
1086
+ expect(buildLineMessageContextMock).toHaveBeenCalledTimes(2);
1087
+ expect(processMessage).toHaveBeenCalledTimes(2);
1088
+ });
1089
+ });