@openclaw/zalo 2026.7.2-beta.7 → 2026.7.33

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.
@@ -1,19 +1,19 @@
1
- import { n as resolveZaloRuntimeGroupPolicy, t as normalizeZaloAllowEntry } from "./group-access-8qHRzDHx.js";
2
- import { t as getZaloRuntime } from "./runtime-api-qxdOyk3M.js";
3
- import { c as sendChatAction, d as setWebhook, f as ZALO_OUTBOUND_MEDIA_TTL_MS, i as deleteWebhook, l as sendMessage, n as resolveZaloProxyFetch, o as getUpdates, r as ZaloApiError, s as getWebhookInfo, u as sendPhoto } from "./send-BUvbbedq.js";
4
- import { createLazyRuntimeModule, createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
1
+ import { n as resolveZaloRuntimeGroupPolicy, t as normalizeZaloAllowEntry } from "./group-access-8qHRzDHx.mjs";
2
+ import { t as getZaloRuntime } from "./runtime-api-CAA4ZtqF.mjs";
3
+ import { c as sendChatAction, d as setWebhook, i as deleteWebhook, l as sendMessage, n as resolveZaloProxyFetch, o as getUpdates, r as ZaloApiError, s as getWebhookInfo, u as sendPhoto } from "./send-CNCw5UOt.mjs";
4
+ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
5
5
  import { deliverTextOrMediaReply, resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
6
6
  import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
7
7
  import { resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce } from "openclaw/plugin-sdk/runtime-group-policy";
8
8
  import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
9
9
  import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
10
+ import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
10
11
  import { registerPluginHttpRoute, resolveWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
11
12
  import { asDateTimestampMs, resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
12
- import { formatInboundMediaUnavailableText, resolveChannelInboundRouteEnvelope } from "openclaw/plugin-sdk/channel-inbound";
13
+ import { formatInboundMediaUnavailableText } from "openclaw/plugin-sdk/channel-inbound";
13
14
  import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
14
- import { sleepWithAbort, waitForAbortSignal } from "openclaw/plugin-sdk/runtime-env";
15
+ import { waitForAbortSignal } from "openclaw/plugin-sdk/runtime-env";
15
16
  import { createHostedOutboundMediaStore } from "openclaw/plugin-sdk/outbound-media";
16
- import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
17
17
  //#region extensions/zalo/src/monitor-durable.ts
18
18
  function prepareZaloDurableReplyPayload(params) {
19
19
  if (!params.payload.text) return params.payload;
@@ -30,6 +30,7 @@ function resolveZaloDurableReplyOptions(params) {
30
30
  }
31
31
  //#endregion
32
32
  //#region extensions/zalo/src/outbound-media.ts
33
+ const ZALO_OUTBOUND_MEDIA_TTL_MS = 12e4;
33
34
  const ZALO_OUTBOUND_MEDIA_SEGMENT = "media";
34
35
  const ZALO_OUTBOUND_MEDIA_PREFIX = `/${ZALO_OUTBOUND_MEDIA_SEGMENT}/`;
35
36
  const ZALO_OUTBOUND_MEDIA_ID_RE = /^[a-f0-9]{24}$/;
@@ -73,7 +74,7 @@ function resolveHostedZaloMediaRoutePath(params) {
73
74
  }
74
75
  async function prepareHostedZaloMediaUrl(params) {
75
76
  const now = asDateTimestampMs(Date.now());
76
- if ((now === void 0 ? void 0 : resolveExpiresAtMsFromDurationMs(12e4, { nowMs: now })) === void 0) throw new Error("Zalo outbound media expiry could not be resolved");
77
+ if ((now === void 0 ? void 0 : resolveExpiresAtMsFromDurationMs(ZALO_OUTBOUND_MEDIA_TTL_MS, { nowMs: now })) === void 0) throw new Error("Zalo outbound media expiry could not be resolved");
77
78
  const routePath = resolveHostedZaloMediaRoutePath({
78
79
  webhookUrl: params.webhookUrl,
79
80
  webhookPath: params.webhookPath
@@ -128,7 +129,7 @@ async function tryHandleHostedZaloMediaRequest(req, res) {
128
129
  res.end("Expired");
129
130
  return true;
130
131
  }
131
- if (!safeEqualSecret(url.searchParams.get("token"), entry.metadata.token)) {
132
+ if (url.searchParams.get("token") !== entry.metadata.token) {
132
133
  res.statusCode = 401;
133
134
  res.end("Unauthorized");
134
135
  return true;
@@ -163,12 +164,7 @@ function resolveZaloTimestampMs(date) {
163
164
  if (!date) return;
164
165
  return date >= UNIX_MILLISECONDS_THRESHOLD ? date : date * 1e3;
165
166
  }
166
- const loadZaloWebhookRuntime = createLazyRuntimeNamedExport(() => import("./monitor.webhook-D56OCa3C.js"), "zaloWebhookRuntime");
167
- const loadZaloWebhookIngressRuntime = createLazyRuntimeNamedExport(() => import("./webhook-spool-DzCPbb3O.js"), "zaloWebhookIngressRuntime");
168
- const loadZaloWebhookModule = createLazyRuntimeModule(async () => ({
169
- ...await loadZaloWebhookRuntime(),
170
- ...await loadZaloWebhookIngressRuntime()
171
- }));
167
+ const loadZaloWebhookModule = createLazyRuntimeModule(() => import("./monitor.webhook-BwPNGSB4.mjs"));
172
168
  function releaseSharedHostedMediaRouteRef(routePath) {
173
169
  const current = hostedMediaRouteRefs.get(routePath);
174
170
  if (!current) return;
@@ -229,7 +225,22 @@ function logVerbose(core, runtime, message) {
229
225
  }
230
226
  async function handleZaloWebhookRequest(req, res) {
231
227
  const { handleZaloWebhookRequest: handleZaloWebhookRequestInternal } = await loadZaloWebhookModule();
232
- return await handleZaloWebhookRequestInternal(req, res);
228
+ return await handleZaloWebhookRequestInternal(req, res, async ({ update, target }) => {
229
+ await processUpdate({
230
+ update,
231
+ token: target.token,
232
+ account: target.account,
233
+ config: target.config,
234
+ runtime: target.runtime,
235
+ core: target.core,
236
+ mediaMaxMb: target.mediaMaxMb,
237
+ canHostMedia: target.canHostMedia,
238
+ webhookUrl: target.webhookUrl,
239
+ webhookPath: target.webhookPath,
240
+ statusSink: target.statusSink,
241
+ fetcher: target.fetcher
242
+ });
243
+ });
233
244
  }
234
245
  function startPollingLoop(params) {
235
246
  const { token, account, config, runtime, core, mediaMaxMb, canHostMedia, webhookUrl, webhookPath, abortSignal, isStopped, statusSink, fetcher } = params;
@@ -263,7 +274,9 @@ function startPollingLoop(params) {
263
274
  } catch (err) {
264
275
  if (err instanceof ZaloApiError && err.isPollingTimeout) {} else if (!isStopped() && !abortSignal.aborted) {
265
276
  runtime.error?.(`[${account.accountId}] Zalo polling error: ${formatZaloError(err)}`);
266
- await sleepWithAbort(5e3, abortSignal).catch(() => void 0);
277
+ await new Promise((resolve) => {
278
+ setTimeout(resolve, 5e3);
279
+ });
267
280
  }
268
281
  }
269
282
  if (!isStopped() && !abortSignal.aborted) setImmediate(() => {
@@ -286,8 +299,7 @@ async function processUpdate(params) {
286
299
  webhookUrl: params.webhookUrl,
287
300
  webhookPath: params.webhookPath,
288
301
  statusSink,
289
- fetcher,
290
- turnAdoptionLifecycle: params.turnAdoptionLifecycle
302
+ fetcher
291
303
  };
292
304
  if (!message) return;
293
305
  switch (event_name) {
@@ -307,9 +319,7 @@ async function processUpdate(params) {
307
319
  case "message.sticker.received":
308
320
  logVerbose(core, runtime, `[${account.accountId}] Received sticker from ${message.from.id}`);
309
321
  break;
310
- case "message.unsupported.received":
311
- logVerbose(core, runtime, `[${account.accountId}] Received unsupported message type from ${message.from.id}`);
312
- break;
322
+ case "message.unsupported.received": logVerbose(core, runtime, `[${account.accountId}] Received unsupported message type from ${message.from.id}`);
313
323
  }
314
324
  }
315
325
  async function handleTextMessage(params) {
@@ -319,7 +329,6 @@ async function handleTextMessage(params) {
319
329
  await processMessageWithPipeline({
320
330
  ...params,
321
331
  text,
322
- mediaKind: void 0,
323
332
  mediaPath: void 0,
324
333
  mediaType: void 0
325
334
  });
@@ -330,8 +339,7 @@ async function handleImageMessage(params) {
330
339
  const authorization = await authorizeZaloMessage({
331
340
  ...params,
332
341
  text: caption,
333
- mediaKind: "image",
334
- mediaPath: void 0,
342
+ mediaPath: "__pending_media__",
335
343
  mediaType: void 0
336
344
  });
337
345
  if (!authorization) return;
@@ -342,7 +350,7 @@ async function handleImageMessage(params) {
342
350
  const saved = await core.channel.media.saveRemoteMedia({
343
351
  url: photo_url,
344
352
  maxBytes,
345
- responseHeaderTimeoutMs: ZALO_MEDIA_RESPONSE_HEADER_TIMEOUT_MS,
353
+ timeoutMs: ZALO_MEDIA_RESPONSE_HEADER_TIMEOUT_MS,
346
354
  readIdleTimeoutMs: ZALO_MEDIA_READ_IDLE_TIMEOUT_MS
347
355
  });
348
356
  mediaPath = saved.path;
@@ -351,21 +359,21 @@ async function handleImageMessage(params) {
351
359
  runtime.error?.(`[${account.accountId}] Failed to download Zalo image: ${String(err)}`);
352
360
  }
353
361
  const agentBody = mediaPath ? authorization.rawBody : formatInboundMediaUnavailableText({
354
- body: authorization.rawBody,
362
+ body: caption,
363
+ mediaPlaceholder: "<media:image>",
355
364
  notice: "[zalo image attachment unavailable]"
356
365
  });
357
366
  await processMessageWithPipeline({
358
367
  ...params,
359
368
  authorization,
360
369
  agentBody,
361
- mediaKind: "image",
362
370
  text: caption,
363
371
  mediaPath,
364
372
  mediaType
365
373
  });
366
374
  }
367
375
  async function authorizeZaloMessage(params) {
368
- const { message, account, config, runtime, core, text, token, statusSink, fetcher } = params;
376
+ const { message, account, config, runtime, core, text, mediaPath, token, statusSink, fetcher } = params;
369
377
  const pairing = createChannelPairingController({
370
378
  core,
371
379
  channel: "zalo",
@@ -378,7 +386,7 @@ async function authorizeZaloMessage(params) {
378
386
  const senderName = from.display_name ?? from.name;
379
387
  const dmPolicy = account.config.dmPolicy ?? "pairing";
380
388
  const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
381
- const rawBody = text?.trim() ?? "";
389
+ const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
382
390
  const { groupPolicy, providerMissingFallbackApplied } = resolveZaloRuntimeGroupPolicy({
383
391
  providerConfigPresent: config.channels?.zalo !== void 0,
384
392
  groupPolicy: account.config.groupPolicy,
@@ -460,7 +468,7 @@ async function authorizeZaloMessage(params) {
460
468
  };
461
469
  }
462
470
  async function processMessageWithPipeline(params) {
463
- const { message, token, account, config, runtime, core, mediaPath, mediaKind, mediaType, statusSink, fetcher, agentBody: agentBodyOverride, authorization: authorizationOverride } = params;
471
+ const { message, token, account, config, runtime, core, mediaPath, mediaType, statusSink, fetcher, agentBody: agentBodyOverride, authorization: authorizationOverride } = params;
464
472
  const { message_id, date } = message;
465
473
  const authorization = authorizationOverride ?? await authorizeZaloMessage({
466
474
  ...params,
@@ -470,14 +478,16 @@ async function processMessageWithPipeline(params) {
470
478
  if (!authorization) return;
471
479
  const { isGroup, chatId, senderId, senderName, rawBody, commandAuthorized } = authorization;
472
480
  const agentBody = agentBodyOverride ?? rawBody;
473
- const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
481
+ const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
474
482
  cfg: config,
475
483
  channel: "zalo",
476
484
  accountId: account.accountId,
477
485
  peer: {
478
486
  kind: isGroup ? "group" : "direct",
479
487
  id: chatId
480
- }
488
+ },
489
+ runtime: core.channel,
490
+ sessionStore: config.session?.store
481
491
  });
482
492
  if (isGroup && core.channel.commands.isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
483
493
  logVerbose(core, runtime, `zalo: drop control command from unauthorized sender ${senderId}`);
@@ -485,7 +495,7 @@ async function processMessageWithPipeline(params) {
485
495
  }
486
496
  const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
487
497
  const timestamp = resolveZaloTimestampMs(date);
488
- const body = buildEnvelope({
498
+ const { storePath, body } = buildEnvelope({
489
499
  channel: "Zalo",
490
500
  from: fromLabel,
491
501
  timestamp,
@@ -508,7 +518,6 @@ async function processMessageWithPipeline(params) {
508
518
  },
509
519
  route: {
510
520
  agentId: route.agentId,
511
- dmScope: route.dmScope,
512
521
  accountId: route.accountId,
513
522
  routeSessionKey: route.sessionKey
514
523
  },
@@ -519,11 +528,10 @@ async function processMessageWithPipeline(params) {
519
528
  rawBody,
520
529
  commandBody: rawBody
521
530
  },
522
- media: mediaKind || mediaPath || mediaType ? [{
531
+ media: mediaPath || mediaType ? [{
523
532
  path: mediaPath,
524
533
  url: mediaPath,
525
- contentType: mediaType,
526
- kind: mediaKind ?? void 0
534
+ contentType: mediaType
527
535
  }] : void 0,
528
536
  extra: {
529
537
  CommandAuthorized: commandAuthorized,
@@ -535,16 +543,16 @@ async function processMessageWithPipeline(params) {
535
543
  channel: "zalo",
536
544
  accountId: account.accountId
537
545
  });
538
- await core.channel.inbound.dispatch({
546
+ await core.channel.inbound.dispatchReply({
539
547
  cfg: config,
540
548
  channel: "zalo",
541
549
  accountId: account.accountId,
542
- route: {
543
- agentId: route.agentId,
544
- dmScope: route.dmScope,
545
- sessionKey: route.sessionKey
546
- },
550
+ agentId: route.agentId,
551
+ routeSessionKey: route.sessionKey,
552
+ storePath,
547
553
  ctxPayload,
554
+ recordInboundSession: core.channel.session.recordInboundSession,
555
+ dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
548
556
  delivery: {
549
557
  preparePayload: (payload) => prepareZaloDurableReplyPayload({
550
558
  payload,
@@ -601,8 +609,7 @@ async function processMessageWithPipeline(params) {
601
609
  } },
602
610
  record: { onRecordError: (err) => {
603
611
  runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
604
- } },
605
- ...params.turnAdoptionLifecycle ? { turnAdoptionLifecycle: params.turnAdoptionLifecycle } : {}
612
+ } }
606
613
  });
607
614
  }
608
615
  async function deliverZaloReply(params) {
@@ -664,7 +671,6 @@ async function monitorZaloProvider(options) {
664
671
  }) : void 0;
665
672
  let stopped = false;
666
673
  const stopHandlers = [];
667
- const asyncStopHandlers = [];
668
674
  let cleanupWebhook;
669
675
  const stop = () => {
670
676
  if (stopped) return;
@@ -674,10 +680,6 @@ async function monitorZaloProvider(options) {
674
680
  const stopOnAbort = () => {
675
681
  if (!useWebhook) stop();
676
682
  };
677
- if (abortSignal.aborted) {
678
- stopOnAbort();
679
- return;
680
- }
681
683
  abortSignal.addEventListener("abort", stopOnAbort, { once: true });
682
684
  runtime.log?.(`[${account.accountId}] Zalo provider init mode=${mode} mediaMaxMb=${String(effectiveMediaMaxMb)}`);
683
685
  try {
@@ -690,44 +692,46 @@ async function monitorZaloProvider(options) {
690
692
  stopHandlers.push(unregisterHostedMediaRoute);
691
693
  }
692
694
  if (useWebhook) {
693
- const { createZaloWebhookIngress, registerZaloWebhookTarget } = await loadZaloWebhookModule();
695
+ const { registerZaloWebhookTarget } = await loadZaloWebhookModule();
694
696
  if (!effectiveWebhookUrl || !webhookSecret) throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
695
697
  if (!effectiveWebhookUrl.startsWith("https://")) throw new Error("Zalo webhook URL must use HTTPS");
696
698
  if (webhookSecret.length < 8 || webhookSecret.length > 256) throw new Error("Zalo webhook secret must be 8-256 characters");
697
699
  const path = effectiveWebhookPath;
698
700
  if (!path) throw new Error("Zalo webhookPath could not be derived");
699
701
  runtime.log?.(`[${account.accountId}] Zalo configuring webhook path=${path} target=${describeWebhookTarget(effectiveWebhookUrl)}`);
700
- const ingress = createZaloWebhookIngress({
701
- accountId: account.accountId,
702
- runtime,
703
- deliver: async (update, turnAdoptionLifecycle) => {
704
- statusSink?.({ lastInboundAt: Date.now() });
705
- await processUpdate({
706
- update,
707
- token,
708
- account,
709
- config,
710
- runtime,
711
- core,
712
- mediaMaxMb: effectiveMediaMaxMb,
713
- canHostMedia,
714
- webhookUrl: effectiveWebhookUrl,
715
- webhookPath: path,
716
- statusSink,
717
- fetcher,
718
- turnAdoptionLifecycle
719
- });
720
- }
721
- });
722
- ingress.start();
723
- asyncStopHandlers.push(ingress.stop);
702
+ await setWebhook(token, {
703
+ url: effectiveWebhookUrl,
704
+ secret_token: webhookSecret
705
+ }, fetcher);
706
+ let webhookCleanupPromise;
707
+ cleanupWebhook = async () => {
708
+ if (!webhookCleanupPromise) webhookCleanupPromise = (async () => {
709
+ runtime.log?.(`[${account.accountId}] Zalo stopping; deleting webhook`);
710
+ try {
711
+ await deleteWebhook(token, fetcher, WEBHOOK_CLEANUP_TIMEOUT_MS);
712
+ runtime.log?.(`[${account.accountId}] Zalo webhook deleted`);
713
+ } catch (err) {
714
+ const detail = err instanceof Error && err.name === "AbortError" ? `timed out after ${String(WEBHOOK_CLEANUP_TIMEOUT_MS)}ms` : formatZaloError(err);
715
+ runtime.error?.(`[${account.accountId}] Zalo webhook delete failed: ${detail}`);
716
+ }
717
+ })();
718
+ await webhookCleanupPromise;
719
+ };
720
+ runtime.log?.(`[${account.accountId}] Zalo webhook registered path=${path}`);
724
721
  const unregister = registerZaloWebhookTarget({
722
+ token,
725
723
  account,
726
724
  config,
727
725
  runtime,
726
+ core,
728
727
  path,
728
+ webhookUrl: effectiveWebhookUrl,
729
+ webhookPath: path,
729
730
  secret: webhookSecret,
730
- acceptWebhook: ingress.accept
731
+ statusSink: (patch) => statusSink?.(patch),
732
+ mediaMaxMb: effectiveMediaMaxMb,
733
+ canHostMedia,
734
+ fetcher
731
735
  }, { route: {
732
736
  auth: "plugin",
733
737
  match: "exact",
@@ -744,25 +748,6 @@ async function monitorZaloProvider(options) {
744
748
  }
745
749
  } });
746
750
  stopHandlers.push(unregister);
747
- await setWebhook(token, {
748
- url: effectiveWebhookUrl,
749
- secret_token: webhookSecret
750
- }, fetcher);
751
- let webhookCleanupPromise;
752
- cleanupWebhook = async () => {
753
- if (!webhookCleanupPromise) webhookCleanupPromise = (async () => {
754
- runtime.log?.(`[${account.accountId}] Zalo stopping; deleting webhook`);
755
- try {
756
- await deleteWebhook(token, fetcher, WEBHOOK_CLEANUP_TIMEOUT_MS);
757
- runtime.log?.(`[${account.accountId}] Zalo webhook deleted`);
758
- } catch (err) {
759
- const detail = err instanceof Error && err.name === "AbortError" ? `timed out after ${String(WEBHOOK_CLEANUP_TIMEOUT_MS)}ms` : formatZaloError(err);
760
- runtime.error?.(`[${account.accountId}] Zalo webhook delete failed: ${detail}`);
761
- }
762
- })();
763
- await webhookCleanupPromise;
764
- };
765
- runtime.log?.(`[${account.accountId}] Zalo webhook registered path=${path}`);
766
751
  await waitForAbortSignal(abortSignal);
767
752
  return;
768
753
  }
@@ -806,7 +791,6 @@ async function monitorZaloProvider(options) {
806
791
  abortSignal.removeEventListener("abort", stopOnAbort);
807
792
  await cleanupWebhook?.();
808
793
  stop();
809
- for (const stopAsync of asyncStopHandlers) await stopAsync();
810
794
  runtime.log?.(`[${account.accountId}] Zalo provider stopped mode=${mode}`);
811
795
  }
812
796
  }
@@ -1,16 +1,18 @@
1
- import { B as registerWebhookTargetWithPluginRoute, G as resolveWebhookTargetWithAuthOrRejectSync, V as resolveClientIp, Z as withResolvedWebhookRequestPipeline, a as WEBHOOK_ANOMALY_COUNTER_DEFAULTS, b as createFixedWindowRateLimiter, l as applyBasicWebhookRequestGuards, o as WEBHOOK_RATE_LIMIT_DEFAULTS, x as createWebhookAnomalyTracker, z as registerWebhookTarget } from "./runtime-api-qxdOyk3M.js";
2
- import { ZaloWebhookPayloadError } from "./webhook-spool-DzCPbb3O.js";
1
+ import { B as registerWebhookTargetWithPluginRoute, I as readJsonWebhookBodyOrReject, K as resolveWebhookTargetWithAuthOrRejectSync, Q as withResolvedWebhookRequestPipeline, V as resolveClientIp, a as WEBHOOK_ANOMALY_COUNTER_DEFAULTS, b as createFixedWindowRateLimiter, l as applyBasicWebhookRequestGuards, o as WEBHOOK_RATE_LIMIT_DEFAULTS, x as createWebhookAnomalyTracker, z as registerWebhookTarget } from "./runtime-api-CAA4ZtqF.mjs";
2
+ import { createClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
3
3
  import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
4
- import { readWebhookBodyOrReject } from "openclaw/plugin-sdk/webhook-request-guards";
5
4
  //#region extensions/zalo/src/monitor.webhook.ts
6
- const ZALO_WEBHOOK_ACCEPTED_HEADER = "x-openclaw-delivery-accepted";
7
- const ZALO_WEBHOOK_ACCEPTED_VALUE = "durable";
5
+ const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 3e5;
8
6
  const webhookTargets = /* @__PURE__ */ new Map();
9
7
  const webhookRateLimiter = createFixedWindowRateLimiter({
10
8
  windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs,
11
9
  maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests,
12
10
  maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys
13
11
  });
12
+ const recentWebhookEvents = createClaimableDedupe({
13
+ ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS,
14
+ memoryMaxSize: 5e3
15
+ });
14
16
  const webhookAnomalyTracker = createWebhookAnomalyTracker({
15
17
  maxTrackedKeys: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.maxTrackedKeys,
16
18
  ttlMs: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.ttlMs,
@@ -18,6 +20,7 @@ const webhookAnomalyTracker = createWebhookAnomalyTracker({
18
20
  });
19
21
  function clearZaloWebhookSecurityStateForTest() {
20
22
  webhookRateLimiter.clear();
23
+ recentWebhookEvents.clearMemory();
21
24
  webhookAnomalyTracker.clear();
22
25
  }
23
26
  function getZaloWebhookRateLimitStateSizeForTest() {
@@ -26,6 +29,47 @@ function getZaloWebhookRateLimitStateSizeForTest() {
26
29
  function getZaloWebhookStatusCounterSizeForTest() {
27
30
  return webhookAnomalyTracker.size();
28
31
  }
32
+ function buildReplayEventCacheKey(target, update) {
33
+ const messageId = update.message?.message_id;
34
+ if (!messageId) return null;
35
+ const chatId = update.message?.chat?.id ?? "";
36
+ const senderId = update.message?.from?.id ?? "";
37
+ return JSON.stringify([
38
+ target.path,
39
+ target.account.accountId,
40
+ update.event_name,
41
+ chatId,
42
+ senderId,
43
+ messageId
44
+ ]);
45
+ }
46
+ var ZaloRetryableWebhookError = class extends Error {
47
+ constructor(message, options) {
48
+ super(message, options);
49
+ this.name = "ZaloRetryableWebhookError";
50
+ }
51
+ };
52
+ async function processZaloReplayGuardedUpdate(params) {
53
+ const replayEventKey = buildReplayEventCacheKey(params.target, params.update);
54
+ if (replayEventKey) {
55
+ if ((await recentWebhookEvents.claim(replayEventKey, { now: params.nowMs })).kind !== "claimed") return "duplicate";
56
+ }
57
+ params.target.statusSink?.({ lastInboundAt: Date.now() });
58
+ try {
59
+ await params.processUpdate({
60
+ update: params.update,
61
+ target: params.target
62
+ });
63
+ if (replayEventKey) await recentWebhookEvents.commit(replayEventKey);
64
+ return "processed";
65
+ } catch (error) {
66
+ if (replayEventKey) {
67
+ if (error instanceof ZaloRetryableWebhookError) recentWebhookEvents.release(replayEventKey, { error });
68
+ else await recentWebhookEvents.commit(replayEventKey);
69
+ }
70
+ throw error;
71
+ }
72
+ }
29
73
  function recordWebhookStatus(runtime, path, statusCode) {
30
74
  webhookAnomalyTracker.record({
31
75
  key: `${path}:${statusCode}`,
@@ -46,7 +90,7 @@ function registerZaloWebhookTarget(target, opts) {
46
90
  }).unregister;
47
91
  return registerWebhookTarget(webhookTargets, target, opts).unregister;
48
92
  }
49
- async function handleZaloWebhookRequest(req, res) {
93
+ async function handleZaloWebhookRequest(req, res, processUpdate) {
50
94
  return await withResolvedWebhookRequestPipeline({
51
95
  req,
52
96
  res,
@@ -62,12 +106,13 @@ async function handleZaloWebhookRequest(req, res) {
62
106
  trustedProxies,
63
107
  allowRealIpFallback
64
108
  }) ?? req.socket.remoteAddress ?? "unknown"}`;
109
+ const nowMs = Date.now();
65
110
  if (!applyBasicWebhookRequestGuards({
66
111
  req,
67
112
  res,
68
113
  rateLimiter: webhookRateLimiter,
69
114
  rateLimitKey,
70
- nowMs: Date.now()
115
+ nowMs
71
116
  })) {
72
117
  recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
73
118
  return true;
@@ -90,39 +135,40 @@ async function handleZaloWebhookRequest(req, res) {
90
135
  recordWebhookStatus(target.runtime, path, res.statusCode);
91
136
  return true;
92
137
  }
93
- const body = await readWebhookBodyOrReject({
138
+ const body = await readJsonWebhookBodyOrReject({
94
139
  req,
95
140
  res,
96
- maxBytes: 1024 * 1024,
141
+ maxBytes: 1048576,
97
142
  timeoutMs: 3e4,
98
- invalidBodyMessage: "Bad Request"
143
+ emptyObjectOnEmpty: false,
144
+ invalidJsonMessage: "Bad Request"
99
145
  });
100
146
  if (!body.ok) {
101
147
  recordWebhookStatus(target.runtime, path, res.statusCode);
102
148
  return true;
103
149
  }
104
- try {
105
- await target.acceptWebhook(body.value);
106
- } catch (error) {
107
- res.statusCode = error instanceof ZaloWebhookPayloadError ? 400 : 500;
108
- res.end(res.statusCode === 400 ? "Bad Request" : "Internal Server Error");
150
+ const raw = body.value;
151
+ const record = raw && typeof raw === "object" ? raw : null;
152
+ const update = record && record.ok === true && record.result ? record.result : record ?? void 0;
153
+ if (!update?.event_name) {
154
+ res.statusCode = 400;
155
+ res.end("Bad Request");
109
156
  recordWebhookStatus(target.runtime, path, res.statusCode);
110
- target.runtime.error?.(`[${target.account.accountId}] Zalo webhook admission failed: ${String(error)}`);
111
157
  return true;
112
158
  }
113
- res.setHeader(ZALO_WEBHOOK_ACCEPTED_HEADER, ZALO_WEBHOOK_ACCEPTED_VALUE);
159
+ processZaloReplayGuardedUpdate({
160
+ target,
161
+ update,
162
+ processUpdate,
163
+ nowMs
164
+ }).catch((err) => {
165
+ target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
166
+ });
114
167
  res.statusCode = 200;
115
168
  res.end("ok");
116
169
  return true;
117
170
  }
118
171
  });
119
172
  }
120
- const zaloWebhookRuntime = {
121
- clearZaloWebhookSecurityStateForTest,
122
- getZaloWebhookRateLimitStateSizeForTest,
123
- getZaloWebhookStatusCounterSizeForTest,
124
- handleZaloWebhookRequest,
125
- registerZaloWebhookTarget
126
- };
127
173
  //#endregion
128
- export { zaloWebhookRuntime };
174
+ export { ZaloRetryableWebhookError, clearZaloWebhookSecurityStateForTest, getZaloWebhookRateLimitStateSizeForTest, getZaloWebhookStatusCounterSizeForTest, handleZaloWebhookRequest, registerZaloWebhookTarget };
@@ -6,10 +6,11 @@ import { buildBaseAccountStatusSnapshot } from "openclaw/plugin-sdk/status-helpe
6
6
  import { chunkTextForOutbound as chunkTextForOutbound$1 } from "openclaw/plugin-sdk/text-chunking";
7
7
  import { DEFAULT_ACCOUNT_ID as DEFAULT_ACCOUNT_ID$1, buildChannelConfigSchema, createDedupeCache, formatPairingApproveHint, jsonResult, normalizeAccountId as normalizeAccountId$1, readStringParam, resolveClientIp } from "openclaw/plugin-sdk/core";
8
8
  import { buildSecretInputSchema, hasConfiguredSecretInput as hasConfiguredSecretInput$1, normalizeResolvedSecretInputString, normalizeSecretInputString } from "openclaw/plugin-sdk/secret-input";
9
- import { addWildcardAllowFrom, applyAccountNameToChannelSection, applySetupAccountConfigPatch, buildSingleChannelSecretPromptState as buildSingleChannelSecretPromptState$1, mergeAllowFromEntries as mergeAllowFromEntries$1, migrateBaseNameToDefaultAccount, promptSingleChannelSecretInput as promptSingleChannelSecretInput$1, runSingleChannelSecretStep as runSingleChannelSecretStep$1, setTopLevelChannelDmPolicyWithAllowFrom } from "openclaw/plugin-sdk/setup";
9
+ import { addWildcardAllowFrom as addWildcardAllowFrom$1, applyAccountNameToChannelSection, applySetupAccountConfigPatch, buildSingleChannelSecretPromptState as buildSingleChannelSecretPromptState$1, mergeAllowFromEntries as mergeAllowFromEntries$1, migrateBaseNameToDefaultAccount, promptSingleChannelSecretInput as promptSingleChannelSecretInput$1, runSingleChannelSecretStep as runSingleChannelSecretStep$1, setTopLevelChannelDmPolicyWithAllowFrom } from "openclaw/plugin-sdk/setup";
10
10
  import { resolveDefaultGroupPolicy as resolveDefaultGroupPolicy$1, resolveOpenProviderRuntimeGroupPolicy as resolveOpenProviderRuntimeGroupPolicy$1, warnMissingProviderGroupPolicyFallbackOnce as warnMissingProviderGroupPolicyFallbackOnce$1 } from "openclaw/plugin-sdk/runtime-group-policy";
11
11
  import { createChannelPairingController as createChannelPairingController$1 } from "openclaw/plugin-sdk/channel-pairing";
12
12
  import { logTypingFailure as logTypingFailure$1 } from "openclaw/plugin-sdk/channel-feedback";
13
+ import { resolveInboundRouteEnvelopeBuilderWithRuntime as resolveInboundRouteEnvelopeBuilderWithRuntime$1 } from "openclaw/plugin-sdk/inbound-envelope";
13
14
  import { waitForAbortSignal } from "openclaw/plugin-sdk/runtime";
14
15
  import { WEBHOOK_ANOMALY_COUNTER_DEFAULTS, WEBHOOK_RATE_LIMIT_DEFAULTS, applyBasicWebhookRequestGuards, createFixedWindowRateLimiter, createWebhookAnomalyTracker, readJsonWebhookBodyOrReject, registerPluginHttpRoute as registerPluginHttpRoute$1, registerWebhookTarget, registerWebhookTargetWithPluginRoute, resolveWebhookPath as resolveWebhookPath$1, resolveWebhookTargetWithAuthOrRejectSync, withResolvedWebhookRequestPipeline } from "openclaw/plugin-sdk/webhook-ingress";
15
16
  import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
@@ -19,4 +20,4 @@ const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } = createPluginR
19
20
  errorMessage: "Zalo runtime not initialized"
20
21
  });
21
22
  //#endregion
22
- export { mergeAllowFromEntries$1 as A, registerWebhookTargetWithPluginRoute as B, formatAllowFromLowercase$1 as C, isNumericTargetId as D, isNormalizedSenderAllowed as E, promptSingleChannelSecretInput$1 as F, resolveWebhookTargetWithAuthOrRejectSync as G, resolveDefaultGroupPolicy$1 as H, readJsonWebhookBodyOrReject as I, setTopLevelChannelDmPolicyWithAllowFrom as J, runSingleChannelSecretStep$1 as K, readStringParam as L, normalizeAccountId$1 as M, normalizeResolvedSecretInputString as N, jsonResult as O, normalizeSecretInputString as P, registerPluginHttpRoute$1 as R, deliverTextOrMediaReply$1 as S, hasConfiguredSecretInput$1 as T, resolveOpenProviderRuntimeGroupPolicy$1 as U, resolveClientIp as V, resolveWebhookPath$1 as W, warnMissingProviderGroupPolicyFallbackOnce$1 as X, waitForAbortSignal as Y, withResolvedWebhookRequestPipeline as Z, createChannelMessageReplyPipeline as _, WEBHOOK_ANOMALY_COUNTER_DEFAULTS as a, createFixedWindowRateLimiter as b, applyAccountNameToChannelSection as c, buildBaseAccountStatusSnapshot as d, buildChannelConfigSchema as f, chunkTextForOutbound$1 as g, buildTokenChannelStatusSummary$1 as h, PAIRING_APPROVED_MESSAGE as i, migrateBaseNameToDefaultAccount as j, logTypingFailure$1 as k, applyBasicWebhookRequestGuards as l, buildSingleChannelSecretPromptState$1 as m, setZaloRuntime as n, WEBHOOK_RATE_LIMIT_DEFAULTS as o, buildSecretInputSchema as p, sendPayloadWithChunkedTextAndMedia$1 as q, DEFAULT_ACCOUNT_ID$1 as r, addWildcardAllowFrom as s, getZaloRuntime as t, applySetupAccountConfigPatch as u, createChannelPairingController$1 as v, formatPairingApproveHint as w, createWebhookAnomalyTracker as x, createDedupeCache as y, registerWebhookTarget as z };
23
+ export { mergeAllowFromEntries$1 as A, registerWebhookTargetWithPluginRoute as B, formatAllowFromLowercase$1 as C, isNumericTargetId as D, isNormalizedSenderAllowed as E, promptSingleChannelSecretInput$1 as F, resolveWebhookPath$1 as G, resolveDefaultGroupPolicy$1 as H, readJsonWebhookBodyOrReject as I, sendPayloadWithChunkedTextAndMedia$1 as J, resolveWebhookTargetWithAuthOrRejectSync as K, readStringParam as L, normalizeAccountId$1 as M, normalizeResolvedSecretInputString as N, jsonResult as O, normalizeSecretInputString as P, withResolvedWebhookRequestPipeline as Q, registerPluginHttpRoute$1 as R, deliverTextOrMediaReply$1 as S, hasConfiguredSecretInput$1 as T, resolveInboundRouteEnvelopeBuilderWithRuntime$1 as U, resolveClientIp as V, resolveOpenProviderRuntimeGroupPolicy$1 as W, waitForAbortSignal as X, setTopLevelChannelDmPolicyWithAllowFrom as Y, warnMissingProviderGroupPolicyFallbackOnce$1 as Z, createChannelMessageReplyPipeline as _, WEBHOOK_ANOMALY_COUNTER_DEFAULTS as a, createFixedWindowRateLimiter as b, applyAccountNameToChannelSection as c, buildBaseAccountStatusSnapshot as d, buildChannelConfigSchema as f, chunkTextForOutbound$1 as g, buildTokenChannelStatusSummary$1 as h, PAIRING_APPROVED_MESSAGE as i, migrateBaseNameToDefaultAccount as j, logTypingFailure$1 as k, applyBasicWebhookRequestGuards as l, buildSingleChannelSecretPromptState$1 as m, setZaloRuntime as n, WEBHOOK_RATE_LIMIT_DEFAULTS as o, buildSecretInputSchema as p, runSingleChannelSecretStep$1 as q, DEFAULT_ACCOUNT_ID$1 as r, addWildcardAllowFrom$1 as s, getZaloRuntime as t, applySetupAccountConfigPatch as u, createChannelPairingController$1 as v, formatPairingApproveHint as w, createWebhookAnomalyTracker as x, createDedupeCache as y, registerWebhookTarget as z };
@@ -1,10 +1,51 @@
1
- import { collectConditionalChannelFieldAssignments, createChannelSecretTargetRegistryEntries, getChannelSurface, hasOwnProperty } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
1
+ import { collectConditionalChannelFieldAssignments, getChannelSurface, hasOwnProperty } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
2
2
  //#region extensions/zalo/src/secret-contract.ts
3
- const secretTargetRegistryEntries = createChannelSecretTargetRegistryEntries({
4
- channelKey: "zalo",
5
- account: ["botToken", "webhookSecret"],
6
- channel: ["botToken", "webhookSecret"]
7
- });
3
+ const secretTargetRegistryEntries = [
4
+ {
5
+ id: "channels.zalo.accounts.*.botToken",
6
+ targetType: "channels.zalo.accounts.*.botToken",
7
+ configFile: "openclaw.json",
8
+ pathPattern: "channels.zalo.accounts.*.botToken",
9
+ secretShape: "secret_input",
10
+ expectedResolvedValue: "string",
11
+ includeInPlan: true,
12
+ includeInConfigure: true,
13
+ includeInAudit: true
14
+ },
15
+ {
16
+ id: "channels.zalo.accounts.*.webhookSecret",
17
+ targetType: "channels.zalo.accounts.*.webhookSecret",
18
+ configFile: "openclaw.json",
19
+ pathPattern: "channels.zalo.accounts.*.webhookSecret",
20
+ secretShape: "secret_input",
21
+ expectedResolvedValue: "string",
22
+ includeInPlan: true,
23
+ includeInConfigure: true,
24
+ includeInAudit: true
25
+ },
26
+ {
27
+ id: "channels.zalo.botToken",
28
+ targetType: "channels.zalo.botToken",
29
+ configFile: "openclaw.json",
30
+ pathPattern: "channels.zalo.botToken",
31
+ secretShape: "secret_input",
32
+ expectedResolvedValue: "string",
33
+ includeInPlan: true,
34
+ includeInConfigure: true,
35
+ includeInAudit: true
36
+ },
37
+ {
38
+ id: "channels.zalo.webhookSecret",
39
+ targetType: "channels.zalo.webhookSecret",
40
+ configFile: "openclaw.json",
41
+ pathPattern: "channels.zalo.webhookSecret",
42
+ secretShape: "secret_input",
43
+ expectedResolvedValue: "string",
44
+ includeInPlan: true,
45
+ includeInConfigure: true,
46
+ includeInAudit: true
47
+ }
48
+ ];
8
49
  function collectRuntimeConfigAssignments(params) {
9
50
  const resolved = getChannelSurface(params.config, "zalo");
10
51
  if (!resolved) return;
@@ -0,0 +1,3 @@
1
+ import { n as collectRuntimeConfigAssignments, r as secretTargetRegistryEntries, t as channelSecrets } from "./secret-contract-Bno_huZF.mjs";
2
+ import "../secret-contract-api.js";
3
+ export { channelSecrets, collectRuntimeConfigAssignments, secretTargetRegistryEntries };