@openclaw/zalo 2026.2.15 → 2026.2.19

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.2.19
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.2.16
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
3
15
  ## 2026.2.15
4
16
 
5
17
  ### Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/zalo",
3
- "version": "2026.2.15",
3
+ "version": "2026.2.19",
4
4
  "description": "OpenClaw Zalo channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
package/src/accounts.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
2
  import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
3
- import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
4
3
  import { resolveZaloToken } from "./token.js";
4
+ import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
5
5
 
6
6
  export type { ResolvedZaloAccount };
7
7
 
@@ -1,8 +1,16 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import { zaloPlugin } from "./channel.js";
4
4
 
5
5
  describe("zalo directory", () => {
6
+ const runtimeEnv: RuntimeEnv = {
7
+ log: () => {},
8
+ error: () => {},
9
+ exit: (code: number): never => {
10
+ throw new Error(`exit ${code}`);
11
+ },
12
+ };
13
+
6
14
  it("lists peers from allowFrom", async () => {
7
15
  const cfg = {
8
16
  channels: {
@@ -17,11 +25,12 @@ describe("zalo directory", () => {
17
25
  expect(zaloPlugin.directory?.listGroups).toBeTruthy();
18
26
 
19
27
  await expect(
20
- zaloPlugin.directory!.listPeers({
28
+ zaloPlugin.directory!.listPeers!({
21
29
  cfg,
22
30
  accountId: undefined,
23
31
  query: undefined,
24
32
  limit: undefined,
33
+ runtime: runtimeEnv,
25
34
  }),
26
35
  ).resolves.toEqual(
27
36
  expect.arrayContaining([
@@ -32,11 +41,12 @@ describe("zalo directory", () => {
32
41
  );
33
42
 
34
43
  await expect(
35
- zaloPlugin.directory!.listGroups({
44
+ zaloPlugin.directory!.listGroups!({
36
45
  cfg,
37
46
  accountId: undefined,
38
47
  query: undefined,
39
48
  limit: undefined,
49
+ runtime: runtimeEnv,
40
50
  }),
41
51
  ).resolves.toEqual([]);
42
52
  });
package/src/monitor.ts CHANGED
@@ -1,10 +1,14 @@
1
+ import { timingSafeEqual } from "node:crypto";
1
2
  import type { IncomingMessage, ServerResponse } from "node:http";
2
3
  import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk";
3
4
  import {
4
5
  createReplyPrefixOptions,
5
- normalizeWebhookPath,
6
6
  readJsonBodyWithLimit,
7
+ registerWebhookTarget,
8
+ rejectNonPostWebhookRequest,
9
+ resolveSenderCommandAuthorization,
7
10
  resolveWebhookPath,
11
+ resolveWebhookTargets,
8
12
  requestBodyErrorToText,
9
13
  } from "openclaw/plugin-sdk";
10
14
  import type { ResolvedZaloAccount } from "./accounts.js";
@@ -47,8 +51,13 @@ export type ZaloMonitorResult = {
47
51
 
48
52
  const ZALO_TEXT_LIMIT = 2000;
49
53
  const DEFAULT_MEDIA_MAX_MB = 5;
54
+ const ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000;
55
+ const ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120;
56
+ const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000;
57
+ const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25;
50
58
 
51
59
  type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
60
+ type WebhookRateLimitState = { count: number; windowStartMs: number };
52
61
 
53
62
  function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
54
63
  if (core.logging.shouldLogVerbose()) {
@@ -81,54 +90,142 @@ type WebhookTarget = {
81
90
  };
82
91
 
83
92
  const webhookTargets = new Map<string, WebhookTarget[]>();
93
+ const webhookRateLimits = new Map<string, WebhookRateLimitState>();
94
+ const recentWebhookEvents = new Map<string, number>();
95
+ const webhookStatusCounters = new Map<string, number>();
84
96
 
85
- export function registerZaloWebhookTarget(target: WebhookTarget): () => void {
86
- const key = normalizeWebhookPath(target.path);
87
- const normalizedTarget = { ...target, path: key };
88
- const existing = webhookTargets.get(key) ?? [];
89
- const next = [...existing, normalizedTarget];
90
- webhookTargets.set(key, next);
91
- return () => {
92
- const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
93
- if (updated.length > 0) {
94
- webhookTargets.set(key, updated);
95
- } else {
96
- webhookTargets.delete(key);
97
+ function isJsonContentType(value: string | string[] | undefined): boolean {
98
+ const first = Array.isArray(value) ? value[0] : value;
99
+ if (!first) {
100
+ return false;
101
+ }
102
+ const mediaType = first.split(";", 1)[0]?.trim().toLowerCase();
103
+ return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json"));
104
+ }
105
+
106
+ function timingSafeEquals(left: string, right: string): boolean {
107
+ const leftBuffer = Buffer.from(left);
108
+ const rightBuffer = Buffer.from(right);
109
+
110
+ if (leftBuffer.length !== rightBuffer.length) {
111
+ const length = Math.max(1, leftBuffer.length, rightBuffer.length);
112
+ const paddedLeft = Buffer.alloc(length);
113
+ const paddedRight = Buffer.alloc(length);
114
+ leftBuffer.copy(paddedLeft);
115
+ rightBuffer.copy(paddedRight);
116
+ timingSafeEqual(paddedLeft, paddedRight);
117
+ return false;
118
+ }
119
+
120
+ return timingSafeEqual(leftBuffer, rightBuffer);
121
+ }
122
+
123
+ function isWebhookRateLimited(key: string, nowMs: number): boolean {
124
+ const state = webhookRateLimits.get(key);
125
+ if (!state || nowMs - state.windowStartMs >= ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS) {
126
+ webhookRateLimits.set(key, { count: 1, windowStartMs: nowMs });
127
+ return false;
128
+ }
129
+
130
+ state.count += 1;
131
+ if (state.count > ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) {
132
+ return true;
133
+ }
134
+ return false;
135
+ }
136
+
137
+ function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean {
138
+ const messageId = update.message?.message_id;
139
+ if (!messageId) {
140
+ return false;
141
+ }
142
+ const key = `${update.event_name}:${messageId}`;
143
+ const seenAt = recentWebhookEvents.get(key);
144
+ recentWebhookEvents.set(key, nowMs);
145
+
146
+ if (seenAt && nowMs - seenAt < ZALO_WEBHOOK_REPLAY_WINDOW_MS) {
147
+ return true;
148
+ }
149
+
150
+ if (recentWebhookEvents.size > 5000) {
151
+ for (const [eventKey, timestamp] of recentWebhookEvents) {
152
+ if (nowMs - timestamp >= ZALO_WEBHOOK_REPLAY_WINDOW_MS) {
153
+ recentWebhookEvents.delete(eventKey);
154
+ }
97
155
  }
98
- };
156
+ }
157
+
158
+ return false;
159
+ }
160
+
161
+ function recordWebhookStatus(
162
+ runtime: ZaloRuntimeEnv | undefined,
163
+ path: string,
164
+ statusCode: number,
165
+ ): void {
166
+ if (![400, 401, 408, 413, 415, 429].includes(statusCode)) {
167
+ return;
168
+ }
169
+ const key = `${path}:${statusCode}`;
170
+ const next = (webhookStatusCounters.get(key) ?? 0) + 1;
171
+ webhookStatusCounters.set(key, next);
172
+ if (next === 1 || next % ZALO_WEBHOOK_COUNTER_LOG_EVERY === 0) {
173
+ runtime?.log?.(
174
+ `[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(next)}`,
175
+ );
176
+ }
177
+ }
178
+
179
+ export function registerZaloWebhookTarget(target: WebhookTarget): () => void {
180
+ return registerWebhookTarget(webhookTargets, target).unregister;
99
181
  }
100
182
 
101
183
  export async function handleZaloWebhookRequest(
102
184
  req: IncomingMessage,
103
185
  res: ServerResponse,
104
186
  ): Promise<boolean> {
105
- const url = new URL(req.url ?? "/", "http://localhost");
106
- const path = normalizeWebhookPath(url.pathname);
107
- const targets = webhookTargets.get(path);
108
- if (!targets || targets.length === 0) {
187
+ const resolved = resolveWebhookTargets(req, webhookTargets);
188
+ if (!resolved) {
109
189
  return false;
110
190
  }
191
+ const { targets } = resolved;
111
192
 
112
- if (req.method !== "POST") {
113
- res.statusCode = 405;
114
- res.setHeader("Allow", "POST");
115
- res.end("Method Not Allowed");
193
+ if (rejectNonPostWebhookRequest(req, res)) {
116
194
  return true;
117
195
  }
118
196
 
119
197
  const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
120
- const matching = targets.filter((entry) => entry.secret === headerToken);
198
+ const matching = targets.filter((entry) => timingSafeEquals(entry.secret, headerToken));
121
199
  if (matching.length === 0) {
122
200
  res.statusCode = 401;
123
201
  res.end("unauthorized");
202
+ recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
124
203
  return true;
125
204
  }
126
205
  if (matching.length > 1) {
127
206
  res.statusCode = 401;
128
207
  res.end("ambiguous webhook target");
208
+ recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
129
209
  return true;
130
210
  }
131
211
  const target = matching[0];
212
+ const path = req.url ?? "<unknown>";
213
+ const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
214
+ const nowMs = Date.now();
215
+
216
+ if (isWebhookRateLimited(rateLimitKey, nowMs)) {
217
+ res.statusCode = 429;
218
+ res.end("Too Many Requests");
219
+ recordWebhookStatus(target.runtime, path, res.statusCode);
220
+ return true;
221
+ }
222
+
223
+ if (!isJsonContentType(req.headers["content-type"])) {
224
+ res.statusCode = 415;
225
+ res.end("Unsupported Media Type");
226
+ recordWebhookStatus(target.runtime, path, res.statusCode);
227
+ return true;
228
+ }
132
229
 
133
230
  const body = await readJsonBodyWithLimit(req, {
134
231
  maxBytes: 1024 * 1024,
@@ -138,11 +235,14 @@ export async function handleZaloWebhookRequest(
138
235
  if (!body.ok) {
139
236
  res.statusCode =
140
237
  body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
141
- res.end(
142
- body.code === "REQUEST_BODY_TIMEOUT"
143
- ? requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
144
- : body.error,
145
- );
238
+ const message =
239
+ body.code === "PAYLOAD_TOO_LARGE"
240
+ ? requestBodyErrorToText("PAYLOAD_TOO_LARGE")
241
+ : body.code === "REQUEST_BODY_TIMEOUT"
242
+ ? requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
243
+ : "Bad Request";
244
+ res.end(message);
245
+ recordWebhookStatus(target.runtime, path, res.statusCode);
146
246
  return true;
147
247
  }
148
248
 
@@ -156,7 +256,14 @@ export async function handleZaloWebhookRequest(
156
256
 
157
257
  if (!update?.event_name) {
158
258
  res.statusCode = 400;
159
- res.end("invalid payload");
259
+ res.end("Bad Request");
260
+ recordWebhookStatus(target.runtime, path, res.statusCode);
261
+ return true;
262
+ }
263
+
264
+ if (isReplayEvent(update, nowMs)) {
265
+ res.statusCode = 200;
266
+ res.end("ok");
160
267
  return true;
161
268
  }
162
269
 
@@ -402,22 +509,20 @@ async function processMessageWithPipeline(params: {
402
509
  const dmPolicy = account.config.dmPolicy ?? "pairing";
403
510
  const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
404
511
  const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
405
- const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
406
- const storeAllowFrom =
407
- !isGroup && (dmPolicy !== "open" || shouldComputeAuth)
408
- ? await core.channel.pairing.readAllowFromStore("zalo").catch(() => [])
409
- : [];
410
- const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
411
- const useAccessGroups = config.commands?.useAccessGroups !== false;
412
- const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
413
- const commandAuthorized = shouldComputeAuth
414
- ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
415
- useAccessGroups,
416
- authorizers: [
417
- { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
418
- ],
419
- })
420
- : undefined;
512
+ const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
513
+ cfg: config,
514
+ rawBody,
515
+ isGroup,
516
+ dmPolicy,
517
+ configuredAllowFrom: configAllowFrom,
518
+ senderId,
519
+ isSenderAllowed,
520
+ readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo"),
521
+ shouldComputeCommandAuthorized: (body, cfg) =>
522
+ core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
523
+ resolveCommandAuthorizedFromAuthorizers: (params) =>
524
+ core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params),
525
+ });
421
526
 
422
527
  if (!isGroup) {
423
528
  if (dmPolicy === "disabled") {
@@ -1,14 +1,11 @@
1
+ import { createServer, type RequestListener } from "node:http";
1
2
  import type { AddressInfo } from "node:net";
2
3
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
3
- import { createServer } from "node:http";
4
4
  import { describe, expect, it, vi } from "vitest";
5
- import type { ResolvedZaloAccount } from "./types.js";
6
5
  import { handleZaloWebhookRequest, registerZaloWebhookTarget } from "./monitor.js";
6
+ import type { ResolvedZaloAccount } from "./types.js";
7
7
 
8
- async function withServer(
9
- handler: Parameters<typeof createServer>[0],
10
- fn: (baseUrl: string) => Promise<void>,
11
- ) {
8
+ async function withServer(handler: RequestListener, fn: (baseUrl: string) => Promise<void>) {
12
9
  const server = createServer(handler);
13
10
  await new Promise<void>((resolve) => {
14
11
  server.listen(0, "127.0.0.1", () => resolve());
@@ -59,11 +56,13 @@ describe("handleZaloWebhookRequest", () => {
59
56
  method: "POST",
60
57
  headers: {
61
58
  "x-bot-api-secret-token": "secret",
59
+ "content-type": "application/json",
62
60
  },
63
61
  body: "null",
64
62
  });
65
63
 
66
64
  expect(response.status).toBe(400);
65
+ expect(await response.text()).toBe("Bad Request");
67
66
  },
68
67
  );
69
68
  } finally {
@@ -134,4 +133,175 @@ describe("handleZaloWebhookRequest", () => {
134
133
  unregisterB();
135
134
  }
136
135
  });
136
+
137
+ it("returns 415 for non-json content-type", async () => {
138
+ const core = {} as PluginRuntime;
139
+ const account: ResolvedZaloAccount = {
140
+ accountId: "default",
141
+ enabled: true,
142
+ token: "tok",
143
+ tokenSource: "config",
144
+ config: {},
145
+ };
146
+ const unregister = registerZaloWebhookTarget({
147
+ token: "tok",
148
+ account,
149
+ config: {} as OpenClawConfig,
150
+ runtime: {},
151
+ core,
152
+ secret: "secret",
153
+ path: "/hook-content-type",
154
+ mediaMaxMb: 5,
155
+ });
156
+
157
+ try {
158
+ await withServer(
159
+ async (req, res) => {
160
+ const handled = await handleZaloWebhookRequest(req, res);
161
+ if (!handled) {
162
+ res.statusCode = 404;
163
+ res.end("not found");
164
+ }
165
+ },
166
+ async (baseUrl) => {
167
+ const response = await fetch(`${baseUrl}/hook-content-type`, {
168
+ method: "POST",
169
+ headers: {
170
+ "x-bot-api-secret-token": "secret",
171
+ "content-type": "text/plain",
172
+ },
173
+ body: "{}",
174
+ });
175
+
176
+ expect(response.status).toBe(415);
177
+ },
178
+ );
179
+ } finally {
180
+ unregister();
181
+ }
182
+ });
183
+
184
+ it("deduplicates webhook replay by event_name + message_id", async () => {
185
+ const core = {} as PluginRuntime;
186
+ const account: ResolvedZaloAccount = {
187
+ accountId: "default",
188
+ enabled: true,
189
+ token: "tok",
190
+ tokenSource: "config",
191
+ config: {},
192
+ };
193
+ const sink = vi.fn();
194
+ const unregister = registerZaloWebhookTarget({
195
+ token: "tok",
196
+ account,
197
+ config: {} as OpenClawConfig,
198
+ runtime: {},
199
+ core,
200
+ secret: "secret",
201
+ path: "/hook-replay",
202
+ mediaMaxMb: 5,
203
+ statusSink: sink,
204
+ });
205
+
206
+ const payload = {
207
+ event_name: "message.text.received",
208
+ message: {
209
+ from: { id: "123" },
210
+ chat: { id: "123", chat_type: "PRIVATE" },
211
+ message_id: "msg-replay-1",
212
+ date: Math.floor(Date.now() / 1000),
213
+ text: "hello",
214
+ },
215
+ };
216
+
217
+ try {
218
+ await withServer(
219
+ async (req, res) => {
220
+ const handled = await handleZaloWebhookRequest(req, res);
221
+ if (!handled) {
222
+ res.statusCode = 404;
223
+ res.end("not found");
224
+ }
225
+ },
226
+ async (baseUrl) => {
227
+ const first = await fetch(`${baseUrl}/hook-replay`, {
228
+ method: "POST",
229
+ headers: {
230
+ "x-bot-api-secret-token": "secret",
231
+ "content-type": "application/json",
232
+ },
233
+ body: JSON.stringify(payload),
234
+ });
235
+ const second = await fetch(`${baseUrl}/hook-replay`, {
236
+ method: "POST",
237
+ headers: {
238
+ "x-bot-api-secret-token": "secret",
239
+ "content-type": "application/json",
240
+ },
241
+ body: JSON.stringify(payload),
242
+ });
243
+
244
+ expect(first.status).toBe(200);
245
+ expect(second.status).toBe(200);
246
+ expect(sink).toHaveBeenCalledTimes(1);
247
+ },
248
+ );
249
+ } finally {
250
+ unregister();
251
+ }
252
+ });
253
+
254
+ it("returns 429 when per-path request rate exceeds threshold", async () => {
255
+ const core = {} as PluginRuntime;
256
+ const account: ResolvedZaloAccount = {
257
+ accountId: "default",
258
+ enabled: true,
259
+ token: "tok",
260
+ tokenSource: "config",
261
+ config: {},
262
+ };
263
+ const unregister = registerZaloWebhookTarget({
264
+ token: "tok",
265
+ account,
266
+ config: {} as OpenClawConfig,
267
+ runtime: {},
268
+ core,
269
+ secret: "secret",
270
+ path: "/hook-rate",
271
+ mediaMaxMb: 5,
272
+ });
273
+
274
+ try {
275
+ await withServer(
276
+ async (req, res) => {
277
+ const handled = await handleZaloWebhookRequest(req, res);
278
+ if (!handled) {
279
+ res.statusCode = 404;
280
+ res.end("not found");
281
+ }
282
+ },
283
+ async (baseUrl) => {
284
+ let saw429 = false;
285
+ for (let i = 0; i < 130; i += 1) {
286
+ const response = await fetch(`${baseUrl}/hook-rate`, {
287
+ method: "POST",
288
+ headers: {
289
+ "x-bot-api-secret-token": "secret",
290
+ "content-type": "application/json",
291
+ },
292
+ body: "{}",
293
+ });
294
+ if (response.status === 429) {
295
+ saw429 = true;
296
+ break;
297
+ }
298
+ }
299
+
300
+ expect(saw429).toBe(true);
301
+ },
302
+ );
303
+ } finally {
304
+ unregister();
305
+ }
306
+ });
137
307
  });
package/src/onboarding.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  import {
8
8
  addWildcardAllowFrom,
9
9
  DEFAULT_ACCOUNT_ID,
10
+ mergeAllowFromEntries,
10
11
  normalizeAccountId,
11
12
  promptAccountId,
12
13
  } from "openclaw/plugin-sdk";
@@ -147,11 +148,7 @@ async function promptZaloAllowFrom(params: {
147
148
  },
148
149
  });
149
150
  const normalized = String(entry).trim();
150
- const merged = [
151
- ...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
152
- normalized,
153
- ];
154
- const unique = [...new Set(merged)];
151
+ const unique = mergeAllowFromEntries(existingAllowFrom, [normalized]);
155
152
 
156
153
  if (accountId === DEFAULT_ACCOUNT_ID) {
157
154
  return {
package/src/send.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
- import type { ZaloFetch } from "./api.js";
3
2
  import { resolveZaloAccount } from "./accounts.js";
3
+ import type { ZaloFetch } from "./api.js";
4
4
  import { sendMessage, sendPhoto } from "./api.js";
5
5
  import { resolveZaloProxyFetch } from "./proxy.js";
6
6
  import { resolveZaloToken } from "./token.js";