@openclaw/zalo 2026.2.21 → 2026.2.22

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.2.22
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
3
9
  ## 0.1.0
4
10
 
5
11
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/zalo",
3
- "version": "2026.2.21",
3
+ "version": "2026.2.22",
4
4
  "description": "OpenClaw Zalo channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
package/src/monitor.ts CHANGED
@@ -2,6 +2,7 @@ import { timingSafeEqual } from "node:crypto";
2
2
  import type { IncomingMessage, ServerResponse } from "node:http";
3
3
  import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk";
4
4
  import {
5
+ createDedupeCache,
5
6
  createReplyPrefixOptions,
6
7
  readJsonBodyWithLimit,
7
8
  registerWebhookTarget,
@@ -92,7 +93,10 @@ type WebhookTarget = {
92
93
 
93
94
  const webhookTargets = new Map<string, WebhookTarget[]>();
94
95
  const webhookRateLimits = new Map<string, WebhookRateLimitState>();
95
- const recentWebhookEvents = new Map<string, number>();
96
+ const recentWebhookEvents = createDedupeCache({
97
+ ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS,
98
+ maxSize: 5000,
99
+ });
96
100
  const webhookStatusCounters = new Map<string, number>();
97
101
 
98
102
  function isJsonContentType(value: string | string[] | undefined): boolean {
@@ -141,22 +145,7 @@ function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean {
141
145
  return false;
142
146
  }
143
147
  const key = `${update.event_name}:${messageId}`;
144
- const seenAt = recentWebhookEvents.get(key);
145
- recentWebhookEvents.set(key, nowMs);
146
-
147
- if (seenAt && nowMs - seenAt < ZALO_WEBHOOK_REPLAY_WINDOW_MS) {
148
- return true;
149
- }
150
-
151
- if (recentWebhookEvents.size > 5000) {
152
- for (const [eventKey, timestamp] of recentWebhookEvents) {
153
- if (nowMs - timestamp >= ZALO_WEBHOOK_REPLAY_WINDOW_MS) {
154
- recentWebhookEvents.delete(eventKey);
155
- }
156
- }
157
- }
158
-
159
- return false;
148
+ return recentWebhookEvents.check(key, nowMs);
160
149
  }
161
150
 
162
151
  function recordWebhookStatus(
@@ -447,7 +436,7 @@ async function handleImageMessage(
447
436
  if (photo) {
448
437
  try {
449
438
  const maxBytes = mediaMaxMb * 1024 * 1024;
450
- const fetched = await core.channel.media.fetchRemoteMedia({ url: photo });
439
+ const fetched = await core.channel.media.fetchRemoteMedia({ url: photo, maxBytes });
451
440
  const saved = await core.channel.media.saveMediaBuffer(
452
441
  fetched.buffer,
453
442
  fetched.contentType,
@@ -21,113 +21,84 @@ async function withServer(handler: RequestListener, fn: (baseUrl: string) => Pro
21
21
  }
22
22
  }
23
23
 
24
+ const DEFAULT_ACCOUNT: ResolvedZaloAccount = {
25
+ accountId: "default",
26
+ enabled: true,
27
+ token: "tok",
28
+ tokenSource: "config",
29
+ config: {},
30
+ };
31
+
32
+ const webhookRequestHandler: RequestListener = async (req, res) => {
33
+ const handled = await handleZaloWebhookRequest(req, res);
34
+ if (!handled) {
35
+ res.statusCode = 404;
36
+ res.end("not found");
37
+ }
38
+ };
39
+
40
+ function registerTarget(params: {
41
+ path: string;
42
+ secret?: string;
43
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
44
+ }): () => void {
45
+ return registerZaloWebhookTarget({
46
+ token: "tok",
47
+ account: DEFAULT_ACCOUNT,
48
+ config: {} as OpenClawConfig,
49
+ runtime: {},
50
+ core: {} as PluginRuntime,
51
+ secret: params.secret ?? "secret",
52
+ path: params.path,
53
+ mediaMaxMb: 5,
54
+ statusSink: params.statusSink,
55
+ });
56
+ }
57
+
24
58
  describe("handleZaloWebhookRequest", () => {
25
59
  it("returns 400 for non-object payloads", async () => {
26
- const core = {} as PluginRuntime;
27
- const account: ResolvedZaloAccount = {
28
- accountId: "default",
29
- enabled: true,
30
- token: "tok",
31
- tokenSource: "config",
32
- config: {},
33
- };
34
- const unregister = registerZaloWebhookTarget({
35
- token: "tok",
36
- account,
37
- config: {} as OpenClawConfig,
38
- runtime: {},
39
- core,
40
- secret: "secret",
41
- path: "/hook",
42
- mediaMaxMb: 5,
43
- });
60
+ const unregister = registerTarget({ path: "/hook" });
44
61
 
45
62
  try {
46
- await withServer(
47
- async (req, res) => {
48
- const handled = await handleZaloWebhookRequest(req, res);
49
- if (!handled) {
50
- res.statusCode = 404;
51
- res.end("not found");
52
- }
53
- },
54
- async (baseUrl) => {
55
- const response = await fetch(`${baseUrl}/hook`, {
56
- method: "POST",
57
- headers: {
58
- "x-bot-api-secret-token": "secret",
59
- "content-type": "application/json",
60
- },
61
- body: "null",
62
- });
63
-
64
- expect(response.status).toBe(400);
65
- expect(await response.text()).toBe("Bad Request");
66
- },
67
- );
63
+ await withServer(webhookRequestHandler, async (baseUrl) => {
64
+ const response = await fetch(`${baseUrl}/hook`, {
65
+ method: "POST",
66
+ headers: {
67
+ "x-bot-api-secret-token": "secret",
68
+ "content-type": "application/json",
69
+ },
70
+ body: "null",
71
+ });
72
+
73
+ expect(response.status).toBe(400);
74
+ expect(await response.text()).toBe("Bad Request");
75
+ });
68
76
  } finally {
69
77
  unregister();
70
78
  }
71
79
  });
72
80
 
73
81
  it("rejects ambiguous routing when multiple targets match the same secret", async () => {
74
- const core = {} as PluginRuntime;
75
- const account: ResolvedZaloAccount = {
76
- accountId: "default",
77
- enabled: true,
78
- token: "tok",
79
- tokenSource: "config",
80
- config: {},
81
- };
82
82
  const sinkA = vi.fn();
83
83
  const sinkB = vi.fn();
84
- const unregisterA = registerZaloWebhookTarget({
85
- token: "tok",
86
- account,
87
- config: {} as OpenClawConfig,
88
- runtime: {},
89
- core,
90
- secret: "secret",
91
- path: "/hook",
92
- mediaMaxMb: 5,
93
- statusSink: sinkA,
94
- });
95
- const unregisterB = registerZaloWebhookTarget({
96
- token: "tok",
97
- account,
98
- config: {} as OpenClawConfig,
99
- runtime: {},
100
- core,
101
- secret: "secret",
102
- path: "/hook",
103
- mediaMaxMb: 5,
104
- statusSink: sinkB,
105
- });
84
+ const unregisterA = registerTarget({ path: "/hook", statusSink: sinkA });
85
+ const unregisterB = registerTarget({ path: "/hook", statusSink: sinkB });
106
86
 
107
87
  try {
108
- await withServer(
109
- async (req, res) => {
110
- const handled = await handleZaloWebhookRequest(req, res);
111
- if (!handled) {
112
- res.statusCode = 404;
113
- res.end("not found");
114
- }
115
- },
116
- async (baseUrl) => {
117
- const response = await fetch(`${baseUrl}/hook`, {
118
- method: "POST",
119
- headers: {
120
- "x-bot-api-secret-token": "secret",
121
- "content-type": "application/json",
122
- },
123
- body: "{}",
124
- });
125
-
126
- expect(response.status).toBe(401);
127
- expect(sinkA).not.toHaveBeenCalled();
128
- expect(sinkB).not.toHaveBeenCalled();
129
- },
130
- );
88
+ await withServer(webhookRequestHandler, async (baseUrl) => {
89
+ const response = await fetch(`${baseUrl}/hook`, {
90
+ method: "POST",
91
+ headers: {
92
+ "x-bot-api-secret-token": "secret",
93
+ "content-type": "application/json",
94
+ },
95
+ body: "{}",
96
+ });
97
+
98
+ expect(response.status).toBe(401);
99
+ expect(sinkA).not.toHaveBeenCalled();
100
+ expect(sinkB).not.toHaveBeenCalled();
101
+ });
131
102
  } finally {
132
103
  unregisterA();
133
104
  unregisterB();
@@ -135,73 +106,29 @@ describe("handleZaloWebhookRequest", () => {
135
106
  });
136
107
 
137
108
  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
- });
109
+ const unregister = registerTarget({ path: "/hook-content-type" });
156
110
 
157
111
  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
- );
112
+ await withServer(webhookRequestHandler, async (baseUrl) => {
113
+ const response = await fetch(`${baseUrl}/hook-content-type`, {
114
+ method: "POST",
115
+ headers: {
116
+ "x-bot-api-secret-token": "secret",
117
+ "content-type": "text/plain",
118
+ },
119
+ body: "{}",
120
+ });
121
+
122
+ expect(response.status).toBe(415);
123
+ });
179
124
  } finally {
180
125
  unregister();
181
126
  }
182
127
  });
183
128
 
184
129
  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
130
  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
- });
131
+ const unregister = registerTarget({ path: "/hook-replay", statusSink: sink });
205
132
 
206
133
  const payload = {
207
134
  event_name: "message.text.received",
@@ -215,91 +142,56 @@ describe("handleZaloWebhookRequest", () => {
215
142
  };
216
143
 
217
144
  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
- );
145
+ await withServer(webhookRequestHandler, async (baseUrl) => {
146
+ const first = await fetch(`${baseUrl}/hook-replay`, {
147
+ method: "POST",
148
+ headers: {
149
+ "x-bot-api-secret-token": "secret",
150
+ "content-type": "application/json",
151
+ },
152
+ body: JSON.stringify(payload),
153
+ });
154
+ const second = await fetch(`${baseUrl}/hook-replay`, {
155
+ method: "POST",
156
+ headers: {
157
+ "x-bot-api-secret-token": "secret",
158
+ "content-type": "application/json",
159
+ },
160
+ body: JSON.stringify(payload),
161
+ });
162
+
163
+ expect(first.status).toBe(200);
164
+ expect(second.status).toBe(200);
165
+ expect(sink).toHaveBeenCalledTimes(1);
166
+ });
249
167
  } finally {
250
168
  unregister();
251
169
  }
252
170
  });
253
171
 
254
172
  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
- });
173
+ const unregister = registerTarget({ path: "/hook-rate" });
273
174
 
274
175
  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
- }
176
+ await withServer(webhookRequestHandler, async (baseUrl) => {
177
+ let saw429 = false;
178
+ for (let i = 0; i < 130; i += 1) {
179
+ const response = await fetch(`${baseUrl}/hook-rate`, {
180
+ method: "POST",
181
+ headers: {
182
+ "x-bot-api-secret-token": "secret",
183
+ "content-type": "application/json",
184
+ },
185
+ body: "{}",
186
+ });
187
+ if (response.status === 429) {
188
+ saw429 = true;
189
+ break;
298
190
  }
191
+ }
299
192
 
300
- expect(saw429).toBe(true);
301
- },
302
- );
193
+ expect(saw429).toBe(true);
194
+ });
303
195
  } finally {
304
196
  unregister();
305
197
  }