@openclaw/zalo 2026.3.13 → 2026.5.1-beta.2
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/README.md +1 -1
- package/api.ts +9 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +5 -0
- package/index.test.ts +15 -0
- package/index.ts +16 -13
- package/openclaw.plugin.json +514 -1
- package/package.json +31 -5
- package/runtime-api.test.ts +17 -0
- package/runtime-api.ts +75 -0
- package/secret-contract-api.ts +5 -0
- package/setup-api.ts +34 -0
- package/setup-entry.ts +13 -0
- package/src/accounts.test.ts +70 -0
- package/src/accounts.ts +19 -19
- package/src/actions.runtime.ts +5 -0
- package/src/actions.test.ts +32 -0
- package/src/actions.ts +20 -14
- package/src/api.test.ts +93 -2
- package/src/api.ts +29 -2
- package/src/approval-auth.test.ts +17 -0
- package/src/approval-auth.ts +25 -0
- package/src/channel.directory.test.ts +19 -6
- package/src/channel.runtime.ts +93 -0
- package/src/channel.startup.test.ts +26 -19
- package/src/channel.ts +228 -336
- package/src/config-schema.ts +3 -3
- package/src/group-access.ts +4 -3
- package/src/monitor.group-policy.test.ts +0 -12
- package/src/monitor.image.polling.test.ts +110 -0
- package/src/monitor.lifecycle.test.ts +41 -22
- package/src/monitor.pairing.lifecycle.test.ts +141 -0
- package/src/monitor.polling.media-reply.test.ts +425 -0
- package/src/monitor.reply-once.lifecycle.test.ts +171 -0
- package/src/monitor.ts +460 -206
- package/src/monitor.types.ts +4 -0
- package/src/monitor.webhook.test.ts +392 -62
- package/src/monitor.webhook.ts +73 -36
- package/src/outbound-media.test.ts +182 -0
- package/src/outbound-media.ts +241 -0
- package/src/outbound-payload.contract.test.ts +45 -0
- package/src/probe.ts +1 -1
- package/src/proxy.ts +1 -1
- package/src/runtime-api.ts +75 -0
- package/src/runtime-support.ts +91 -0
- package/src/runtime.ts +6 -3
- package/src/secret-contract.ts +109 -0
- package/src/secret-input.ts +1 -9
- package/src/send.test.ts +120 -0
- package/src/send.ts +15 -13
- package/src/session-route.ts +32 -0
- package/src/setup-allow-from.ts +94 -0
- package/src/setup-core.ts +149 -0
- package/src/{onboarding.status.test.ts → setup-status.test.ts} +13 -4
- package/src/setup-surface.test.ts +175 -0
- package/src/{onboarding.ts → setup-surface.ts} +59 -177
- package/src/status-issues.test.ts +2 -14
- package/src/status-issues.ts +8 -2
- package/src/test-support/lifecycle-test-support.ts +413 -0
- package/src/test-support/monitor-mocks-test-support.ts +209 -0
- package/src/token.test.ts +15 -0
- package/src/token.ts +8 -17
- package/src/types.ts +2 -2
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -101
- package/src/channel.sendpayload.test.ts +0 -44
|
@@ -1,34 +1,30 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
|
|
1
|
+
import type { RequestListener } from "node:http";
|
|
2
|
+
import {
|
|
3
|
+
createEmptyPluginRegistry,
|
|
4
|
+
setActivePluginRegistry,
|
|
5
|
+
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
6
|
+
import { withServer } from "openclaw/plugin-sdk/test-env";
|
|
4
7
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
8
|
+
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
|
9
|
+
import { handleZaloWebhookRequest } from "./monitor.js";
|
|
10
|
+
import type { ZaloRuntimeEnv } from "./monitor.types.js";
|
|
7
11
|
import {
|
|
8
12
|
clearZaloWebhookSecurityStateForTest,
|
|
9
13
|
getZaloWebhookRateLimitStateSizeForTest,
|
|
10
14
|
getZaloWebhookStatusCounterSizeForTest,
|
|
11
|
-
handleZaloWebhookRequest,
|
|
15
|
+
handleZaloWebhookRequest as handleZaloWebhookRequestInternal,
|
|
12
16
|
registerZaloWebhookTarget,
|
|
13
|
-
|
|
17
|
+
type ZaloWebhookProcessUpdate,
|
|
18
|
+
ZaloRetryableWebhookError,
|
|
19
|
+
} from "./monitor.webhook.js";
|
|
20
|
+
import {
|
|
21
|
+
createImageLifecycleCore,
|
|
22
|
+
createImageUpdate,
|
|
23
|
+
createTextUpdate,
|
|
24
|
+
expectImageLifecycleDelivery,
|
|
25
|
+
postWebhookReplay,
|
|
26
|
+
} from "./test-support/lifecycle-test-support.js";
|
|
14
27
|
import type { ResolvedZaloAccount } from "./types.js";
|
|
15
|
-
|
|
16
|
-
async function withServer(handler: RequestListener, fn: (baseUrl: string) => Promise<void>) {
|
|
17
|
-
const server = createServer(handler);
|
|
18
|
-
await new Promise<void>((resolve) => {
|
|
19
|
-
server.listen(0, "127.0.0.1", () => resolve());
|
|
20
|
-
});
|
|
21
|
-
const address = server.address() as AddressInfo | null;
|
|
22
|
-
if (!address) {
|
|
23
|
-
throw new Error("missing server address");
|
|
24
|
-
}
|
|
25
|
-
try {
|
|
26
|
-
await fn(`http://127.0.0.1:${address.port}`);
|
|
27
|
-
} finally {
|
|
28
|
-
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
28
|
const DEFAULT_ACCOUNT: ResolvedZaloAccount = {
|
|
33
29
|
accountId: "default",
|
|
34
30
|
enabled: true,
|
|
@@ -37,13 +33,19 @@ const DEFAULT_ACCOUNT: ResolvedZaloAccount = {
|
|
|
37
33
|
config: {},
|
|
38
34
|
};
|
|
39
35
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
36
|
+
function createWebhookRequestHandler(processUpdate?: ZaloWebhookProcessUpdate): RequestListener {
|
|
37
|
+
return async (req, res) => {
|
|
38
|
+
const handled = processUpdate
|
|
39
|
+
? await handleZaloWebhookRequestInternal(req, res, processUpdate)
|
|
40
|
+
: await handleZaloWebhookRequest(req, res);
|
|
41
|
+
if (!handled) {
|
|
42
|
+
res.statusCode = 404;
|
|
43
|
+
res.end("not found");
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const webhookRequestHandler = createWebhookRequestHandler();
|
|
47
49
|
|
|
48
50
|
function registerTarget(params: {
|
|
49
51
|
path: string;
|
|
@@ -52,16 +54,20 @@ function registerTarget(params: {
|
|
|
52
54
|
account?: ResolvedZaloAccount;
|
|
53
55
|
config?: OpenClawConfig;
|
|
54
56
|
core?: PluginRuntime;
|
|
57
|
+
runtime?: Partial<ZaloRuntimeEnv>;
|
|
55
58
|
}): () => void {
|
|
56
59
|
return registerZaloWebhookTarget({
|
|
57
60
|
token: "tok",
|
|
58
61
|
account: params.account ?? DEFAULT_ACCOUNT,
|
|
59
62
|
config: params.config ?? ({} as OpenClawConfig),
|
|
60
|
-
runtime: {},
|
|
63
|
+
runtime: (params.runtime ?? {}) as ZaloRuntimeEnv,
|
|
61
64
|
core: params.core ?? ({} as PluginRuntime),
|
|
62
65
|
secret: params.secret ?? "secret",
|
|
63
66
|
path: params.path,
|
|
67
|
+
webhookUrl: `https://example.com${params.path}`,
|
|
68
|
+
webhookPath: params.path,
|
|
64
69
|
mediaMaxMb: 5,
|
|
70
|
+
canHostMedia: true,
|
|
65
71
|
statusSink: params.statusSink,
|
|
66
72
|
});
|
|
67
73
|
}
|
|
@@ -121,31 +127,48 @@ async function postUntilRateLimited(params: {
|
|
|
121
127
|
return false;
|
|
122
128
|
}
|
|
123
129
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
130
|
+
async function postWebhookJson(params: {
|
|
131
|
+
baseUrl: string;
|
|
132
|
+
path: string;
|
|
133
|
+
secret: string;
|
|
134
|
+
payload: unknown;
|
|
135
|
+
}) {
|
|
136
|
+
return fetch(`${params.baseUrl}${params.path}`, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: {
|
|
139
|
+
"x-bot-api-secret-token": params.secret,
|
|
140
|
+
"content-type": "application/json",
|
|
141
|
+
},
|
|
142
|
+
body: JSON.stringify(params.payload),
|
|
128
143
|
});
|
|
144
|
+
}
|
|
129
145
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
146
|
+
async function expectTwoWebhookPostsOk(params: {
|
|
147
|
+
baseUrl: string;
|
|
148
|
+
first: { path: string; secret: string; payload: unknown };
|
|
149
|
+
second: { path: string; secret: string; payload: unknown };
|
|
150
|
+
}) {
|
|
151
|
+
const first = await postWebhookJson({
|
|
152
|
+
baseUrl: params.baseUrl,
|
|
153
|
+
path: params.first.path,
|
|
154
|
+
secret: params.first.secret,
|
|
155
|
+
payload: params.first.payload,
|
|
156
|
+
});
|
|
157
|
+
const second = await postWebhookJson({
|
|
158
|
+
baseUrl: params.baseUrl,
|
|
159
|
+
path: params.second.path,
|
|
160
|
+
secret: params.second.secret,
|
|
161
|
+
payload: params.second.payload,
|
|
162
|
+
});
|
|
135
163
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
pluginId: "zalo",
|
|
140
|
-
path: "/hook",
|
|
141
|
-
source: "zalo-webhook",
|
|
142
|
-
}),
|
|
143
|
-
);
|
|
164
|
+
expect(first.status).toBe(200);
|
|
165
|
+
expect(second.status).toBe(200);
|
|
166
|
+
}
|
|
144
167
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
168
|
+
describe("handleZaloWebhookRequest", () => {
|
|
169
|
+
afterEach(() => {
|
|
170
|
+
clearZaloWebhookSecurityStateForTest();
|
|
171
|
+
setActivePluginRegistry(createEmptyPluginRegistry());
|
|
149
172
|
});
|
|
150
173
|
|
|
151
174
|
it("returns 400 for non-object payloads", async () => {
|
|
@@ -218,16 +241,198 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
218
241
|
}
|
|
219
242
|
});
|
|
220
243
|
|
|
221
|
-
it("deduplicates webhook replay
|
|
244
|
+
it("deduplicates webhook replay for the same event origin", async () => {
|
|
222
245
|
const sink = vi.fn();
|
|
223
246
|
const unregister = registerTarget({ path: "/hook-replay", statusSink: sink });
|
|
247
|
+
const payload = createTextUpdate({
|
|
248
|
+
messageId: "msg-replay-1",
|
|
249
|
+
userId: "123",
|
|
250
|
+
userName: "",
|
|
251
|
+
chatId: "123",
|
|
252
|
+
text: "hello",
|
|
253
|
+
});
|
|
224
254
|
|
|
255
|
+
try {
|
|
256
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
257
|
+
const { first, replay } = await postWebhookReplay({
|
|
258
|
+
baseUrl,
|
|
259
|
+
path: "/hook-replay",
|
|
260
|
+
secret: "secret",
|
|
261
|
+
payload,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(first.status).toBe(200);
|
|
265
|
+
expect(replay.status).toBe(200);
|
|
266
|
+
expect(sink).toHaveBeenCalledTimes(1);
|
|
267
|
+
});
|
|
268
|
+
} finally {
|
|
269
|
+
unregister();
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("allows a retry after processUpdate throws a retryable replay error", async () => {
|
|
274
|
+
const error = vi.fn();
|
|
275
|
+
const unregister = registerTarget({
|
|
276
|
+
path: "/hook-retry-after-failure",
|
|
277
|
+
runtime: { error },
|
|
278
|
+
});
|
|
279
|
+
const payload = createTextUpdate({
|
|
280
|
+
messageId: "msg-retry-after-failure-1",
|
|
281
|
+
userId: "123",
|
|
282
|
+
userName: "",
|
|
283
|
+
chatId: "123",
|
|
284
|
+
text: "hello",
|
|
285
|
+
});
|
|
286
|
+
let attempts = 0;
|
|
287
|
+
const processUpdate = vi.fn<ZaloWebhookProcessUpdate>(async () => {
|
|
288
|
+
attempts += 1;
|
|
289
|
+
if (attempts === 1) {
|
|
290
|
+
throw new ZaloRetryableWebhookError("boom");
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
await withServer(createWebhookRequestHandler(processUpdate), async (baseUrl) => {
|
|
296
|
+
const first = await postWebhookJson({
|
|
297
|
+
baseUrl,
|
|
298
|
+
path: "/hook-retry-after-failure",
|
|
299
|
+
secret: "secret",
|
|
300
|
+
payload,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(first.status).toBe(200);
|
|
304
|
+
await vi.waitFor(() => expect(error).toHaveBeenCalledTimes(1));
|
|
305
|
+
|
|
306
|
+
const second = await postWebhookJson({
|
|
307
|
+
baseUrl,
|
|
308
|
+
path: "/hook-retry-after-failure",
|
|
309
|
+
secret: "secret",
|
|
310
|
+
payload,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
expect(second.status).toBe(200);
|
|
314
|
+
await vi.waitFor(() => expect(processUpdate).toHaveBeenCalledTimes(2));
|
|
315
|
+
});
|
|
316
|
+
} finally {
|
|
317
|
+
unregister();
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("keeps replay dedupe isolated per authenticated target", async () => {
|
|
322
|
+
const sinkA = vi.fn();
|
|
323
|
+
const sinkB = vi.fn();
|
|
324
|
+
const unregisterA = registerTarget({
|
|
325
|
+
path: "/hook-replay-scope",
|
|
326
|
+
secret: "secret-a",
|
|
327
|
+
statusSink: sinkA,
|
|
328
|
+
});
|
|
329
|
+
const unregisterB = registerTarget({
|
|
330
|
+
path: "/hook-replay-scope",
|
|
331
|
+
secret: "secret-b",
|
|
332
|
+
statusSink: sinkB,
|
|
333
|
+
account: {
|
|
334
|
+
...DEFAULT_ACCOUNT,
|
|
335
|
+
accountId: "work",
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
const payload = createTextUpdate({
|
|
339
|
+
messageId: "msg-replay-scope-1",
|
|
340
|
+
userId: "123",
|
|
341
|
+
userName: "",
|
|
342
|
+
chatId: "123",
|
|
343
|
+
text: "hello",
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
348
|
+
await expectTwoWebhookPostsOk({
|
|
349
|
+
baseUrl,
|
|
350
|
+
first: { path: "/hook-replay-scope", secret: "secret-a", payload },
|
|
351
|
+
second: { path: "/hook-replay-scope", secret: "secret-b", payload },
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
expect(sinkA).toHaveBeenCalledTimes(1);
|
|
356
|
+
expect(sinkB).toHaveBeenCalledTimes(1);
|
|
357
|
+
} finally {
|
|
358
|
+
unregisterA();
|
|
359
|
+
unregisterB();
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("does not collide replay dedupe across different chats", async () => {
|
|
364
|
+
const sink = vi.fn();
|
|
365
|
+
const unregister = registerTarget({ path: "/hook-replay-chat-scope", statusSink: sink });
|
|
366
|
+
const firstPayload = createTextUpdate({
|
|
367
|
+
messageId: "msg-replay-chat-1",
|
|
368
|
+
userId: "123",
|
|
369
|
+
userName: "",
|
|
370
|
+
chatId: "chat-a",
|
|
371
|
+
text: "hello from a",
|
|
372
|
+
});
|
|
373
|
+
const secondPayload = createTextUpdate({
|
|
374
|
+
messageId: "msg-replay-chat-1",
|
|
375
|
+
userId: "123",
|
|
376
|
+
userName: "",
|
|
377
|
+
chatId: "chat-b",
|
|
378
|
+
text: "hello from b",
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
383
|
+
await expectTwoWebhookPostsOk({
|
|
384
|
+
baseUrl,
|
|
385
|
+
first: { path: "/hook-replay-chat-scope", secret: "secret", payload: firstPayload },
|
|
386
|
+
second: { path: "/hook-replay-chat-scope", secret: "secret", payload: secondPayload },
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
expect(sink).toHaveBeenCalledTimes(2);
|
|
391
|
+
} finally {
|
|
392
|
+
unregister();
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("does not collide replay dedupe across different senders in the same chat", async () => {
|
|
397
|
+
const sink = vi.fn();
|
|
398
|
+
const unregister = registerTarget({ path: "/hook-replay-sender-scope", statusSink: sink });
|
|
399
|
+
const firstPayload = createTextUpdate({
|
|
400
|
+
messageId: "msg-replay-sender-1",
|
|
401
|
+
userId: "user-a",
|
|
402
|
+
userName: "",
|
|
403
|
+
chatId: "chat-shared",
|
|
404
|
+
text: "hello from user a",
|
|
405
|
+
});
|
|
406
|
+
const secondPayload = createTextUpdate({
|
|
407
|
+
messageId: "msg-replay-sender-1",
|
|
408
|
+
userId: "user-b",
|
|
409
|
+
userName: "",
|
|
410
|
+
chatId: "chat-shared",
|
|
411
|
+
text: "hello from user b",
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
416
|
+
await expectTwoWebhookPostsOk({
|
|
417
|
+
baseUrl,
|
|
418
|
+
first: { path: "/hook-replay-sender-scope", secret: "secret", payload: firstPayload },
|
|
419
|
+
second: { path: "/hook-replay-sender-scope", secret: "secret", payload: secondPayload },
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
expect(sink).toHaveBeenCalledTimes(2);
|
|
424
|
+
} finally {
|
|
425
|
+
unregister();
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("does not throw when replay metadata is partially missing", async () => {
|
|
430
|
+
const sink = vi.fn();
|
|
431
|
+
const unregister = registerTarget({ path: "/hook-replay-partial", statusSink: sink });
|
|
225
432
|
const payload = {
|
|
226
433
|
event_name: "message.text.received",
|
|
227
434
|
message: {
|
|
228
|
-
|
|
229
|
-
chat: { id: "123", chat_type: "PRIVATE" },
|
|
230
|
-
message_id: "msg-replay-1",
|
|
435
|
+
message_id: "msg-replay-partial-1",
|
|
231
436
|
date: Math.floor(Date.now() / 1000),
|
|
232
437
|
text: "hello",
|
|
233
438
|
},
|
|
@@ -235,7 +440,7 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
235
440
|
|
|
236
441
|
try {
|
|
237
442
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
238
|
-
const
|
|
443
|
+
const response = await fetch(`${baseUrl}/hook-replay-partial`, {
|
|
239
444
|
method: "POST",
|
|
240
445
|
headers: {
|
|
241
446
|
"x-bot-api-secret-token": "secret",
|
|
@@ -243,7 +448,126 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
243
448
|
},
|
|
244
449
|
body: JSON.stringify(payload),
|
|
245
450
|
});
|
|
246
|
-
|
|
451
|
+
|
|
452
|
+
expect(response.status).toBe(200);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
expect(sink).toHaveBeenCalledTimes(1);
|
|
456
|
+
} finally {
|
|
457
|
+
unregister();
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("keeps replay dedupe isolated when path/account values collide under colon-joined keys", async () => {
|
|
462
|
+
const sinkA = vi.fn();
|
|
463
|
+
const sinkB = vi.fn();
|
|
464
|
+
// Old key format `${path}:${accountId}:${event_name}:${messageId}` would collide for these two targets.
|
|
465
|
+
const unregisterA = registerTarget({
|
|
466
|
+
path: "/hook-replay-collision:a",
|
|
467
|
+
secret: "secret-a",
|
|
468
|
+
statusSink: sinkA,
|
|
469
|
+
account: {
|
|
470
|
+
...DEFAULT_ACCOUNT,
|
|
471
|
+
accountId: "team",
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
const unregisterB = registerTarget({
|
|
475
|
+
path: "/hook-replay-collision",
|
|
476
|
+
secret: "secret-b",
|
|
477
|
+
statusSink: sinkB,
|
|
478
|
+
account: {
|
|
479
|
+
...DEFAULT_ACCOUNT,
|
|
480
|
+
accountId: "a:team",
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
const payload = createTextUpdate({
|
|
484
|
+
messageId: "msg-replay-collision-1",
|
|
485
|
+
userId: "123",
|
|
486
|
+
userName: "",
|
|
487
|
+
chatId: "123",
|
|
488
|
+
text: "hello",
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
493
|
+
await expectTwoWebhookPostsOk({
|
|
494
|
+
baseUrl,
|
|
495
|
+
first: { path: "/hook-replay-collision:a", secret: "secret-a", payload },
|
|
496
|
+
second: { path: "/hook-replay-collision", secret: "secret-b", payload },
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
expect(sinkA).toHaveBeenCalledTimes(1);
|
|
501
|
+
expect(sinkB).toHaveBeenCalledTimes(1);
|
|
502
|
+
} finally {
|
|
503
|
+
unregisterA();
|
|
504
|
+
unregisterB();
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("keeps replay dedupe isolated across different webhook paths", async () => {
|
|
509
|
+
const sinkA = vi.fn();
|
|
510
|
+
const sinkB = vi.fn();
|
|
511
|
+
const sharedSecret = "secret";
|
|
512
|
+
const unregisterA = registerTarget({
|
|
513
|
+
path: "/hook-replay-scope-a",
|
|
514
|
+
secret: sharedSecret,
|
|
515
|
+
statusSink: sinkA,
|
|
516
|
+
});
|
|
517
|
+
const unregisterB = registerTarget({
|
|
518
|
+
path: "/hook-replay-scope-b",
|
|
519
|
+
secret: sharedSecret,
|
|
520
|
+
statusSink: sinkB,
|
|
521
|
+
});
|
|
522
|
+
const payload = createTextUpdate({
|
|
523
|
+
messageId: "msg-replay-cross-path-1",
|
|
524
|
+
userId: "123",
|
|
525
|
+
userName: "",
|
|
526
|
+
chatId: "123",
|
|
527
|
+
text: "hello",
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
532
|
+
await expectTwoWebhookPostsOk({
|
|
533
|
+
baseUrl,
|
|
534
|
+
first: { path: "/hook-replay-scope-a", secret: sharedSecret, payload },
|
|
535
|
+
second: { path: "/hook-replay-scope-b", secret: sharedSecret, payload },
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
expect(sinkA).toHaveBeenCalledTimes(1);
|
|
540
|
+
expect(sinkB).toHaveBeenCalledTimes(1);
|
|
541
|
+
} finally {
|
|
542
|
+
unregisterA();
|
|
543
|
+
unregisterB();
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("downloads inbound image media from webhook photo_url and preserves display_name", async () => {
|
|
548
|
+
const {
|
|
549
|
+
core,
|
|
550
|
+
finalizeInboundContextMock,
|
|
551
|
+
recordInboundSessionMock,
|
|
552
|
+
fetchRemoteMediaMock,
|
|
553
|
+
saveMediaBufferMock,
|
|
554
|
+
} = createImageLifecycleCore();
|
|
555
|
+
const unregister = registerTarget({
|
|
556
|
+
path: "/hook-image",
|
|
557
|
+
core,
|
|
558
|
+
account: {
|
|
559
|
+
...DEFAULT_ACCOUNT,
|
|
560
|
+
config: {
|
|
561
|
+
dmPolicy: "open",
|
|
562
|
+
allowFrom: ["*"],
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
const payload = createImageUpdate();
|
|
567
|
+
|
|
568
|
+
try {
|
|
569
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
570
|
+
const response = await fetch(`${baseUrl}/hook-image`, {
|
|
247
571
|
method: "POST",
|
|
248
572
|
headers: {
|
|
249
573
|
"x-bot-api-secret-token": "secret",
|
|
@@ -252,13 +576,19 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
252
576
|
body: JSON.stringify(payload),
|
|
253
577
|
});
|
|
254
578
|
|
|
255
|
-
expect(
|
|
256
|
-
expect(second.status).toBe(200);
|
|
257
|
-
expect(sink).toHaveBeenCalledTimes(1);
|
|
579
|
+
expect(response.status).toBe(200);
|
|
258
580
|
});
|
|
259
581
|
} finally {
|
|
260
582
|
unregister();
|
|
261
583
|
}
|
|
584
|
+
|
|
585
|
+
await vi.waitFor(() => expect(fetchRemoteMediaMock).toHaveBeenCalledTimes(1));
|
|
586
|
+
expectImageLifecycleDelivery({
|
|
587
|
+
fetchRemoteMediaMock,
|
|
588
|
+
saveMediaBufferMock,
|
|
589
|
+
finalizeInboundContextMock,
|
|
590
|
+
recordInboundSessionMock,
|
|
591
|
+
});
|
|
262
592
|
});
|
|
263
593
|
|
|
264
594
|
it("returns 429 when per-path request rate exceeds threshold", async () => {
|
package/src/monitor.webhook.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { timingSafeEqual } from "node:crypto";
|
|
2
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
-
import
|
|
2
|
+
import { createClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
|
|
3
|
+
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
|
4
|
+
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
5
|
+
import type { ZaloFetch, ZaloUpdate } from "./api.js";
|
|
6
|
+
import type { ZaloRuntimeEnv } from "./monitor.types.js";
|
|
4
7
|
import {
|
|
5
|
-
createDedupeCache,
|
|
6
8
|
createFixedWindowRateLimiter,
|
|
7
9
|
createWebhookAnomalyTracker,
|
|
8
10
|
readJsonWebhookBodyOrReject,
|
|
@@ -15,11 +17,9 @@ import {
|
|
|
15
17
|
withResolvedWebhookRequestPipeline,
|
|
16
18
|
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
|
|
17
19
|
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import type { ZaloFetch, ZaloUpdate } from "./api.js";
|
|
22
|
-
import type { ZaloRuntimeEnv } from "./monitor.js";
|
|
20
|
+
resolveClientIp,
|
|
21
|
+
type OpenClawConfig,
|
|
22
|
+
} from "./runtime-api.js";
|
|
23
23
|
|
|
24
24
|
const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000;
|
|
25
25
|
|
|
@@ -31,7 +31,10 @@ export type ZaloWebhookTarget = {
|
|
|
31
31
|
core: unknown;
|
|
32
32
|
secret: string;
|
|
33
33
|
path: string;
|
|
34
|
+
webhookUrl: string;
|
|
35
|
+
webhookPath: string;
|
|
34
36
|
mediaMaxMb: number;
|
|
37
|
+
canHostMedia: boolean;
|
|
35
38
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
36
39
|
fetcher?: ZaloFetch;
|
|
37
40
|
};
|
|
@@ -47,9 +50,9 @@ const webhookRateLimiter = createFixedWindowRateLimiter({
|
|
|
47
50
|
maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests,
|
|
48
51
|
maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys,
|
|
49
52
|
});
|
|
50
|
-
const recentWebhookEvents =
|
|
53
|
+
const recentWebhookEvents = createClaimableDedupe({
|
|
51
54
|
ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS,
|
|
52
|
-
|
|
55
|
+
memoryMaxSize: 5000,
|
|
53
56
|
});
|
|
54
57
|
const webhookAnomalyTracker = createWebhookAnomalyTracker({
|
|
55
58
|
maxTrackedKeys: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.maxTrackedKeys,
|
|
@@ -59,6 +62,7 @@ const webhookAnomalyTracker = createWebhookAnomalyTracker({
|
|
|
59
62
|
|
|
60
63
|
export function clearZaloWebhookSecurityStateForTest(): void {
|
|
61
64
|
webhookRateLimiter.clear();
|
|
65
|
+
recentWebhookEvents.clearMemory();
|
|
62
66
|
webhookAnomalyTracker.clear();
|
|
63
67
|
}
|
|
64
68
|
|
|
@@ -71,29 +75,64 @@ export function getZaloWebhookStatusCounterSizeForTest(): number {
|
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
function timingSafeEquals(left: string, right: string): boolean {
|
|
74
|
-
|
|
75
|
-
const rightBuffer = Buffer.from(right);
|
|
76
|
-
|
|
77
|
-
if (leftBuffer.length !== rightBuffer.length) {
|
|
78
|
-
const length = Math.max(1, leftBuffer.length, rightBuffer.length);
|
|
79
|
-
const paddedLeft = Buffer.alloc(length);
|
|
80
|
-
const paddedRight = Buffer.alloc(length);
|
|
81
|
-
leftBuffer.copy(paddedLeft);
|
|
82
|
-
rightBuffer.copy(paddedRight);
|
|
83
|
-
timingSafeEqual(paddedLeft, paddedRight);
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
78
|
+
return safeEqualSecret(left, right);
|
|
88
79
|
}
|
|
89
80
|
|
|
90
|
-
function
|
|
81
|
+
function buildReplayEventCacheKey(target: ZaloWebhookTarget, update: ZaloUpdate): string | null {
|
|
91
82
|
const messageId = update.message?.message_id;
|
|
92
83
|
if (!messageId) {
|
|
93
|
-
return
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const chatId = update.message?.chat?.id ?? "";
|
|
87
|
+
const senderId = update.message?.from?.id ?? "";
|
|
88
|
+
return JSON.stringify([
|
|
89
|
+
target.path,
|
|
90
|
+
target.account.accountId,
|
|
91
|
+
update.event_name,
|
|
92
|
+
chatId,
|
|
93
|
+
senderId,
|
|
94
|
+
messageId,
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class ZaloRetryableWebhookError extends Error {
|
|
99
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
100
|
+
super(message, options);
|
|
101
|
+
this.name = "ZaloRetryableWebhookError";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function processZaloReplayGuardedUpdate(params: {
|
|
106
|
+
target: ZaloWebhookTarget;
|
|
107
|
+
update: ZaloUpdate;
|
|
108
|
+
processUpdate: ZaloWebhookProcessUpdate;
|
|
109
|
+
nowMs?: number;
|
|
110
|
+
}): Promise<"processed" | "duplicate"> {
|
|
111
|
+
const replayEventKey = buildReplayEventCacheKey(params.target, params.update);
|
|
112
|
+
if (replayEventKey) {
|
|
113
|
+
const replayClaim = await recentWebhookEvents.claim(replayEventKey, { now: params.nowMs });
|
|
114
|
+
if (replayClaim.kind !== "claimed") {
|
|
115
|
+
return "duplicate";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
params.target.statusSink?.({ lastInboundAt: Date.now() });
|
|
120
|
+
try {
|
|
121
|
+
await params.processUpdate({ update: params.update, target: params.target });
|
|
122
|
+
if (replayEventKey) {
|
|
123
|
+
await recentWebhookEvents.commit(replayEventKey);
|
|
124
|
+
}
|
|
125
|
+
return "processed";
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (replayEventKey) {
|
|
128
|
+
if (error instanceof ZaloRetryableWebhookError) {
|
|
129
|
+
recentWebhookEvents.release(replayEventKey, { error });
|
|
130
|
+
} else {
|
|
131
|
+
await recentWebhookEvents.commit(replayEventKey);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
throw error;
|
|
94
135
|
}
|
|
95
|
-
const key = `${update.event_name}:${messageId}`;
|
|
96
|
-
return recentWebhookEvents.check(key, nowMs);
|
|
97
136
|
}
|
|
98
137
|
|
|
99
138
|
function recordWebhookStatus(
|
|
@@ -222,14 +261,12 @@ export async function handleZaloWebhookRequest(
|
|
|
222
261
|
return true;
|
|
223
262
|
}
|
|
224
263
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
target.statusSink?.({ lastInboundAt: Date.now() });
|
|
232
|
-
processUpdate({ update, target }).catch((err) => {
|
|
264
|
+
void processZaloReplayGuardedUpdate({
|
|
265
|
+
target,
|
|
266
|
+
update,
|
|
267
|
+
processUpdate,
|
|
268
|
+
nowMs,
|
|
269
|
+
}).catch((err) => {
|
|
233
270
|
target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
|
|
234
271
|
});
|
|
235
272
|
|