@openclaw/zalo 2026.2.24 → 2026.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.3.1
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.2.26
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
15
+ ## 2026.2.25
16
+
17
+ ### Changes
18
+
19
+ - Version alignment with core OpenClaw release numbers.
20
+
3
21
  ## 2026.2.24
4
22
 
5
23
  ### Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/zalo",
3
- "version": "2026.2.24",
3
+ "version": "2026.3.1",
4
4
  "description": "OpenClaw Zalo channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
package/src/accounts.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
- import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
2
+ import {
3
+ DEFAULT_ACCOUNT_ID,
4
+ normalizeAccountId,
5
+ normalizeOptionalAccountId,
6
+ } from "openclaw/plugin-sdk/account-id";
3
7
  import { resolveZaloToken } from "./token.js";
4
8
  import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
5
9
 
@@ -23,8 +27,12 @@ export function listZaloAccountIds(cfg: OpenClawConfig): string[] {
23
27
 
24
28
  export function resolveDefaultZaloAccountId(cfg: OpenClawConfig): string {
25
29
  const zaloConfig = cfg.channels?.zalo as ZaloConfig | undefined;
26
- if (zaloConfig?.defaultAccount?.trim()) {
27
- return zaloConfig.defaultAccount.trim();
30
+ const preferred = normalizeOptionalAccountId(zaloConfig?.defaultAccount);
31
+ if (
32
+ preferred &&
33
+ listZaloAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
34
+ ) {
35
+ return preferred;
28
36
  }
29
37
  const ids = listZaloAccountIds(cfg);
30
38
  if (ids.includes(DEFAULT_ACCOUNT_ID)) {
package/src/monitor.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk";
3
3
  import {
4
+ createScopedPairingAccess,
4
5
  createReplyPrefixOptions,
5
6
  resolveSenderCommandAuthorization,
6
7
  resolveOutboundMediaUrls,
@@ -27,6 +28,9 @@ import {
27
28
  resolveZaloRuntimeGroupPolicy,
28
29
  } from "./group-access.js";
29
30
  import {
31
+ clearZaloWebhookSecurityStateForTest,
32
+ getZaloWebhookRateLimitStateSizeForTest,
33
+ getZaloWebhookStatusCounterSizeForTest,
30
34
  handleZaloWebhookRequest as handleZaloWebhookRequestInternal,
31
35
  registerZaloWebhookTarget as registerZaloWebhookTargetInternal,
32
36
  type ZaloWebhookTarget,
@@ -72,6 +76,12 @@ export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void
72
76
  return registerZaloWebhookTargetInternal(target);
73
77
  }
74
78
 
79
+ export {
80
+ clearZaloWebhookSecurityStateForTest,
81
+ getZaloWebhookRateLimitStateSizeForTest,
82
+ getZaloWebhookStatusCounterSizeForTest,
83
+ };
84
+
75
85
  export async function handleZaloWebhookRequest(
76
86
  req: IncomingMessage,
77
87
  res: ServerResponse,
@@ -142,7 +152,7 @@ function startPollingLoop(params: {
142
152
  if (err instanceof ZaloApiError && err.isPollingTimeout) {
143
153
  // no updates
144
154
  } else if (!isStopped() && !abortSignal.aborted) {
145
- console.error(`[${account.accountId}] Zalo polling error:`, err);
155
+ runtime.error?.(`[${account.accountId}] Zalo polling error: ${String(err)}`);
146
156
  await new Promise((resolve) => setTimeout(resolve, 5000));
147
157
  }
148
158
  }
@@ -189,10 +199,12 @@ async function processUpdate(
189
199
  );
190
200
  break;
191
201
  case "message.sticker.received":
192
- console.log(`[${account.accountId}] Received sticker from ${message.from.id}`);
202
+ logVerbose(core, runtime, `[${account.accountId}] Received sticker from ${message.from.id}`);
193
203
  break;
194
204
  case "message.unsupported.received":
195
- console.log(
205
+ logVerbose(
206
+ core,
207
+ runtime,
196
208
  `[${account.accountId}] Received unsupported message type from ${message.from.id}`,
197
209
  );
198
210
  break;
@@ -258,7 +270,7 @@ async function handleImageMessage(
258
270
  mediaPath = saved.path;
259
271
  mediaType = saved.contentType;
260
272
  } catch (err) {
261
- console.error(`[${account.accountId}] Failed to download Zalo image:`, err);
273
+ runtime.error?.(`[${account.accountId}] Failed to download Zalo image: ${String(err)}`);
262
274
  }
263
275
  }
264
276
 
@@ -303,6 +315,11 @@ async function processMessageWithPipeline(params: {
303
315
  statusSink,
304
316
  fetcher,
305
317
  } = params;
318
+ const pairing = createScopedPairingAccess({
319
+ core,
320
+ channel: "zalo",
321
+ accountId: account.accountId,
322
+ });
306
323
  const { from, chat, message_id, date } = message;
307
324
 
308
325
  const isGroup = chat.chat_type === "GROUP";
@@ -355,9 +372,10 @@ async function processMessageWithPipeline(params: {
355
372
  isGroup,
356
373
  dmPolicy,
357
374
  configuredAllowFrom: configAllowFrom,
375
+ configuredGroupAllowFrom: groupAllowFrom,
358
376
  senderId,
359
377
  isSenderAllowed: isZaloSenderAllowed,
360
- readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo"),
378
+ readAllowFromStore: pairing.readAllowFromStore,
361
379
  shouldComputeCommandAuthorized: (body, cfg) =>
362
380
  core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
363
381
  resolveCommandAuthorizedFromAuthorizers: (params) =>
@@ -375,8 +393,7 @@ async function processMessageWithPipeline(params: {
375
393
 
376
394
  if (!allowed) {
377
395
  if (dmPolicy === "pairing") {
378
- const { code, created } = await core.channel.pairing.upsertPairingRequest({
379
- channel: "zalo",
396
+ const { code, created } = await pairing.upsertPairingRequest({
380
397
  id: senderId,
381
398
  meta: { name: senderName ?? undefined },
382
399
  });
@@ -1,8 +1,14 @@
1
1
  import { createServer, type RequestListener } from "node:http";
2
2
  import type { AddressInfo } from "node:net";
3
3
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
4
- import { describe, expect, it, vi } from "vitest";
5
- import { handleZaloWebhookRequest, registerZaloWebhookTarget } from "./monitor.js";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ clearZaloWebhookSecurityStateForTest,
7
+ getZaloWebhookRateLimitStateSizeForTest,
8
+ getZaloWebhookStatusCounterSizeForTest,
9
+ handleZaloWebhookRequest,
10
+ registerZaloWebhookTarget,
11
+ } from "./monitor.js";
6
12
  import type { ResolvedZaloAccount } from "./types.js";
7
13
 
8
14
  async function withServer(handler: RequestListener, fn: (baseUrl: string) => Promise<void>) {
@@ -56,6 +62,10 @@ function registerTarget(params: {
56
62
  }
57
63
 
58
64
  describe("handleZaloWebhookRequest", () => {
65
+ afterEach(() => {
66
+ clearZaloWebhookSecurityStateForTest();
67
+ });
68
+
59
69
  it("returns 400 for non-object payloads", async () => {
60
70
  const unregister = registerTarget({ path: "/hook" });
61
71
 
@@ -196,4 +206,57 @@ describe("handleZaloWebhookRequest", () => {
196
206
  unregister();
197
207
  }
198
208
  });
209
+
210
+ it("does not grow status counters when query strings churn on unauthorized requests", async () => {
211
+ const unregister = registerTarget({ path: "/hook-query-status" });
212
+
213
+ try {
214
+ await withServer(webhookRequestHandler, async (baseUrl) => {
215
+ for (let i = 0; i < 200; i += 1) {
216
+ const response = await fetch(`${baseUrl}/hook-query-status?nonce=${i}`, {
217
+ method: "POST",
218
+ headers: {
219
+ "x-bot-api-secret-token": "invalid-token",
220
+ "content-type": "application/json",
221
+ },
222
+ body: "{}",
223
+ });
224
+ expect(response.status).toBe(401);
225
+ }
226
+
227
+ expect(getZaloWebhookStatusCounterSizeForTest()).toBe(1);
228
+ });
229
+ } finally {
230
+ unregister();
231
+ }
232
+ });
233
+
234
+ it("rate limits authenticated requests even when query strings churn", async () => {
235
+ const unregister = registerTarget({ path: "/hook-query-rate" });
236
+
237
+ try {
238
+ await withServer(webhookRequestHandler, async (baseUrl) => {
239
+ let saw429 = false;
240
+ for (let i = 0; i < 130; i += 1) {
241
+ const response = await fetch(`${baseUrl}/hook-query-rate?nonce=${i}`, {
242
+ method: "POST",
243
+ headers: {
244
+ "x-bot-api-secret-token": "secret",
245
+ "content-type": "application/json",
246
+ },
247
+ body: "{}",
248
+ });
249
+ if (response.status === 429) {
250
+ saw429 = true;
251
+ break;
252
+ }
253
+ }
254
+
255
+ expect(saw429).toBe(true);
256
+ expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
257
+ });
258
+ } finally {
259
+ unregister();
260
+ }
261
+ });
199
262
  });
@@ -3,23 +3,21 @@ import type { IncomingMessage, ServerResponse } from "node:http";
3
3
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
4
4
  import {
5
5
  createDedupeCache,
6
- readJsonBodyWithLimit,
6
+ createFixedWindowRateLimiter,
7
+ createWebhookAnomalyTracker,
8
+ readJsonWebhookBodyOrReject,
9
+ applyBasicWebhookRequestGuards,
7
10
  registerWebhookTarget,
8
- rejectNonPostWebhookRequest,
9
- requestBodyErrorToText,
10
11
  resolveSingleWebhookTarget,
11
12
  resolveWebhookTargets,
13
+ WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
14
+ WEBHOOK_RATE_LIMIT_DEFAULTS,
12
15
  } from "openclaw/plugin-sdk";
13
16
  import type { ResolvedZaloAccount } from "./accounts.js";
14
17
  import type { ZaloFetch, ZaloUpdate } from "./api.js";
15
18
  import type { ZaloRuntimeEnv } from "./monitor.js";
16
19
 
17
- type WebhookRateLimitState = { count: number; windowStartMs: number };
18
-
19
- const ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000;
20
- const ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120;
21
20
  const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000;
22
- const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25;
23
21
 
24
22
  export type ZaloWebhookTarget = {
25
23
  token: string;
@@ -40,20 +38,32 @@ export type ZaloWebhookProcessUpdate = (params: {
40
38
  }) => Promise<void>;
41
39
 
42
40
  const webhookTargets = new Map<string, ZaloWebhookTarget[]>();
43
- const webhookRateLimits = new Map<string, WebhookRateLimitState>();
41
+ const webhookRateLimiter = createFixedWindowRateLimiter({
42
+ windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs,
43
+ maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests,
44
+ maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys,
45
+ });
44
46
  const recentWebhookEvents = createDedupeCache({
45
47
  ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS,
46
48
  maxSize: 5000,
47
49
  });
48
- const webhookStatusCounters = new Map<string, number>();
50
+ const webhookAnomalyTracker = createWebhookAnomalyTracker({
51
+ maxTrackedKeys: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.maxTrackedKeys,
52
+ ttlMs: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.ttlMs,
53
+ logEvery: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.logEvery,
54
+ });
49
55
 
50
- function isJsonContentType(value: string | string[] | undefined): boolean {
51
- const first = Array.isArray(value) ? value[0] : value;
52
- if (!first) {
53
- return false;
54
- }
55
- const mediaType = first.split(";", 1)[0]?.trim().toLowerCase();
56
- return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json"));
56
+ export function clearZaloWebhookSecurityStateForTest(): void {
57
+ webhookRateLimiter.clear();
58
+ webhookAnomalyTracker.clear();
59
+ }
60
+
61
+ export function getZaloWebhookRateLimitStateSizeForTest(): number {
62
+ return webhookRateLimiter.size();
63
+ }
64
+
65
+ export function getZaloWebhookStatusCounterSizeForTest(): number {
66
+ return webhookAnomalyTracker.size();
57
67
  }
58
68
 
59
69
  function timingSafeEquals(left: string, right: string): boolean {
@@ -73,20 +83,6 @@ function timingSafeEquals(left: string, right: string): boolean {
73
83
  return timingSafeEqual(leftBuffer, rightBuffer);
74
84
  }
75
85
 
76
- function isWebhookRateLimited(key: string, nowMs: number): boolean {
77
- const state = webhookRateLimits.get(key);
78
- if (!state || nowMs - state.windowStartMs >= ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS) {
79
- webhookRateLimits.set(key, { count: 1, windowStartMs: nowMs });
80
- return false;
81
- }
82
-
83
- state.count += 1;
84
- if (state.count > ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) {
85
- return true;
86
- }
87
- return false;
88
- }
89
-
90
86
  function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean {
91
87
  const messageId = update.message?.message_id;
92
88
  if (!messageId) {
@@ -101,17 +97,13 @@ function recordWebhookStatus(
101
97
  path: string,
102
98
  statusCode: number,
103
99
  ): void {
104
- if (![400, 401, 408, 413, 415, 429].includes(statusCode)) {
105
- return;
106
- }
107
- const key = `${path}:${statusCode}`;
108
- const next = (webhookStatusCounters.get(key) ?? 0) + 1;
109
- webhookStatusCounters.set(key, next);
110
- if (next === 1 || next % ZALO_WEBHOOK_COUNTER_LOG_EVERY === 0) {
111
- runtime?.log?.(
112
- `[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(next)}`,
113
- );
114
- }
100
+ webhookAnomalyTracker.record({
101
+ key: `${path}:${statusCode}`,
102
+ statusCode,
103
+ log: runtime?.log,
104
+ message: (count) =>
105
+ `[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(count)}`,
106
+ });
115
107
  }
116
108
 
117
109
  export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
@@ -127,9 +119,15 @@ export async function handleZaloWebhookRequest(
127
119
  if (!resolved) {
128
120
  return false;
129
121
  }
130
- const { targets } = resolved;
122
+ const { targets, path } = resolved;
131
123
 
132
- if (rejectNonPostWebhookRequest(req, res)) {
124
+ if (
125
+ !applyBasicWebhookRequestGuards({
126
+ req,
127
+ res,
128
+ allowMethods: ["POST"],
129
+ })
130
+ ) {
133
131
  return true;
134
132
  }
135
133
 
@@ -140,55 +138,47 @@ export async function handleZaloWebhookRequest(
140
138
  if (matchedTarget.kind === "none") {
141
139
  res.statusCode = 401;
142
140
  res.end("unauthorized");
143
- recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
141
+ recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
144
142
  return true;
145
143
  }
146
144
  if (matchedTarget.kind === "ambiguous") {
147
145
  res.statusCode = 401;
148
146
  res.end("ambiguous webhook target");
149
- recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
147
+ recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
150
148
  return true;
151
149
  }
152
150
  const target = matchedTarget.target;
153
- const path = req.url ?? "<unknown>";
154
151
  const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
155
152
  const nowMs = Date.now();
156
153
 
157
- if (isWebhookRateLimited(rateLimitKey, nowMs)) {
158
- res.statusCode = 429;
159
- res.end("Too Many Requests");
154
+ if (
155
+ !applyBasicWebhookRequestGuards({
156
+ req,
157
+ res,
158
+ rateLimiter: webhookRateLimiter,
159
+ rateLimitKey,
160
+ nowMs,
161
+ requireJsonContentType: true,
162
+ })
163
+ ) {
160
164
  recordWebhookStatus(target.runtime, path, res.statusCode);
161
165
  return true;
162
166
  }
163
-
164
- if (!isJsonContentType(req.headers["content-type"])) {
165
- res.statusCode = 415;
166
- res.end("Unsupported Media Type");
167
- recordWebhookStatus(target.runtime, path, res.statusCode);
168
- return true;
169
- }
170
-
171
- const body = await readJsonBodyWithLimit(req, {
167
+ const body = await readJsonWebhookBodyOrReject({
168
+ req,
169
+ res,
172
170
  maxBytes: 1024 * 1024,
173
171
  timeoutMs: 30_000,
174
172
  emptyObjectOnEmpty: false,
173
+ invalidJsonMessage: "Bad Request",
175
174
  });
176
175
  if (!body.ok) {
177
- res.statusCode =
178
- body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
179
- const message =
180
- body.code === "PAYLOAD_TOO_LARGE"
181
- ? requestBodyErrorToText("PAYLOAD_TOO_LARGE")
182
- : body.code === "REQUEST_BODY_TIMEOUT"
183
- ? requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
184
- : "Bad Request";
185
- res.end(message);
186
176
  recordWebhookStatus(target.runtime, path, res.statusCode);
187
177
  return true;
188
178
  }
179
+ const raw = body.value;
189
180
 
190
181
  // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }.
191
- const raw = body.value;
192
182
  const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
193
183
  const update: ZaloUpdate | undefined =
194
184
  record && record.ok === true && record.result