@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.
- package/api.ts +11 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +5 -0
- package/index.ts +54 -0
- package/openclaw.plugin.json +342 -0
- package/package.json +58 -0
- package/runtime-api.ts +187 -0
- package/secret-contract-api.ts +4 -0
- package/setup-api.ts +2 -0
- package/setup-entry.ts +9 -0
- package/src/account-helpers.ts +16 -0
- package/src/accounts.test.ts +290 -0
- package/src/accounts.ts +187 -0
- package/src/actions.ts +61 -0
- package/src/auto-reply-delivery.test.ts +248 -0
- package/src/auto-reply-delivery.ts +200 -0
- package/src/bindings.ts +65 -0
- package/src/bot-access.ts +48 -0
- package/src/bot-handlers.test.ts +1089 -0
- package/src/bot-handlers.ts +642 -0
- package/src/bot-message-context.test.ts +405 -0
- package/src/bot-message-context.ts +581 -0
- package/src/bot.ts +70 -0
- package/src/card-command.ts +347 -0
- package/src/channel-access-token.ts +14 -0
- package/src/channel-api.ts +17 -0
- package/src/channel-setup-status.contract.test.ts +70 -0
- package/src/channel-shared.ts +48 -0
- package/src/channel.logout.test.ts +145 -0
- package/src/channel.runtime.ts +3 -0
- package/src/channel.sendPayload.test.ts +514 -0
- package/src/channel.setup.ts +11 -0
- package/src/channel.status.test.ts +63 -0
- package/src/channel.ts +154 -0
- package/src/config-adapter.ts +29 -0
- package/src/config-schema.ts +57 -0
- package/src/download.test.ts +133 -0
- package/src/download.ts +87 -0
- package/src/flex-templates/basic-cards.ts +395 -0
- package/src/flex-templates/common.ts +20 -0
- package/src/flex-templates/media-control-cards.ts +555 -0
- package/src/flex-templates/message.ts +13 -0
- package/src/flex-templates/schedule-cards.ts +467 -0
- package/src/flex-templates/types.ts +22 -0
- package/src/flex-templates.ts +32 -0
- package/src/gateway.ts +129 -0
- package/src/group-keys.test.ts +123 -0
- package/src/group-keys.ts +65 -0
- package/src/group-policy.ts +22 -0
- package/src/markdown-to-line.test.ts +348 -0
- package/src/markdown-to-line.ts +416 -0
- package/src/message-cards.test.ts +204 -0
- package/src/monitor.lifecycle.test.ts +421 -0
- package/src/monitor.runtime.ts +1 -0
- package/src/monitor.ts +506 -0
- package/src/outbound-media.test.ts +189 -0
- package/src/outbound-media.ts +120 -0
- package/src/outbound.runtime.ts +12 -0
- package/src/outbound.ts +356 -0
- package/src/probe.contract.test.ts +9 -0
- package/src/probe.runtime.ts +1 -0
- package/src/probe.ts +34 -0
- package/src/quick-reply-fallback.ts +10 -0
- package/src/reply-chunks.test.ts +179 -0
- package/src/reply-chunks.ts +110 -0
- package/src/reply-payload-transform.test.ts +387 -0
- package/src/reply-payload-transform.ts +317 -0
- package/src/rich-menu.test.ts +310 -0
- package/src/rich-menu.ts +326 -0
- package/src/runtime.ts +32 -0
- package/src/send.test.ts +346 -0
- package/src/send.ts +489 -0
- package/src/setup-core.ts +149 -0
- package/src/setup-runtime-api.ts +9 -0
- package/src/setup-surface.test.ts +474 -0
- package/src/setup-surface.ts +227 -0
- package/src/signature.test.ts +34 -0
- package/src/signature.ts +24 -0
- package/src/status.ts +37 -0
- package/src/template-messages.ts +333 -0
- package/src/types.ts +128 -0
- package/src/webhook-node.test.ts +513 -0
- package/src/webhook-node.ts +131 -0
- package/src/webhook-utils.ts +10 -0
- package/src/webhook.ts +111 -0
- 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";
|