@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,421 @@
1
+ import crypto from "node:crypto";
2
+ import { EventEmitter } from "node:events";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
5
+ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
6
+ import { WEBHOOK_IN_FLIGHT_DEFAULTS } from "openclaw/plugin-sdk/webhook-request-guards";
7
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import { createMockIncomingRequest } from "openclaw/plugin-sdk/test-env";
9
+
10
+ type LineNodeWebhookHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
11
+
12
+ const {
13
+ createLineBotMock,
14
+ createLineNodeWebhookHandlerMock,
15
+ registerWebhookTargetWithPluginRouteMock,
16
+ unregisterHttpMock,
17
+ } = vi.hoisted(() => ({
18
+ createLineBotMock: vi.fn(() => ({
19
+ account: { accountId: "default" },
20
+ handleWebhook: vi.fn(),
21
+ })),
22
+ createLineNodeWebhookHandlerMock: vi.fn<() => LineNodeWebhookHandler>(() =>
23
+ vi.fn<LineNodeWebhookHandler>(async () => {}),
24
+ ),
25
+ registerWebhookTargetWithPluginRouteMock: vi.fn(),
26
+ unregisterHttpMock: vi.fn(),
27
+ }));
28
+
29
+ let monitorLineProvider: typeof import("./monitor.js").monitorLineProvider;
30
+ let getLineRuntimeState: typeof import("./monitor.js").getLineRuntimeState;
31
+ let clearLineRuntimeStateForTests: typeof import("./monitor.js").clearLineRuntimeStateForTests;
32
+ let innerLineWebhookHandlerMock: ReturnType<typeof vi.fn<LineNodeWebhookHandler>>;
33
+
34
+ vi.mock("./bot.js", () => ({
35
+ createLineBot: createLineBotMock,
36
+ }));
37
+
38
+ vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
39
+ chunkMarkdownText: vi.fn(),
40
+ dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
41
+ }));
42
+
43
+ vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
44
+ const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime-env")>(
45
+ "openclaw/plugin-sdk/runtime-env",
46
+ );
47
+ return {
48
+ ...actual,
49
+ danger: (value: unknown) => String(value),
50
+ logVerbose: vi.fn(),
51
+ waitForAbortSignal: vi.fn(),
52
+ };
53
+ });
54
+
55
+ vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", () => ({
56
+ createChannelReplyPipeline: vi.fn(() => ({})),
57
+ }));
58
+
59
+ vi.mock("openclaw/plugin-sdk/webhook-ingress", async () => {
60
+ const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/webhook-ingress")>(
61
+ "openclaw/plugin-sdk/webhook-ingress",
62
+ );
63
+ return {
64
+ ...actual,
65
+ normalizePluginHttpPath: (path: string | undefined, fallback: string) => path ?? fallback,
66
+ registerWebhookTargetWithPluginRoute: registerWebhookTargetWithPluginRouteMock,
67
+ };
68
+ });
69
+
70
+ vi.mock("./webhook-node.js", async () => {
71
+ const actual = await vi.importActual<typeof import("./webhook-node.js")>("./webhook-node.js");
72
+ return {
73
+ ...actual,
74
+ createLineNodeWebhookHandler: createLineNodeWebhookHandlerMock,
75
+ };
76
+ });
77
+
78
+ vi.mock("./auto-reply-delivery.js", () => ({
79
+ deliverLineAutoReply: vi.fn(),
80
+ }));
81
+
82
+ vi.mock("./markdown-to-line.js", () => ({
83
+ processLineMessage: vi.fn(),
84
+ }));
85
+
86
+ vi.mock("./reply-chunks.js", () => ({
87
+ sendLineReplyChunks: vi.fn(),
88
+ }));
89
+
90
+ vi.mock("./send.js", () => ({
91
+ createFlexMessage: vi.fn(),
92
+ createImageMessage: vi.fn(),
93
+ createLocationMessage: vi.fn(),
94
+ createQuickReplyItems: vi.fn(),
95
+ createTextMessageWithQuickReplies: vi.fn(),
96
+ getUserDisplayName: vi.fn(),
97
+ pushMessageLine: vi.fn(),
98
+ pushMessagesLine: vi.fn(),
99
+ pushTextMessageWithQuickReplies: vi.fn(),
100
+ replyMessageLine: vi.fn(),
101
+ showLoadingAnimation: vi.fn(),
102
+ }));
103
+
104
+ vi.mock("./template-messages.js", () => ({
105
+ buildTemplateMessageFromPayload: vi.fn(),
106
+ }));
107
+
108
+ describe("monitorLineProvider lifecycle", () => {
109
+ beforeAll(async () => {
110
+ ({ monitorLineProvider, getLineRuntimeState, clearLineRuntimeStateForTests } =
111
+ await import("./monitor.js"));
112
+ });
113
+
114
+ beforeEach(() => {
115
+ clearLineRuntimeStateForTests();
116
+ createLineBotMock.mockReset();
117
+ createLineBotMock.mockImplementation(() => ({
118
+ account: { accountId: "default" },
119
+ handleWebhook: vi.fn(),
120
+ }));
121
+ innerLineWebhookHandlerMock = vi.fn<LineNodeWebhookHandler>(async () => {});
122
+ createLineNodeWebhookHandlerMock
123
+ .mockReset()
124
+ .mockImplementation(() => innerLineWebhookHandlerMock);
125
+ unregisterHttpMock.mockReset();
126
+ registerWebhookTargetWithPluginRouteMock.mockReset().mockImplementation((params) => {
127
+ const key = params.target.path.startsWith("/")
128
+ ? params.target.path
129
+ : `/${params.target.path}`;
130
+ const normalizedTarget = { ...params.target, path: key };
131
+ const existing = params.targetsByPath.get(key) ?? [];
132
+ params.targetsByPath.set(key, [...existing, normalizedTarget]);
133
+ return {
134
+ target: normalizedTarget,
135
+ unregister: () => {
136
+ unregisterHttpMock();
137
+ const updated = (params.targetsByPath.get(key) ?? []).filter(
138
+ (entry: unknown) => entry !== normalizedTarget,
139
+ );
140
+ if (updated.length > 0) {
141
+ params.targetsByPath.set(key, updated);
142
+ } else {
143
+ params.targetsByPath.delete(key);
144
+ }
145
+ },
146
+ };
147
+ });
148
+ });
149
+
150
+ const createRouteResponse = () => {
151
+ const resObj = {
152
+ statusCode: 0,
153
+ headersSent: false,
154
+ setHeader: vi.fn(),
155
+ end: vi.fn(() => {
156
+ resObj.headersSent = true;
157
+ }),
158
+ };
159
+ return resObj as unknown as ServerResponse & { end: ReturnType<typeof vi.fn> };
160
+ };
161
+
162
+ it("waits for abort before resolving", async () => {
163
+ const abort = new AbortController();
164
+ let resolved = false;
165
+
166
+ const task = monitorLineProvider({
167
+ channelAccessToken: "token",
168
+ channelSecret: "secret", // pragma: allowlist secret
169
+ config: {} as OpenClawConfig,
170
+ runtime: {} as RuntimeEnv,
171
+ abortSignal: abort.signal,
172
+ }).then((monitor) => {
173
+ resolved = true;
174
+ return monitor;
175
+ });
176
+
177
+ expect(registerWebhookTargetWithPluginRouteMock).toHaveBeenCalledTimes(1);
178
+ expect(registerWebhookTargetWithPluginRouteMock).toHaveBeenCalledWith(
179
+ expect.objectContaining({ route: expect.objectContaining({ auth: "plugin" }) }),
180
+ );
181
+ expect(resolved).toBe(false);
182
+
183
+ abort.abort();
184
+ await task;
185
+ expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
186
+ });
187
+
188
+ it("registers an account target without replacing existing route ownership", async () => {
189
+ const monitor = await monitorLineProvider({
190
+ channelAccessToken: "token",
191
+ channelSecret: "secret", // pragma: allowlist secret
192
+ accountId: "work",
193
+ config: {} as OpenClawConfig,
194
+ runtime: {} as RuntimeEnv,
195
+ });
196
+
197
+ const registration = registerWebhookTargetWithPluginRouteMock.mock.calls[0]?.[0];
198
+ expect(registration).toEqual(
199
+ expect.objectContaining({
200
+ target: expect.objectContaining({ accountId: "work", path: "/line/webhook" }),
201
+ route: expect.objectContaining({
202
+ accountId: "work",
203
+ auth: "plugin",
204
+ pluginId: "line",
205
+ }),
206
+ }),
207
+ );
208
+ expect(registration?.route).not.toHaveProperty("path");
209
+ expect(registration?.route).not.toHaveProperty("replaceExisting");
210
+ monitor.stop();
211
+ });
212
+
213
+ it("stops immediately when signal is already aborted", async () => {
214
+ const abort = new AbortController();
215
+ abort.abort();
216
+
217
+ await monitorLineProvider({
218
+ channelAccessToken: "token",
219
+ channelSecret: "secret", // pragma: allowlist secret
220
+ config: {} as OpenClawConfig,
221
+ runtime: {} as RuntimeEnv,
222
+ abortSignal: abort.signal,
223
+ });
224
+
225
+ expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
226
+ });
227
+
228
+ it("returns immediately without abort signal and stop is idempotent", async () => {
229
+ const monitor = await monitorLineProvider({
230
+ channelAccessToken: "token",
231
+ channelSecret: "secret", // pragma: allowlist secret
232
+ config: {} as OpenClawConfig,
233
+ runtime: {} as RuntimeEnv,
234
+ });
235
+
236
+ expect(unregisterHttpMock).not.toHaveBeenCalled();
237
+ monitor.stop();
238
+ monitor.stop();
239
+ expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
240
+ });
241
+
242
+ it("records startup state under configured defaultAccount when accountId is omitted", async () => {
243
+ const monitor = await monitorLineProvider({
244
+ channelAccessToken: "token",
245
+ channelSecret: "secret", // pragma: allowlist secret
246
+ config: {
247
+ channels: {
248
+ line: {
249
+ defaultAccount: "work",
250
+ accounts: {
251
+ work: {
252
+ channelAccessToken: "work-token",
253
+ channelSecret: "work-secret",
254
+ },
255
+ },
256
+ },
257
+ },
258
+ } as OpenClawConfig,
259
+ runtime: {} as RuntimeEnv,
260
+ });
261
+
262
+ expect(getLineRuntimeState("work")).toEqual(
263
+ expect.objectContaining({
264
+ running: true,
265
+ }),
266
+ );
267
+ expect(getLineRuntimeState("default")).toBeUndefined();
268
+
269
+ monitor.stop();
270
+ });
271
+
272
+ it("dispatches shared-path webhook posts to the account matching the signature", async () => {
273
+ const firstMonitor = await monitorLineProvider({
274
+ channelAccessToken: "first-token",
275
+ channelSecret: "first-secret", // pragma: allowlist secret
276
+ accountId: "first",
277
+ config: {} as OpenClawConfig,
278
+ runtime: {} as RuntimeEnv,
279
+ });
280
+ const secondMonitor = await monitorLineProvider({
281
+ channelAccessToken: "second-token",
282
+ channelSecret: "second-secret", // pragma: allowlist secret
283
+ accountId: "second",
284
+ config: {} as OpenClawConfig,
285
+ runtime: {} as RuntimeEnv,
286
+ });
287
+
288
+ const route = registerWebhookTargetWithPluginRouteMock.mock.calls[0]?.[0]?.route as
289
+ | { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }
290
+ | undefined;
291
+ expect(route).toBeDefined();
292
+
293
+ const payload = JSON.stringify({ events: [{ type: "message" }] });
294
+ const signature = crypto.createHmac("SHA256", "second-secret").update(payload).digest("base64");
295
+ const req = Object.assign(createMockIncomingRequest([payload]), {
296
+ method: "POST",
297
+ headers: { "x-line-signature": signature },
298
+ }) as unknown as IncomingMessage;
299
+ const res = createRouteResponse();
300
+
301
+ await route!.handler(req, res);
302
+
303
+ const firstBot = createLineBotMock.mock.results[0]?.value as {
304
+ handleWebhook: ReturnType<typeof vi.fn>;
305
+ };
306
+ const secondBot = createLineBotMock.mock.results[1]?.value as {
307
+ handleWebhook: ReturnType<typeof vi.fn>;
308
+ };
309
+ expect(res.statusCode).toBe(200);
310
+ expect(firstBot.handleWebhook).not.toHaveBeenCalled();
311
+ expect(secondBot.handleWebhook).toHaveBeenCalledTimes(1);
312
+
313
+ firstMonitor.stop();
314
+ secondMonitor.stop();
315
+ });
316
+
317
+ it("rejects ambiguous shared-path webhook signatures", async () => {
318
+ const firstMonitor = await monitorLineProvider({
319
+ channelAccessToken: "first-token",
320
+ channelSecret: "shared-secret", // pragma: allowlist secret
321
+ accountId: "first",
322
+ config: {} as OpenClawConfig,
323
+ runtime: {} as RuntimeEnv,
324
+ });
325
+ const secondMonitor = await monitorLineProvider({
326
+ channelAccessToken: "second-token",
327
+ channelSecret: "shared-secret", // pragma: allowlist secret
328
+ accountId: "second",
329
+ config: {} as OpenClawConfig,
330
+ runtime: {} as RuntimeEnv,
331
+ });
332
+
333
+ const route = registerWebhookTargetWithPluginRouteMock.mock.calls[0]?.[0]?.route as
334
+ | { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }
335
+ | undefined;
336
+ expect(route).toBeDefined();
337
+
338
+ const payload = JSON.stringify({ events: [{ type: "message" }] });
339
+ const signature = crypto.createHmac("SHA256", "shared-secret").update(payload).digest("base64");
340
+ const req = Object.assign(createMockIncomingRequest([payload]), {
341
+ method: "POST",
342
+ headers: { "x-line-signature": signature },
343
+ }) as unknown as IncomingMessage;
344
+ const res = createRouteResponse();
345
+
346
+ await route!.handler(req, res);
347
+
348
+ const firstBot = createLineBotMock.mock.results[0]?.value as {
349
+ handleWebhook: ReturnType<typeof vi.fn>;
350
+ };
351
+ const secondBot = createLineBotMock.mock.results[1]?.value as {
352
+ handleWebhook: ReturnType<typeof vi.fn>;
353
+ };
354
+ expect(res.statusCode).toBe(401);
355
+ expect(res.end).toHaveBeenCalledWith(JSON.stringify({ error: "Ambiguous webhook target" }));
356
+ expect(firstBot.handleWebhook).not.toHaveBeenCalled();
357
+ expect(secondBot.handleWebhook).not.toHaveBeenCalled();
358
+
359
+ firstMonitor.stop();
360
+ secondMonitor.stop();
361
+ });
362
+
363
+ it("rejects webhook requests above the shared in-flight limit before body handling", async () => {
364
+ const limit = WEBHOOK_IN_FLIGHT_DEFAULTS.maxInFlightPerKey;
365
+ const heldRequests: Array<EventEmitter & { destroy: () => void }> = [];
366
+
367
+ const monitor = await monitorLineProvider({
368
+ channelAccessToken: "token",
369
+ channelSecret: "secret", // pragma: allowlist secret
370
+ config: {} as OpenClawConfig,
371
+ runtime: {} as RuntimeEnv,
372
+ });
373
+
374
+ const route = registerWebhookTargetWithPluginRouteMock.mock.calls[0]?.[0]?.route as
375
+ | { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }
376
+ | undefined;
377
+ expect(route).toBeDefined();
378
+ const createHeldPostRequest = () => {
379
+ const req = Object.assign(new EventEmitter(), {
380
+ destroyed: false,
381
+ destroy(this: EventEmitter & { destroyed: boolean }) {
382
+ this.destroyed = true;
383
+ this.emit("close");
384
+ },
385
+ });
386
+ heldRequests.push(req);
387
+ return Object.assign(req, {
388
+ method: "POST",
389
+ headers: { "x-line-signature": "pending" },
390
+ }) as unknown as IncomingMessage;
391
+ };
392
+ const createSignedPostRequest = () => {
393
+ const payload = JSON.stringify({ events: [{ type: "message" }] });
394
+ const signature = crypto.createHmac("SHA256", "secret").update(payload).digest("base64");
395
+ const req = createMockIncomingRequest([payload]);
396
+ return Object.assign(req, {
397
+ method: "POST",
398
+ headers: { "x-line-signature": signature },
399
+ }) as unknown as IncomingMessage;
400
+ };
401
+
402
+ const firstRequests = Array.from({ length: limit }, () =>
403
+ route!.handler(createHeldPostRequest(), createRouteResponse()),
404
+ );
405
+ await new Promise((resolve) => setImmediate(resolve));
406
+
407
+ const overflowResponse = createRouteResponse();
408
+ await route!.handler(createSignedPostRequest(), overflowResponse);
409
+
410
+ const bot = createLineBotMock.mock.results[0]?.value as {
411
+ handleWebhook: ReturnType<typeof vi.fn>;
412
+ };
413
+ expect(bot.handleWebhook).not.toHaveBeenCalled();
414
+ expect(overflowResponse.statusCode).toBe(429);
415
+ expect(overflowResponse.end).toHaveBeenCalledWith("Too Many Requests");
416
+
417
+ heldRequests.splice(0).forEach((req) => req.destroy());
418
+ await Promise.allSettled(firstRequests);
419
+ monitor.stop();
420
+ });
421
+ });
@@ -0,0 +1 @@
1
+ export { monitorLineProvider } from "./monitor.js";