@openclaw/zalo 2026.2.21 → 2026.2.23

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.23",
4
4
  "description": "OpenClaw Zalo channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
package/src/actions.ts CHANGED
@@ -3,7 +3,7 @@ import type {
3
3
  ChannelMessageActionName,
4
4
  OpenClawConfig,
5
5
  } from "openclaw/plugin-sdk";
6
- import { jsonResult, readStringParam } from "openclaw/plugin-sdk";
6
+ import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk";
7
7
  import { listEnabledZaloAccounts } from "./accounts.js";
8
8
  import { sendMessageZalo } from "./send.js";
9
9
 
@@ -25,18 +25,7 @@ export const zaloMessageActions: ChannelMessageActionAdapter = {
25
25
  return Array.from(actions);
26
26
  },
27
27
  supportsButtons: () => false,
28
- extractToolSend: ({ args }) => {
29
- const action = typeof args.action === "string" ? args.action.trim() : "";
30
- if (action !== "sendMessage") {
31
- return null;
32
- }
33
- const to = typeof args.to === "string" ? args.to : undefined;
34
- if (!to) {
35
- return null;
36
- }
37
- const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
38
- return { to, accountId };
39
- },
28
+ extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
40
29
  handleAction: async ({ action, params, cfg, accountId }) => {
41
30
  if (action === "send") {
42
31
  const to = readStringParam(params, "to", { required: true });
package/src/channel.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  import {
8
8
  applyAccountNameToChannelSection,
9
9
  buildChannelConfigSchema,
10
+ buildTokenChannelStatusSummary,
10
11
  DEFAULT_ACCOUNT_ID,
11
12
  deleteAccountFromConfigSection,
12
13
  chunkTextForOutbound,
@@ -309,17 +310,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
309
310
  lastError: null,
310
311
  },
311
312
  collectStatusIssues: collectZaloStatusIssues,
312
- buildChannelSummary: ({ snapshot }) => ({
313
- configured: snapshot.configured ?? false,
314
- tokenSource: snapshot.tokenSource ?? "none",
315
- running: snapshot.running ?? false,
316
- mode: snapshot.mode ?? null,
317
- lastStartAt: snapshot.lastStartAt ?? null,
318
- lastStopAt: snapshot.lastStopAt ?? null,
319
- lastError: snapshot.lastError ?? null,
320
- probe: snapshot.probe,
321
- lastProbeAt: snapshot.lastProbeAt ?? null,
322
- }),
313
+ buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
323
314
  probeAccount: async ({ account, timeoutMs }) =>
324
315
  probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)),
325
316
  buildAccountSnapshot: ({ account, runtime }) => {
package/src/monitor.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import type { IncomingMessage, ServerResponse } from "node:http";
3
- import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk";
3
+ import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk";
4
4
  import {
5
+ createDedupeCache,
5
6
  createReplyPrefixOptions,
6
7
  readJsonBodyWithLimit,
7
8
  registerWebhookTarget,
8
9
  rejectNonPostWebhookRequest,
9
10
  resolveSingleWebhookTarget,
10
11
  resolveSenderCommandAuthorization,
12
+ resolveOutboundMediaUrls,
13
+ sendMediaWithLeadingCaption,
11
14
  resolveWebhookPath,
12
15
  resolveWebhookTargets,
13
16
  requestBodyErrorToText,
@@ -92,7 +95,10 @@ type WebhookTarget = {
92
95
 
93
96
  const webhookTargets = new Map<string, WebhookTarget[]>();
94
97
  const webhookRateLimits = new Map<string, WebhookRateLimitState>();
95
- const recentWebhookEvents = new Map<string, number>();
98
+ const recentWebhookEvents = createDedupeCache({
99
+ ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS,
100
+ maxSize: 5000,
101
+ });
96
102
  const webhookStatusCounters = new Map<string, number>();
97
103
 
98
104
  function isJsonContentType(value: string | string[] | undefined): boolean {
@@ -141,22 +147,7 @@ function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean {
141
147
  return false;
142
148
  }
143
149
  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;
150
+ return recentWebhookEvents.check(key, nowMs);
160
151
  }
161
152
 
162
153
  function recordWebhookStatus(
@@ -447,7 +438,7 @@ async function handleImageMessage(
447
438
  if (photo) {
448
439
  try {
449
440
  const maxBytes = mediaMaxMb * 1024 * 1024;
450
- const fetched = await core.channel.media.fetchRemoteMedia({ url: photo });
441
+ const fetched = await core.channel.media.fetchRemoteMedia({ url: photo, maxBytes });
451
442
  const saved = await core.channel.media.saveMediaBuffer(
452
443
  fetched.buffer,
453
444
  fetched.contentType,
@@ -692,7 +683,7 @@ async function processMessageWithPipeline(params: {
692
683
  }
693
684
 
694
685
  async function deliverZaloReply(params: {
695
- payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
686
+ payload: OutboundReplyPayload;
696
687
  token: string;
697
688
  chatId: string;
698
689
  runtime: ZaloRuntimeEnv;
@@ -707,24 +698,18 @@ async function deliverZaloReply(params: {
707
698
  const tableMode = params.tableMode ?? "code";
708
699
  const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
709
700
 
710
- const mediaList = payload.mediaUrls?.length
711
- ? payload.mediaUrls
712
- : payload.mediaUrl
713
- ? [payload.mediaUrl]
714
- : [];
715
-
716
- if (mediaList.length > 0) {
717
- let first = true;
718
- for (const mediaUrl of mediaList) {
719
- const caption = first ? text : undefined;
720
- first = false;
721
- try {
722
- await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
723
- statusSink?.({ lastOutboundAt: Date.now() });
724
- } catch (err) {
725
- runtime.error?.(`Zalo photo send failed: ${String(err)}`);
726
- }
727
- }
701
+ const sentMedia = await sendMediaWithLeadingCaption({
702
+ mediaUrls: resolveOutboundMediaUrls(payload),
703
+ caption: text,
704
+ send: async ({ mediaUrl, caption }) => {
705
+ await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
706
+ statusSink?.({ lastOutboundAt: Date.now() });
707
+ },
708
+ onError: (error) => {
709
+ runtime.error?.(`Zalo photo send failed: ${String(error)}`);
710
+ },
711
+ });
712
+ if (sentMedia) {
728
713
  return;
729
714
  }
730
715
 
@@ -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
  }