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