@openclaw/zalo 2026.3.13 → 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 (67) hide show
  1. package/README.md +1 -1
  2. package/api.ts +9 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +5 -0
  5. package/index.test.ts +15 -0
  6. package/index.ts +16 -13
  7. package/openclaw.plugin.json +514 -1
  8. package/package.json +31 -5
  9. package/runtime-api.test.ts +17 -0
  10. package/runtime-api.ts +75 -0
  11. package/secret-contract-api.ts +5 -0
  12. package/setup-api.ts +34 -0
  13. package/setup-entry.ts +13 -0
  14. package/src/accounts.test.ts +70 -0
  15. package/src/accounts.ts +19 -19
  16. package/src/actions.runtime.ts +5 -0
  17. package/src/actions.test.ts +32 -0
  18. package/src/actions.ts +20 -14
  19. package/src/api.test.ts +93 -2
  20. package/src/api.ts +29 -2
  21. package/src/approval-auth.test.ts +17 -0
  22. package/src/approval-auth.ts +25 -0
  23. package/src/channel.directory.test.ts +19 -6
  24. package/src/channel.runtime.ts +93 -0
  25. package/src/channel.startup.test.ts +26 -19
  26. package/src/channel.ts +229 -336
  27. package/src/config-schema.ts +3 -3
  28. package/src/group-access.ts +4 -3
  29. package/src/monitor.group-policy.test.ts +0 -12
  30. package/src/monitor.image.polling.test.ts +110 -0
  31. package/src/monitor.lifecycle.test.ts +41 -22
  32. package/src/monitor.pairing.lifecycle.test.ts +141 -0
  33. package/src/monitor.polling.media-reply.test.ts +425 -0
  34. package/src/monitor.reply-once.lifecycle.test.ts +171 -0
  35. package/src/monitor.ts +460 -206
  36. package/src/monitor.types.ts +4 -0
  37. package/src/monitor.webhook.test.ts +392 -62
  38. package/src/monitor.webhook.ts +73 -36
  39. package/src/outbound-media.test.ts +182 -0
  40. package/src/outbound-media.ts +241 -0
  41. package/src/outbound-payload.contract.test.ts +45 -0
  42. package/src/probe.ts +1 -1
  43. package/src/proxy.ts +1 -1
  44. package/src/runtime-api.ts +75 -0
  45. package/src/runtime-support.ts +91 -0
  46. package/src/runtime.ts +6 -3
  47. package/src/secret-contract.ts +109 -0
  48. package/src/secret-input.ts +1 -9
  49. package/src/send.test.ts +120 -0
  50. package/src/send.ts +15 -13
  51. package/src/session-route.ts +32 -0
  52. package/src/setup-allow-from.ts +94 -0
  53. package/src/setup-core.ts +149 -0
  54. package/src/{onboarding.status.test.ts → setup-status.test.ts} +13 -4
  55. package/src/setup-surface.test.ts +175 -0
  56. package/src/{onboarding.ts → setup-surface.ts} +59 -177
  57. package/src/status-issues.test.ts +2 -14
  58. package/src/status-issues.ts +8 -2
  59. package/src/test-support/lifecycle-test-support.ts +413 -0
  60. package/src/test-support/monitor-mocks-test-support.ts +209 -0
  61. package/src/token.test.ts +15 -0
  62. package/src/token.ts +8 -17
  63. package/src/types.ts +2 -2
  64. package/test-api.ts +1 -0
  65. package/tsconfig.json +16 -0
  66. package/CHANGELOG.md +0 -101
  67. package/src/channel.sendpayload.test.ts +0 -44
@@ -0,0 +1,4 @@
1
+ export type ZaloRuntimeEnv = {
2
+ log?: (message: string) => void;
3
+ error?: (message: string) => void;
4
+ };
@@ -1,34 +1,30 @@
1
- import { createServer, type RequestListener } from "node:http";
2
- import type { AddressInfo } from "node:net";
3
- import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/zalo";
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 { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
6
- import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
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
- } from "./monitor.js";
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
- const webhookRequestHandler: RequestListener = async (req, res) => {
41
- const handled = await handleZaloWebhookRequest(req, res);
42
- if (!handled) {
43
- res.statusCode = 404;
44
- res.end("not found");
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
- describe("handleZaloWebhookRequest", () => {
125
- afterEach(() => {
126
- clearZaloWebhookSecurityStateForTest();
127
- setActivePluginRegistry(createEmptyPluginRegistry());
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
- it("registers and unregisters plugin HTTP route at path boundaries", () => {
131
- const registry = createEmptyPluginRegistry();
132
- setActivePluginRegistry(registry);
133
- const unregisterA = registerTarget({ path: "/hook" });
134
- const unregisterB = registerTarget({ path: "/hook" });
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
- expect(registry.httpRoutes).toHaveLength(1);
137
- expect(registry.httpRoutes[0]).toEqual(
138
- expect.objectContaining({
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
- unregisterA();
146
- expect(registry.httpRoutes).toHaveLength(1);
147
- unregisterB();
148
- expect(registry.httpRoutes).toHaveLength(0);
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 by event_name + message_id", async () => {
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
- from: { id: "123" },
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 first = await fetch(`${baseUrl}/hook-replay`, {
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
- const second = await fetch(`${baseUrl}/hook-replay`, {
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(first.status).toBe(200);
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 () => {
@@ -1,8 +1,10 @@
1
- import { timingSafeEqual } from "node:crypto";
2
1
  import type { IncomingMessage, ServerResponse } from "node:http";
3
- import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
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
- } from "openclaw/plugin-sdk/zalo";
19
- import { resolveClientIp } from "../../../src/gateway/net.js";
20
- import type { ResolvedZaloAccount } from "./accounts.js";
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 = createDedupeCache({
53
+ const recentWebhookEvents = createClaimableDedupe({
51
54
  ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS,
52
- maxSize: 5000,
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
- const leftBuffer = Buffer.from(left);
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 isReplayEvent(update: ZaloUpdate, nowMs: number): boolean {
81
+ function buildReplayEventCacheKey(target: ZaloWebhookTarget, update: ZaloUpdate): string | null {
91
82
  const messageId = update.message?.message_id;
92
83
  if (!messageId) {
93
- return false;
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
- if (isReplayEvent(update, nowMs)) {
226
- res.statusCode = 200;
227
- res.end("ok");
228
- return true;
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