@openclaw/zalo 2026.3.2 → 2026.3.8-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/src/monitor.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk";
2
+ import type {
3
+ MarkdownTableMode,
4
+ OpenClawConfig,
5
+ OutboundReplyPayload,
6
+ } from "openclaw/plugin-sdk/zalo";
3
7
  import {
8
+ createTypingCallbacks,
4
9
  createScopedPairingAccess,
5
10
  createReplyPrefixOptions,
11
+ issuePairingChallenge,
12
+ logTypingFailure,
6
13
  resolveDirectDmAuthorizationOutcome,
7
14
  resolveSenderCommandAuthorizationWithRuntime,
8
15
  resolveOutboundMediaUrls,
@@ -10,13 +17,16 @@ import {
10
17
  resolveInboundRouteEnvelopeBuilderWithRuntime,
11
18
  sendMediaWithLeadingCaption,
12
19
  resolveWebhookPath,
20
+ waitForAbortSignal,
13
21
  warnMissingProviderGroupPolicyFallbackOnce,
14
- } from "openclaw/plugin-sdk";
22
+ } from "openclaw/plugin-sdk/zalo";
15
23
  import type { ResolvedZaloAccount } from "./accounts.js";
16
24
  import {
17
25
  ZaloApiError,
18
26
  deleteWebhook,
27
+ getWebhookInfo,
19
28
  getUpdates,
29
+ sendChatAction,
20
30
  sendMessage,
21
31
  sendPhoto,
22
32
  setWebhook,
@@ -59,15 +69,34 @@ export type ZaloMonitorOptions = {
59
69
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
60
70
  };
61
71
 
62
- export type ZaloMonitorResult = {
63
- stop: () => void;
64
- };
65
-
66
72
  const ZALO_TEXT_LIMIT = 2000;
67
73
  const DEFAULT_MEDIA_MAX_MB = 5;
74
+ const WEBHOOK_CLEANUP_TIMEOUT_MS = 5_000;
75
+ const ZALO_TYPING_TIMEOUT_MS = 5_000;
68
76
 
69
77
  type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
70
78
 
79
+ function formatZaloError(error: unknown): string {
80
+ if (error instanceof Error) {
81
+ return error.stack ?? `${error.name}: ${error.message}`;
82
+ }
83
+ return String(error);
84
+ }
85
+
86
+ function describeWebhookTarget(rawUrl: string): string {
87
+ try {
88
+ const parsed = new URL(rawUrl);
89
+ return `${parsed.origin}${parsed.pathname}`;
90
+ } catch {
91
+ return rawUrl;
92
+ }
93
+ }
94
+
95
+ function normalizeWebhookUrl(url: string | undefined): string | undefined {
96
+ const trimmed = url?.trim();
97
+ return trimmed ? trimmed : undefined;
98
+ }
99
+
71
100
  function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
72
101
  if (core.logging.shouldLogVerbose()) {
73
102
  runtime.log?.(`[zalo] ${message}`);
@@ -146,6 +175,8 @@ function startPollingLoop(params: {
146
175
  } = params;
147
176
  const pollTimeout = 30;
148
177
 
178
+ runtime.log?.(`[${account.accountId}] Zalo polling loop started timeout=${String(pollTimeout)}s`);
179
+
149
180
  const poll = async () => {
150
181
  if (isStopped() || abortSignal.aborted) {
151
182
  return;
@@ -171,7 +202,7 @@ function startPollingLoop(params: {
171
202
  if (err instanceof ZaloApiError && err.isPollingTimeout) {
172
203
  // no updates
173
204
  } else if (!isStopped() && !abortSignal.aborted) {
174
- runtime.error?.(`[${account.accountId}] Zalo polling error: ${String(err)}`);
205
+ runtime.error?.(`[${account.accountId}] Zalo polling error: ${formatZaloError(err)}`);
175
206
  await new Promise((resolve) => setTimeout(resolve, 5000));
176
207
  }
177
208
  }
@@ -410,31 +441,30 @@ async function processMessageWithPipeline(params: {
410
441
  }
411
442
  if (directDmOutcome === "unauthorized") {
412
443
  if (dmPolicy === "pairing") {
413
- const { code, created } = await pairing.upsertPairingRequest({
414
- id: senderId,
444
+ await issuePairingChallenge({
445
+ channel: "zalo",
446
+ senderId,
447
+ senderIdLine: `Your Zalo user id: ${senderId}`,
415
448
  meta: { name: senderName ?? undefined },
416
- });
417
-
418
- if (created) {
419
- logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
420
- try {
449
+ upsertPairingRequest: pairing.upsertPairingRequest,
450
+ onCreated: () => {
451
+ logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
452
+ },
453
+ sendPairingReply: async (text) => {
421
454
  await sendMessage(
422
455
  token,
423
456
  {
424
457
  chat_id: chatId,
425
- text: core.channel.pairing.buildPairingReply({
426
- channel: "zalo",
427
- idLine: `Your Zalo user id: ${senderId}`,
428
- code,
429
- }),
458
+ text,
430
459
  },
431
460
  fetcher,
432
461
  );
433
462
  statusSink?.({ lastOutboundAt: Date.now() });
434
- } catch (err) {
463
+ },
464
+ onReplyError: (err) => {
435
465
  logVerbose(core, runtime, `zalo pairing reply failed for ${senderId}: ${String(err)}`);
436
- }
437
- }
466
+ },
467
+ });
438
468
  } else {
439
469
  logVerbose(
440
470
  core,
@@ -518,12 +548,35 @@ async function processMessageWithPipeline(params: {
518
548
  channel: "zalo",
519
549
  accountId: account.accountId,
520
550
  });
551
+ const typingCallbacks = createTypingCallbacks({
552
+ start: async () => {
553
+ await sendChatAction(
554
+ token,
555
+ {
556
+ chat_id: chatId,
557
+ action: "typing",
558
+ },
559
+ fetcher,
560
+ ZALO_TYPING_TIMEOUT_MS,
561
+ );
562
+ },
563
+ onStartError: (err) => {
564
+ logTypingFailure({
565
+ log: (message) => logVerbose(core, runtime, message),
566
+ channel: "zalo",
567
+ action: "start",
568
+ target: chatId,
569
+ error: err,
570
+ });
571
+ },
572
+ });
521
573
 
522
574
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
523
575
  ctx: ctxPayload,
524
576
  cfg: config,
525
577
  dispatcherOptions: {
526
578
  ...prefixOptions,
579
+ typingCallbacks,
527
580
  deliver: async (payload) => {
528
581
  await deliverZaloReply({
529
582
  payload,
@@ -563,7 +616,6 @@ async function deliverZaloReply(params: {
563
616
  const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
564
617
  const tableMode = params.tableMode ?? "code";
565
618
  const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
566
-
567
619
  const sentMedia = await sendMediaWithLeadingCaption({
568
620
  mediaUrls: resolveOutboundMediaUrls(payload),
569
621
  caption: text,
@@ -593,7 +645,7 @@ async function deliverZaloReply(params: {
593
645
  }
594
646
  }
595
647
 
596
- export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<ZaloMonitorResult> {
648
+ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<void> {
597
649
  const {
598
650
  token,
599
651
  account,
@@ -611,78 +663,140 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
611
663
  const core = getZaloRuntime();
612
664
  const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
613
665
  const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
666
+ const mode = useWebhook ? "webhook" : "polling";
614
667
 
615
668
  let stopped = false;
616
669
  const stopHandlers: Array<() => void> = [];
670
+ let cleanupWebhook: (() => Promise<void>) | undefined;
617
671
 
618
672
  const stop = () => {
673
+ if (stopped) {
674
+ return;
675
+ }
619
676
  stopped = true;
620
677
  for (const handler of stopHandlers) {
621
678
  handler();
622
679
  }
623
680
  };
624
681
 
625
- if (useWebhook) {
626
- if (!webhookUrl || !webhookSecret) {
627
- throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
628
- }
629
- if (!webhookUrl.startsWith("https://")) {
630
- throw new Error("Zalo webhook URL must use HTTPS");
631
- }
632
- if (webhookSecret.length < 8 || webhookSecret.length > 256) {
633
- throw new Error("Zalo webhook secret must be 8-256 characters");
634
- }
682
+ runtime.log?.(
683
+ `[${account.accountId}] Zalo provider init mode=${mode} mediaMaxMb=${String(effectiveMediaMaxMb)}`,
684
+ );
685
+
686
+ try {
687
+ if (useWebhook) {
688
+ if (!webhookUrl || !webhookSecret) {
689
+ throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
690
+ }
691
+ if (!webhookUrl.startsWith("https://")) {
692
+ throw new Error("Zalo webhook URL must use HTTPS");
693
+ }
694
+ if (webhookSecret.length < 8 || webhookSecret.length > 256) {
695
+ throw new Error("Zalo webhook secret must be 8-256 characters");
696
+ }
635
697
 
636
- const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null });
637
- if (!path) {
638
- throw new Error("Zalo webhookPath could not be derived");
698
+ const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null });
699
+ if (!path) {
700
+ throw new Error("Zalo webhookPath could not be derived");
701
+ }
702
+
703
+ runtime.log?.(
704
+ `[${account.accountId}] Zalo configuring webhook path=${path} target=${describeWebhookTarget(webhookUrl)}`,
705
+ );
706
+ await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
707
+ let webhookCleanupPromise: Promise<void> | undefined;
708
+ cleanupWebhook = async () => {
709
+ if (!webhookCleanupPromise) {
710
+ webhookCleanupPromise = (async () => {
711
+ runtime.log?.(`[${account.accountId}] Zalo stopping; deleting webhook`);
712
+ try {
713
+ await deleteWebhook(token, fetcher, WEBHOOK_CLEANUP_TIMEOUT_MS);
714
+ runtime.log?.(`[${account.accountId}] Zalo webhook deleted`);
715
+ } catch (err) {
716
+ const detail =
717
+ err instanceof Error && err.name === "AbortError"
718
+ ? `timed out after ${String(WEBHOOK_CLEANUP_TIMEOUT_MS)}ms`
719
+ : formatZaloError(err);
720
+ runtime.error?.(`[${account.accountId}] Zalo webhook delete failed: ${detail}`);
721
+ }
722
+ })();
723
+ }
724
+ await webhookCleanupPromise;
725
+ };
726
+ runtime.log?.(`[${account.accountId}] Zalo webhook registered path=${path}`);
727
+
728
+ const unregister = registerZaloWebhookTarget({
729
+ token,
730
+ account,
731
+ config,
732
+ runtime,
733
+ core,
734
+ path,
735
+ secret: webhookSecret,
736
+ statusSink: (patch) => statusSink?.(patch),
737
+ mediaMaxMb: effectiveMediaMaxMb,
738
+ fetcher,
739
+ });
740
+ stopHandlers.push(unregister);
741
+ await waitForAbortSignal(abortSignal);
742
+ return;
639
743
  }
640
744
 
641
- await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
745
+ runtime.log?.(`[${account.accountId}] Zalo polling mode: clearing webhook before startup`);
746
+ try {
747
+ try {
748
+ const currentWebhookUrl = normalizeWebhookUrl(
749
+ (await getWebhookInfo(token, fetcher)).result?.url,
750
+ );
751
+ if (!currentWebhookUrl) {
752
+ runtime.log?.(`[${account.accountId}] Zalo polling mode ready (no webhook configured)`);
753
+ } else {
754
+ runtime.log?.(
755
+ `[${account.accountId}] Zalo polling mode disabling existing webhook ${describeWebhookTarget(currentWebhookUrl)}`,
756
+ );
757
+ await deleteWebhook(token, fetcher);
758
+ runtime.log?.(`[${account.accountId}] Zalo polling mode ready (webhook disabled)`);
759
+ }
760
+ } catch (err) {
761
+ if (err instanceof ZaloApiError && err.errorCode === 404) {
762
+ // Some Zalo environments do not expose webhook inspection for polling bots.
763
+ runtime.log?.(
764
+ `[${account.accountId}] Zalo polling mode webhook inspection unavailable; continuing without webhook cleanup`,
765
+ );
766
+ } else {
767
+ throw err;
768
+ }
769
+ }
770
+ } catch (err) {
771
+ runtime.error?.(
772
+ `[${account.accountId}] Zalo polling startup could not clear webhook: ${formatZaloError(err)}`,
773
+ );
774
+ }
642
775
 
643
- const unregister = registerZaloWebhookTarget({
776
+ startPollingLoop({
644
777
  token,
645
778
  account,
646
779
  config,
647
780
  runtime,
648
781
  core,
649
- path,
650
- secret: webhookSecret,
651
- statusSink: (patch) => statusSink?.(patch),
782
+ abortSignal,
783
+ isStopped: () => stopped,
652
784
  mediaMaxMb: effectiveMediaMaxMb,
785
+ statusSink,
653
786
  fetcher,
654
787
  });
655
- stopHandlers.push(unregister);
656
- abortSignal.addEventListener(
657
- "abort",
658
- () => {
659
- void deleteWebhook(token, fetcher).catch(() => {});
660
- },
661
- { once: true },
662
- );
663
- return { stop };
664
- }
665
788
 
666
- try {
667
- await deleteWebhook(token, fetcher);
668
- } catch {
669
- // ignore
789
+ await waitForAbortSignal(abortSignal);
790
+ } catch (err) {
791
+ runtime.error?.(
792
+ `[${account.accountId}] Zalo provider startup failed mode=${mode}: ${formatZaloError(err)}`,
793
+ );
794
+ throw err;
795
+ } finally {
796
+ await cleanupWebhook?.();
797
+ stop();
798
+ runtime.log?.(`[${account.accountId}] Zalo provider stopped mode=${mode}`);
670
799
  }
671
-
672
- startPollingLoop({
673
- token,
674
- account,
675
- config,
676
- runtime,
677
- core,
678
- abortSignal,
679
- isStopped: () => stopped,
680
- mediaMaxMb: effectiveMediaMaxMb,
681
- statusSink,
682
- fetcher,
683
- });
684
-
685
- return { stop };
686
800
  }
687
801
 
688
802
  export const __testing = {
@@ -1,6 +1,6 @@
1
1
  import { createServer, type RequestListener } from "node:http";
2
2
  import type { AddressInfo } from "node:net";
3
- import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
3
+ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/zalo";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
6
6
  import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
@@ -94,6 +94,33 @@ function createPairingAuthCore(params?: { storeAllowFrom?: string[]; pairingCrea
94
94
  return { core, readAllowFromStore, upsertPairingRequest };
95
95
  }
96
96
 
97
+ async function postUntilRateLimited(params: {
98
+ baseUrl: string;
99
+ path: string;
100
+ secret: string;
101
+ withNonceQuery?: boolean;
102
+ attempts?: number;
103
+ }): Promise<boolean> {
104
+ const attempts = params.attempts ?? 130;
105
+ for (let i = 0; i < attempts; i += 1) {
106
+ const url = params.withNonceQuery
107
+ ? `${params.baseUrl}${params.path}?nonce=${i}`
108
+ : `${params.baseUrl}${params.path}`;
109
+ const response = await fetch(url, {
110
+ method: "POST",
111
+ headers: {
112
+ "x-bot-api-secret-token": params.secret,
113
+ "content-type": "application/json",
114
+ },
115
+ body: "{}",
116
+ });
117
+ if (response.status === 429) {
118
+ return true;
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+
97
124
  describe("handleZaloWebhookRequest", () => {
98
125
  afterEach(() => {
99
126
  clearZaloWebhookSecurityStateForTest();
@@ -239,21 +266,11 @@ describe("handleZaloWebhookRequest", () => {
239
266
 
240
267
  try {
241
268
  await withServer(webhookRequestHandler, async (baseUrl) => {
242
- let saw429 = false;
243
- for (let i = 0; i < 130; i += 1) {
244
- const response = await fetch(`${baseUrl}/hook-rate`, {
245
- method: "POST",
246
- headers: {
247
- "x-bot-api-secret-token": "secret",
248
- "content-type": "application/json",
249
- },
250
- body: "{}",
251
- });
252
- if (response.status === 429) {
253
- saw429 = true;
254
- break;
255
- }
256
- }
269
+ const saw429 = await postUntilRateLimited({
270
+ baseUrl,
271
+ path: "/hook-rate",
272
+ secret: "secret", // pragma: allowlist secret
273
+ });
257
274
 
258
275
  expect(saw429).toBe(true);
259
276
  });
@@ -270,7 +287,7 @@ describe("handleZaloWebhookRequest", () => {
270
287
  const response = await fetch(`${baseUrl}/hook-query-status?nonce=${i}`, {
271
288
  method: "POST",
272
289
  headers: {
273
- "x-bot-api-secret-token": "invalid-token",
290
+ "x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
274
291
  "content-type": "application/json",
275
292
  },
276
293
  body: "{}",
@@ -290,21 +307,12 @@ describe("handleZaloWebhookRequest", () => {
290
307
 
291
308
  try {
292
309
  await withServer(webhookRequestHandler, async (baseUrl) => {
293
- let saw429 = false;
294
- for (let i = 0; i < 130; i += 1) {
295
- const response = await fetch(`${baseUrl}/hook-query-rate?nonce=${i}`, {
296
- method: "POST",
297
- headers: {
298
- "x-bot-api-secret-token": "secret",
299
- "content-type": "application/json",
300
- },
301
- body: "{}",
302
- });
303
- if (response.status === 429) {
304
- saw429 = true;
305
- break;
306
- }
307
- }
310
+ const saw429 = await postUntilRateLimited({
311
+ baseUrl,
312
+ path: "/hook-query-rate",
313
+ secret: "secret", // pragma: allowlist secret
314
+ withNonceQuery: true,
315
+ });
308
316
 
309
317
  expect(saw429).toBe(true);
310
318
  expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
@@ -1,6 +1,6 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import type { IncomingMessage, ServerResponse } from "node:http";
3
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
4
4
  import {
5
5
  createDedupeCache,
6
6
  createFixedWindowRateLimiter,
@@ -11,11 +11,11 @@ import {
11
11
  type RegisterWebhookTargetOptions,
12
12
  type RegisterWebhookPluginRouteOptions,
13
13
  registerWebhookTarget,
14
- resolveSingleWebhookTarget,
15
- resolveWebhookTargets,
14
+ resolveWebhookTargetWithAuthOrRejectSync,
15
+ withResolvedWebhookRequestPipeline,
16
16
  WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
17
17
  WEBHOOK_RATE_LIMIT_DEFAULTS,
18
- } from "openclaw/plugin-sdk";
18
+ } from "openclaw/plugin-sdk/zalo";
19
19
  import type { ResolvedZaloAccount } from "./accounts.js";
20
20
  import type { ZaloFetch, ZaloUpdate } from "./api.js";
21
21
  import type { ZaloRuntimeEnv } from "./monitor.js";
@@ -134,95 +134,80 @@ export async function handleZaloWebhookRequest(
134
134
  res: ServerResponse,
135
135
  processUpdate: ZaloWebhookProcessUpdate,
136
136
  ): Promise<boolean> {
137
- const resolved = resolveWebhookTargets(req, webhookTargets);
138
- if (!resolved) {
139
- return false;
140
- }
141
- const { targets, path } = resolved;
142
-
143
- if (
144
- !applyBasicWebhookRequestGuards({
145
- req,
146
- res,
147
- allowMethods: ["POST"],
148
- })
149
- ) {
150
- return true;
151
- }
152
-
153
- const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
154
- const matchedTarget = resolveSingleWebhookTarget(targets, (entry) =>
155
- timingSafeEquals(entry.secret, headerToken),
156
- );
157
- if (matchedTarget.kind === "none") {
158
- res.statusCode = 401;
159
- res.end("unauthorized");
160
- recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
161
- return true;
162
- }
163
- if (matchedTarget.kind === "ambiguous") {
164
- res.statusCode = 401;
165
- res.end("ambiguous webhook target");
166
- recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
167
- return true;
168
- }
169
- const target = matchedTarget.target;
170
- const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
171
- const nowMs = Date.now();
172
-
173
- if (
174
- !applyBasicWebhookRequestGuards({
175
- req,
176
- res,
177
- rateLimiter: webhookRateLimiter,
178
- rateLimitKey,
179
- nowMs,
180
- requireJsonContentType: true,
181
- })
182
- ) {
183
- recordWebhookStatus(target.runtime, path, res.statusCode);
184
- return true;
185
- }
186
- const body = await readJsonWebhookBodyOrReject({
137
+ return await withResolvedWebhookRequestPipeline({
187
138
  req,
188
139
  res,
189
- maxBytes: 1024 * 1024,
190
- timeoutMs: 30_000,
191
- emptyObjectOnEmpty: false,
192
- invalidJsonMessage: "Bad Request",
140
+ targetsByPath: webhookTargets,
141
+ allowMethods: ["POST"],
142
+ handle: async ({ targets, path }) => {
143
+ const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
144
+ const target = resolveWebhookTargetWithAuthOrRejectSync({
145
+ targets,
146
+ res,
147
+ isMatch: (entry) => timingSafeEquals(entry.secret, headerToken),
148
+ });
149
+ if (!target) {
150
+ recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
151
+ return true;
152
+ }
153
+ const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
154
+ const nowMs = Date.now();
155
+
156
+ if (
157
+ !applyBasicWebhookRequestGuards({
158
+ req,
159
+ res,
160
+ rateLimiter: webhookRateLimiter,
161
+ rateLimitKey,
162
+ nowMs,
163
+ requireJsonContentType: true,
164
+ })
165
+ ) {
166
+ recordWebhookStatus(target.runtime, path, res.statusCode);
167
+ return true;
168
+ }
169
+ const body = await readJsonWebhookBodyOrReject({
170
+ req,
171
+ res,
172
+ maxBytes: 1024 * 1024,
173
+ timeoutMs: 30_000,
174
+ emptyObjectOnEmpty: false,
175
+ invalidJsonMessage: "Bad Request",
176
+ });
177
+ if (!body.ok) {
178
+ recordWebhookStatus(target.runtime, path, res.statusCode);
179
+ return true;
180
+ }
181
+ const raw = body.value;
182
+
183
+ // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }.
184
+ const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
185
+ const update: ZaloUpdate | undefined =
186
+ record && record.ok === true && record.result
187
+ ? (record.result as ZaloUpdate)
188
+ : ((record as ZaloUpdate | null) ?? undefined);
189
+
190
+ if (!update?.event_name) {
191
+ res.statusCode = 400;
192
+ res.end("Bad Request");
193
+ recordWebhookStatus(target.runtime, path, res.statusCode);
194
+ return true;
195
+ }
196
+
197
+ if (isReplayEvent(update, nowMs)) {
198
+ res.statusCode = 200;
199
+ res.end("ok");
200
+ return true;
201
+ }
202
+
203
+ target.statusSink?.({ lastInboundAt: Date.now() });
204
+ processUpdate({ update, target }).catch((err) => {
205
+ target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
206
+ });
207
+
208
+ res.statusCode = 200;
209
+ res.end("ok");
210
+ return true;
211
+ },
193
212
  });
194
- if (!body.ok) {
195
- recordWebhookStatus(target.runtime, path, res.statusCode);
196
- return true;
197
- }
198
- const raw = body.value;
199
-
200
- // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }.
201
- const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
202
- const update: ZaloUpdate | undefined =
203
- record && record.ok === true && record.result
204
- ? (record.result as ZaloUpdate)
205
- : ((record as ZaloUpdate | null) ?? undefined);
206
-
207
- if (!update?.event_name) {
208
- res.statusCode = 400;
209
- res.end("Bad Request");
210
- recordWebhookStatus(target.runtime, path, res.statusCode);
211
- return true;
212
- }
213
-
214
- if (isReplayEvent(update, nowMs)) {
215
- res.statusCode = 200;
216
- res.end("ok");
217
- return true;
218
- }
219
-
220
- target.statusSink?.({ lastInboundAt: Date.now() });
221
- processUpdate({ update, target }).catch((err) => {
222
- target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
223
- });
224
-
225
- res.statusCode = 200;
226
- res.end("ok");
227
- return true;
228
213
  }
@@ -1,4 +1,4 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import { zaloOnboardingAdapter } from "./onboarding.js";
4
4