@openclaw/zalo 2026.3.11 → 2026.3.13

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,8 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.3.13
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.3.12
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
3
15
  ## 2026.3.11
4
16
 
5
17
  ### Changes
18
+
6
19
  - Version alignment with core OpenClaw release numbers.
7
20
 
8
21
  ## 2026.3.10
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@openclaw/zalo",
3
- "version": "2026.3.11",
3
+ "version": "2026.3.13",
4
4
  "description": "OpenClaw Zalo channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
7
- "undici": "7.22.0",
7
+ "undici": "7.24.1",
8
8
  "zod": "^4.3.6"
9
9
  },
10
10
  "openclaw": {
package/src/api.test.ts CHANGED
@@ -1,31 +1,26 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import { deleteWebhook, getWebhookInfo, sendChatAction, type ZaloFetch } from "./api.js";
3
3
 
4
+ function createOkFetcher() {
5
+ return vi.fn<ZaloFetch>(async () => new Response(JSON.stringify({ ok: true, result: {} })));
6
+ }
7
+
8
+ async function expectPostJsonRequest(run: (token: string, fetcher: ZaloFetch) => Promise<unknown>) {
9
+ const fetcher = createOkFetcher();
10
+ await run("test-token", fetcher);
11
+ expect(fetcher).toHaveBeenCalledTimes(1);
12
+ const [, init] = fetcher.mock.calls[0] ?? [];
13
+ expect(init?.method).toBe("POST");
14
+ expect(init?.headers).toEqual({ "Content-Type": "application/json" });
15
+ }
16
+
4
17
  describe("Zalo API request methods", () => {
5
18
  it("uses POST for getWebhookInfo", async () => {
6
- const fetcher = vi.fn<ZaloFetch>(
7
- async () => new Response(JSON.stringify({ ok: true, result: {} })),
8
- );
9
-
10
- await getWebhookInfo("test-token", fetcher);
11
-
12
- expect(fetcher).toHaveBeenCalledTimes(1);
13
- const [, init] = fetcher.mock.calls[0] ?? [];
14
- expect(init?.method).toBe("POST");
15
- expect(init?.headers).toEqual({ "Content-Type": "application/json" });
19
+ await expectPostJsonRequest(getWebhookInfo);
16
20
  });
17
21
 
18
22
  it("keeps POST for deleteWebhook", async () => {
19
- const fetcher = vi.fn<ZaloFetch>(
20
- async () => new Response(JSON.stringify({ ok: true, result: {} })),
21
- );
22
-
23
- await deleteWebhook("test-token", fetcher);
24
-
25
- expect(fetcher).toHaveBeenCalledTimes(1);
26
- const [, init] = fetcher.mock.calls[0] ?? [];
27
- expect(init?.method).toBe("POST");
28
- expect(init?.headers).toEqual({ "Content-Type": "application/json" });
23
+ await expectPostJsonRequest(deleteWebhook);
29
24
  });
30
25
 
31
26
  it("aborts sendChatAction when the typing timeout elapses", async () => {
@@ -1,15 +1,10 @@
1
1
  import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/zalo";
2
2
  import { describe, expect, it } from "vitest";
3
+ import { createDirectoryTestRuntime, expectDirectorySurface } from "../../test-utils/directory.js";
3
4
  import { zaloPlugin } from "./channel.js";
4
5
 
5
6
  describe("zalo directory", () => {
6
- const runtimeEnv: RuntimeEnv = {
7
- log: () => {},
8
- error: () => {},
9
- exit: (code: number): never => {
10
- throw new Error(`exit ${code}`);
11
- },
12
- };
7
+ const runtimeEnv = createDirectoryTestRuntime() as RuntimeEnv;
13
8
 
14
9
  it("lists peers from allowFrom", async () => {
15
10
  const cfg = {
@@ -20,12 +15,10 @@ describe("zalo directory", () => {
20
15
  },
21
16
  } as unknown as OpenClawConfig;
22
17
 
23
- expect(zaloPlugin.directory).toBeTruthy();
24
- expect(zaloPlugin.directory?.listPeers).toBeTruthy();
25
- expect(zaloPlugin.directory?.listGroups).toBeTruthy();
18
+ const directory = expectDirectorySurface(zaloPlugin.directory);
26
19
 
27
20
  await expect(
28
- zaloPlugin.directory!.listPeers!({
21
+ directory.listPeers({
29
22
  cfg,
30
23
  accountId: undefined,
31
24
  query: undefined,
@@ -41,7 +34,7 @@ describe("zalo directory", () => {
41
34
  );
42
35
 
43
36
  await expect(
44
- zaloPlugin.directory!.listGroups!({
37
+ directory.listGroups({
45
38
  cfg,
46
39
  accountId: undefined,
47
40
  query: undefined,
@@ -1,6 +1,9 @@
1
1
  import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/zalo";
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
- import { createStartAccountContext } from "../../test-utils/start-account-context.js";
3
+ import {
4
+ expectPendingUntilAbort,
5
+ startAccountAndTrackLifecycle,
6
+ } from "../../test-utils/start-account-lifecycle.js";
4
7
  import type { ResolvedZaloAccount } from "./accounts.js";
5
8
 
6
9
  const hoisted = vi.hoisted(() => ({
@@ -57,37 +60,28 @@ describe("zaloPlugin gateway.startAccount", () => {
57
60
  }),
58
61
  );
59
62
 
60
- const patches: ChannelAccountSnapshot[] = [];
61
- const abort = new AbortController();
62
- const task = zaloPlugin.gateway!.startAccount!(
63
- createStartAccountContext({
64
- account: buildAccount(),
65
- abortSignal: abort.signal,
66
- statusPatchSink: (next) => patches.push({ ...next }),
67
- }),
68
- );
69
-
70
- let settled = false;
71
- void task.then(() => {
72
- settled = true;
63
+ const { abort, patches, task, isSettled } = startAccountAndTrackLifecycle({
64
+ startAccount: zaloPlugin.gateway!.startAccount!,
65
+ account: buildAccount(),
73
66
  });
74
67
 
75
- await vi.waitFor(() => {
76
- expect(hoisted.probeZalo).toHaveBeenCalledOnce();
77
- expect(hoisted.monitorZaloProvider).toHaveBeenCalledOnce();
68
+ await expectPendingUntilAbort({
69
+ waitForStarted: () =>
70
+ vi.waitFor(() => {
71
+ expect(hoisted.probeZalo).toHaveBeenCalledOnce();
72
+ expect(hoisted.monitorZaloProvider).toHaveBeenCalledOnce();
73
+ }),
74
+ isSettled,
75
+ abort,
76
+ task,
78
77
  });
79
78
 
80
- expect(settled).toBe(false);
81
79
  expect(patches).toContainEqual(
82
80
  expect.objectContaining({
83
81
  accountId: "default",
84
82
  }),
85
83
  );
86
-
87
- abort.abort();
88
- await task;
89
-
90
- expect(settled).toBe(true);
84
+ expect(isSettled()).toBe(true);
91
85
  expect(hoisted.monitorZaloProvider).toHaveBeenCalledWith(
92
86
  expect.objectContaining({
93
87
  token: "test-token",
@@ -32,6 +32,41 @@ async function waitForPollingLoopStart(): Promise<void> {
32
32
  await vi.waitFor(() => expect(getUpdatesMock).toHaveBeenCalledTimes(1));
33
33
  }
34
34
 
35
+ const TEST_ACCOUNT = {
36
+ accountId: "default",
37
+ config: {},
38
+ } as unknown as ResolvedZaloAccount;
39
+
40
+ const TEST_CONFIG = {} as OpenClawConfig;
41
+
42
+ function createLifecycleRuntime() {
43
+ return {
44
+ log: vi.fn<(message: string) => void>(),
45
+ error: vi.fn<(message: string) => void>(),
46
+ };
47
+ }
48
+
49
+ async function startLifecycleMonitor(
50
+ options: {
51
+ useWebhook?: boolean;
52
+ webhookSecret?: string;
53
+ webhookUrl?: string;
54
+ } = {},
55
+ ) {
56
+ const { monitorZaloProvider } = await import("./monitor.js");
57
+ const abort = new AbortController();
58
+ const runtime = createLifecycleRuntime();
59
+ const run = monitorZaloProvider({
60
+ token: "test-token",
61
+ account: TEST_ACCOUNT,
62
+ config: TEST_CONFIG,
63
+ runtime,
64
+ abortSignal: abort.signal,
65
+ ...options,
66
+ });
67
+ return { abort, runtime, run };
68
+ }
69
+
35
70
  describe("monitorZaloProvider lifecycle", () => {
36
71
  afterEach(() => {
37
72
  vi.clearAllMocks();
@@ -39,26 +74,9 @@ describe("monitorZaloProvider lifecycle", () => {
39
74
  });
40
75
 
41
76
  it("stays alive in polling mode until abort", async () => {
42
- const { monitorZaloProvider } = await import("./monitor.js");
43
- const abort = new AbortController();
44
- const runtime = {
45
- log: vi.fn<(message: string) => void>(),
46
- error: vi.fn<(message: string) => void>(),
47
- };
48
- const account = {
49
- accountId: "default",
50
- config: {},
51
- } as unknown as ResolvedZaloAccount;
52
- const config = {} as OpenClawConfig;
53
-
54
77
  let settled = false;
55
- const run = monitorZaloProvider({
56
- token: "test-token",
57
- account,
58
- config,
59
- runtime,
60
- abortSignal: abort.signal,
61
- }).then(() => {
78
+ const { abort, runtime, run } = await startLifecycleMonitor();
79
+ const monitoredRun = run.then(() => {
62
80
  settled = true;
63
81
  });
64
82
 
@@ -70,7 +88,7 @@ describe("monitorZaloProvider lifecycle", () => {
70
88
  expect(settled).toBe(false);
71
89
 
72
90
  abort.abort();
73
- await run;
91
+ await monitoredRun;
74
92
 
75
93
  expect(settled).toBe(true);
76
94
  expect(runtime.log).toHaveBeenCalledWith(
@@ -84,25 +102,7 @@ describe("monitorZaloProvider lifecycle", () => {
84
102
  result: { url: "https://example.com/hooks/zalo" },
85
103
  });
86
104
 
87
- const { monitorZaloProvider } = await import("./monitor.js");
88
- const abort = new AbortController();
89
- const runtime = {
90
- log: vi.fn<(message: string) => void>(),
91
- error: vi.fn<(message: string) => void>(),
92
- };
93
- const account = {
94
- accountId: "default",
95
- config: {},
96
- } as unknown as ResolvedZaloAccount;
97
- const config = {} as OpenClawConfig;
98
-
99
- const run = monitorZaloProvider({
100
- token: "test-token",
101
- account,
102
- config,
103
- runtime,
104
- abortSignal: abort.signal,
105
- });
105
+ const { abort, runtime, run } = await startLifecycleMonitor();
106
106
 
107
107
  await waitForPollingLoopStart();
108
108
 
@@ -120,25 +120,7 @@ describe("monitorZaloProvider lifecycle", () => {
120
120
  const { ZaloApiError } = await import("./api.js");
121
121
  getWebhookInfoMock.mockRejectedValueOnce(new ZaloApiError("Not Found", 404, "Not Found"));
122
122
 
123
- const { monitorZaloProvider } = await import("./monitor.js");
124
- const abort = new AbortController();
125
- const runtime = {
126
- log: vi.fn<(message: string) => void>(),
127
- error: vi.fn<(message: string) => void>(),
128
- };
129
- const account = {
130
- accountId: "default",
131
- config: {},
132
- } as unknown as ResolvedZaloAccount;
133
- const config = {} as OpenClawConfig;
134
-
135
- const run = monitorZaloProvider({
136
- token: "test-token",
137
- account,
138
- config,
139
- runtime,
140
- abortSignal: abort.signal,
141
- });
123
+ const { abort, runtime, run } = await startLifecycleMonitor();
142
124
 
143
125
  await waitForPollingLoopStart();
144
126
 
@@ -165,29 +147,13 @@ describe("monitorZaloProvider lifecycle", () => {
165
147
  }),
166
148
  );
167
149
 
168
- const { monitorZaloProvider } = await import("./monitor.js");
169
- const abort = new AbortController();
170
- const runtime = {
171
- log: vi.fn<(message: string) => void>(),
172
- error: vi.fn<(message: string) => void>(),
173
- };
174
- const account = {
175
- accountId: "default",
176
- config: {},
177
- } as unknown as ResolvedZaloAccount;
178
- const config = {} as OpenClawConfig;
179
-
180
150
  let settled = false;
181
- const run = monitorZaloProvider({
182
- token: "test-token",
183
- account,
184
- config,
185
- runtime,
186
- abortSignal: abort.signal,
151
+ const { abort, runtime, run } = await startLifecycleMonitor({
187
152
  useWebhook: true,
188
153
  webhookUrl: "https://example.com/hooks/zalo",
189
154
  webhookSecret: "supersecret", // pragma: allowlist secret
190
- }).then(() => {
155
+ });
156
+ const monitoredRun = run.then(() => {
191
157
  settled = true;
192
158
  });
193
159
 
@@ -202,7 +168,7 @@ describe("monitorZaloProvider lifecycle", () => {
202
168
  expect(registry.httpRoutes).toHaveLength(1);
203
169
 
204
170
  resolveDeleteWebhook?.();
205
- await run;
171
+ await monitoredRun;
206
172
 
207
173
  expect(settled).toBe(true);
208
174
  expect(registry.httpRoutes).toHaveLength(0);
package/src/monitor.ts CHANGED
@@ -75,6 +75,35 @@ const WEBHOOK_CLEANUP_TIMEOUT_MS = 5_000;
75
75
  const ZALO_TYPING_TIMEOUT_MS = 5_000;
76
76
 
77
77
  type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
78
+ type ZaloStatusSink = (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
79
+ type ZaloProcessingContext = {
80
+ token: string;
81
+ account: ResolvedZaloAccount;
82
+ config: OpenClawConfig;
83
+ runtime: ZaloRuntimeEnv;
84
+ core: ZaloCoreRuntime;
85
+ statusSink?: ZaloStatusSink;
86
+ fetcher?: ZaloFetch;
87
+ };
88
+ type ZaloPollingLoopParams = ZaloProcessingContext & {
89
+ abortSignal: AbortSignal;
90
+ isStopped: () => boolean;
91
+ mediaMaxMb: number;
92
+ };
93
+ type ZaloUpdateProcessingParams = ZaloProcessingContext & {
94
+ update: ZaloUpdate;
95
+ mediaMaxMb: number;
96
+ };
97
+ type ZaloMessagePipelineParams = ZaloProcessingContext & {
98
+ message: ZaloMessage;
99
+ text?: string;
100
+ mediaPath?: string;
101
+ mediaType?: string;
102
+ };
103
+ type ZaloImageMessageParams = ZaloProcessingContext & {
104
+ message: ZaloMessage;
105
+ mediaMaxMb: number;
106
+ };
78
107
 
79
108
  function formatZaloError(error: unknown): string {
80
109
  if (error instanceof Error) {
@@ -135,32 +164,21 @@ export async function handleZaloWebhookRequest(
135
164
  res: ServerResponse,
136
165
  ): Promise<boolean> {
137
166
  return handleZaloWebhookRequestInternal(req, res, async ({ update, target }) => {
138
- await processUpdate(
167
+ await processUpdate({
139
168
  update,
140
- target.token,
141
- target.account,
142
- target.config,
143
- target.runtime,
144
- target.core as ZaloCoreRuntime,
145
- target.mediaMaxMb,
146
- target.statusSink,
147
- target.fetcher,
148
- );
169
+ token: target.token,
170
+ account: target.account,
171
+ config: target.config,
172
+ runtime: target.runtime,
173
+ core: target.core as ZaloCoreRuntime,
174
+ mediaMaxMb: target.mediaMaxMb,
175
+ statusSink: target.statusSink,
176
+ fetcher: target.fetcher,
177
+ });
149
178
  });
150
179
  }
151
180
 
152
- function startPollingLoop(params: {
153
- token: string;
154
- account: ResolvedZaloAccount;
155
- config: OpenClawConfig;
156
- runtime: ZaloRuntimeEnv;
157
- core: ZaloCoreRuntime;
158
- abortSignal: AbortSignal;
159
- isStopped: () => boolean;
160
- mediaMaxMb: number;
161
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
162
- fetcher?: ZaloFetch;
163
- }) {
181
+ function startPollingLoop(params: ZaloPollingLoopParams) {
164
182
  const {
165
183
  token,
166
184
  account,
@@ -174,6 +192,16 @@ function startPollingLoop(params: {
174
192
  fetcher,
175
193
  } = params;
176
194
  const pollTimeout = 30;
195
+ const processingContext = {
196
+ token,
197
+ account,
198
+ config,
199
+ runtime,
200
+ core,
201
+ mediaMaxMb,
202
+ statusSink,
203
+ fetcher,
204
+ };
177
205
 
178
206
  runtime.log?.(`[${account.accountId}] Zalo polling loop started timeout=${String(pollTimeout)}s`);
179
207
 
@@ -186,17 +214,10 @@ function startPollingLoop(params: {
186
214
  const response = await getUpdates(token, { timeout: pollTimeout }, fetcher);
187
215
  if (response.ok && response.result) {
188
216
  statusSink?.({ lastInboundAt: Date.now() });
189
- await processUpdate(
190
- response.result,
191
- token,
192
- account,
193
- config,
194
- runtime,
195
- core,
196
- mediaMaxMb,
197
- statusSink,
198
- fetcher,
199
- );
217
+ await processUpdate({
218
+ update: response.result,
219
+ ...processingContext,
220
+ });
200
221
  }
201
222
  } catch (err) {
202
223
  if (err instanceof ZaloApiError && err.isPollingTimeout) {
@@ -215,38 +236,27 @@ function startPollingLoop(params: {
215
236
  void poll();
216
237
  }
217
238
 
218
- async function processUpdate(
219
- update: ZaloUpdate,
220
- token: string,
221
- account: ResolvedZaloAccount,
222
- config: OpenClawConfig,
223
- runtime: ZaloRuntimeEnv,
224
- core: ZaloCoreRuntime,
225
- mediaMaxMb: number,
226
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
227
- fetcher?: ZaloFetch,
228
- ): Promise<void> {
239
+ async function processUpdate(params: ZaloUpdateProcessingParams): Promise<void> {
240
+ const { update, token, account, config, runtime, core, mediaMaxMb, statusSink, fetcher } = params;
229
241
  const { event_name, message } = update;
242
+ const sharedContext = { token, account, config, runtime, core, statusSink, fetcher };
230
243
  if (!message) {
231
244
  return;
232
245
  }
233
246
 
234
247
  switch (event_name) {
235
248
  case "message.text.received":
236
- await handleTextMessage(message, token, account, config, runtime, core, statusSink, fetcher);
249
+ await handleTextMessage({
250
+ message,
251
+ ...sharedContext,
252
+ });
237
253
  break;
238
254
  case "message.image.received":
239
- await handleImageMessage(
255
+ await handleImageMessage({
240
256
  message,
241
- token,
242
- account,
243
- config,
244
- runtime,
245
- core,
257
+ ...sharedContext,
246
258
  mediaMaxMb,
247
- statusSink,
248
- fetcher,
249
- );
259
+ });
250
260
  break;
251
261
  case "message.sticker.received":
252
262
  logVerbose(core, runtime, `[${account.accountId}] Received sticker from ${message.from.id}`);
@@ -262,46 +272,24 @@ async function processUpdate(
262
272
  }
263
273
 
264
274
  async function handleTextMessage(
265
- message: ZaloMessage,
266
- token: string,
267
- account: ResolvedZaloAccount,
268
- config: OpenClawConfig,
269
- runtime: ZaloRuntimeEnv,
270
- core: ZaloCoreRuntime,
271
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
272
- fetcher?: ZaloFetch,
275
+ params: ZaloProcessingContext & { message: ZaloMessage },
273
276
  ): Promise<void> {
277
+ const { message } = params;
274
278
  const { text } = message;
275
279
  if (!text?.trim()) {
276
280
  return;
277
281
  }
278
282
 
279
283
  await processMessageWithPipeline({
280
- message,
281
- token,
282
- account,
283
- config,
284
- runtime,
285
- core,
284
+ ...params,
286
285
  text,
287
286
  mediaPath: undefined,
288
287
  mediaType: undefined,
289
- statusSink,
290
- fetcher,
291
288
  });
292
289
  }
293
290
 
294
- async function handleImageMessage(
295
- message: ZaloMessage,
296
- token: string,
297
- account: ResolvedZaloAccount,
298
- config: OpenClawConfig,
299
- runtime: ZaloRuntimeEnv,
300
- core: ZaloCoreRuntime,
301
- mediaMaxMb: number,
302
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
303
- fetcher?: ZaloFetch,
304
- ): Promise<void> {
291
+ async function handleImageMessage(params: ZaloImageMessageParams): Promise<void> {
292
+ const { message, mediaMaxMb, account, core, runtime } = params;
305
293
  const { photo, caption } = message;
306
294
 
307
295
  let mediaPath: string | undefined;
@@ -325,33 +313,14 @@ async function handleImageMessage(
325
313
  }
326
314
 
327
315
  await processMessageWithPipeline({
328
- message,
329
- token,
330
- account,
331
- config,
332
- runtime,
333
- core,
316
+ ...params,
334
317
  text: caption,
335
318
  mediaPath,
336
319
  mediaType,
337
- statusSink,
338
- fetcher,
339
320
  });
340
321
  }
341
322
 
342
- async function processMessageWithPipeline(params: {
343
- message: ZaloMessage;
344
- token: string;
345
- account: ResolvedZaloAccount;
346
- config: OpenClawConfig;
347
- runtime: ZaloRuntimeEnv;
348
- core: ZaloCoreRuntime;
349
- text?: string;
350
- mediaPath?: string;
351
- mediaType?: string;
352
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
353
- fetcher?: ZaloFetch;
354
- }): Promise<void> {
323
+ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Promise<void> {
355
324
  const {
356
325
  message,
357
326
  token,
@@ -609,7 +578,7 @@ async function deliverZaloReply(params: {
609
578
  core: ZaloCoreRuntime;
610
579
  config: OpenClawConfig;
611
580
  accountId?: string;
612
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
581
+ statusSink?: ZaloStatusSink;
613
582
  fetcher?: ZaloFetch;
614
583
  tableMode?: MarkdownTableMode;
615
584
  }): Promise<void> {
@@ -283,6 +283,7 @@ describe("handleZaloWebhookRequest", () => {
283
283
 
284
284
  try {
285
285
  await withServer(webhookRequestHandler, async (baseUrl) => {
286
+ let saw429 = false;
286
287
  for (let i = 0; i < 200; i += 1) {
287
288
  const response = await fetch(`${baseUrl}/hook-query-status?nonce=${i}`, {
288
289
  method: "POST",
@@ -292,10 +293,15 @@ describe("handleZaloWebhookRequest", () => {
292
293
  },
293
294
  body: "{}",
294
295
  });
295
- expect(response.status).toBe(401);
296
+ expect([401, 429]).toContain(response.status);
297
+ if (response.status === 429) {
298
+ saw429 = true;
299
+ break;
300
+ }
296
301
  }
297
302
 
298
- expect(getZaloWebhookStatusCounterSizeForTest()).toBe(1);
303
+ expect(saw429).toBe(true);
304
+ expect(getZaloWebhookStatusCounterSizeForTest()).toBe(2);
299
305
  });
300
306
  } finally {
301
307
  unregister();
@@ -322,6 +328,91 @@ describe("handleZaloWebhookRequest", () => {
322
328
  }
323
329
  });
324
330
 
331
+ it("rate limits unauthorized secret guesses before authentication succeeds", async () => {
332
+ const unregister = registerTarget({ path: "/hook-preauth-rate" });
333
+
334
+ try {
335
+ await withServer(webhookRequestHandler, async (baseUrl) => {
336
+ const saw429 = await postUntilRateLimited({
337
+ baseUrl,
338
+ path: "/hook-preauth-rate",
339
+ secret: "invalid-token", // pragma: allowlist secret
340
+ withNonceQuery: true,
341
+ });
342
+
343
+ expect(saw429).toBe(true);
344
+ expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
345
+ });
346
+ } finally {
347
+ unregister();
348
+ }
349
+ });
350
+
351
+ it("does not let unauthorized floods rate-limit authenticated traffic from a different trusted forwarded client IP", async () => {
352
+ const unregister = registerTarget({
353
+ path: "/hook-preauth-split",
354
+ config: {
355
+ gateway: {
356
+ trustedProxies: ["127.0.0.1"],
357
+ },
358
+ } as OpenClawConfig,
359
+ });
360
+
361
+ try {
362
+ await withServer(webhookRequestHandler, async (baseUrl) => {
363
+ for (let i = 0; i < 130; i += 1) {
364
+ const response = await fetch(`${baseUrl}/hook-preauth-split?nonce=${i}`, {
365
+ method: "POST",
366
+ headers: {
367
+ "x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
368
+ "content-type": "application/json",
369
+ "x-forwarded-for": "203.0.113.10",
370
+ },
371
+ body: "{}",
372
+ });
373
+ if (response.status === 429) {
374
+ break;
375
+ }
376
+ }
377
+
378
+ const validResponse = await fetch(`${baseUrl}/hook-preauth-split`, {
379
+ method: "POST",
380
+ headers: {
381
+ "x-bot-api-secret-token": "secret",
382
+ "content-type": "application/json",
383
+ "x-forwarded-for": "198.51.100.20",
384
+ },
385
+ body: JSON.stringify({ event_name: "message.unsupported.received" }),
386
+ });
387
+
388
+ expect(validResponse.status).toBe(200);
389
+ });
390
+ } finally {
391
+ unregister();
392
+ }
393
+ });
394
+
395
+ it("still returns 401 before 415 when both secret and content-type are invalid", async () => {
396
+ const unregister = registerTarget({ path: "/hook-auth-before-type" });
397
+
398
+ try {
399
+ await withServer(webhookRequestHandler, async (baseUrl) => {
400
+ const response = await fetch(`${baseUrl}/hook-auth-before-type`, {
401
+ method: "POST",
402
+ headers: {
403
+ "x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
404
+ "content-type": "text/plain",
405
+ },
406
+ body: "not-json",
407
+ });
408
+
409
+ expect(response.status).toBe(401);
410
+ });
411
+ } finally {
412
+ unregister();
413
+ }
414
+ });
415
+
325
416
  it("scopes DM pairing store reads and writes to accountId", async () => {
326
417
  const { core, readAllowFromStore, upsertPairingRequest } = createPairingAuthCore({
327
418
  pairingCreated: false,
@@ -16,6 +16,7 @@ import {
16
16
  WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
17
17
  WEBHOOK_RATE_LIMIT_DEFAULTS,
18
18
  } from "openclaw/plugin-sdk/zalo";
19
+ import { resolveClientIp } from "../../../src/gateway/net.js";
19
20
  import type { ResolvedZaloAccount } from "./accounts.js";
20
21
  import type { ZaloFetch, ZaloUpdate } from "./api.js";
21
22
  import type { ZaloRuntimeEnv } from "./monitor.js";
@@ -109,6 +110,10 @@ function recordWebhookStatus(
109
110
  });
110
111
  }
111
112
 
113
+ function headerValue(value: string | string[] | undefined): string | undefined {
114
+ return Array.isArray(value) ? value[0] : value;
115
+ }
116
+
112
117
  export function registerZaloWebhookTarget(
113
118
  target: ZaloWebhookTarget,
114
119
  opts?: {
@@ -140,6 +145,33 @@ export async function handleZaloWebhookRequest(
140
145
  targetsByPath: webhookTargets,
141
146
  allowMethods: ["POST"],
142
147
  handle: async ({ targets, path }) => {
148
+ const trustedProxies = targets[0]?.config.gateway?.trustedProxies;
149
+ const allowRealIpFallback = targets[0]?.config.gateway?.allowRealIpFallback === true;
150
+ const clientIp =
151
+ resolveClientIp({
152
+ remoteAddr: req.socket.remoteAddress,
153
+ forwardedFor: headerValue(req.headers["x-forwarded-for"]),
154
+ realIp: headerValue(req.headers["x-real-ip"]),
155
+ trustedProxies,
156
+ allowRealIpFallback,
157
+ }) ??
158
+ req.socket.remoteAddress ??
159
+ "unknown";
160
+ const rateLimitKey = `${path}:${clientIp}`;
161
+ const nowMs = Date.now();
162
+ if (
163
+ !applyBasicWebhookRequestGuards({
164
+ req,
165
+ res,
166
+ rateLimiter: webhookRateLimiter,
167
+ rateLimitKey,
168
+ nowMs,
169
+ })
170
+ ) {
171
+ recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
172
+ return true;
173
+ }
174
+
143
175
  const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
144
176
  const target = resolveWebhookTargetWithAuthOrRejectSync({
145
177
  targets,
@@ -150,16 +182,12 @@ export async function handleZaloWebhookRequest(
150
182
  recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
151
183
  return true;
152
184
  }
153
- const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
154
- const nowMs = Date.now();
155
-
185
+ // Preserve the historical 401-before-415 ordering for invalid secrets while still
186
+ // consuming rate-limit budget on unauthenticated guesses.
156
187
  if (
157
188
  !applyBasicWebhookRequestGuards({
158
189
  req,
159
190
  res,
160
- rateLimiter: webhookRateLimiter,
161
- rateLimitKey,
162
- nowMs,
163
191
  requireJsonContentType: true,
164
192
  })
165
193
  ) {
package/src/send.ts CHANGED
@@ -21,6 +21,28 @@ export type ZaloSendResult = {
21
21
  error?: string;
22
22
  };
23
23
 
24
+ function toZaloSendResult(response: {
25
+ ok?: boolean;
26
+ result?: { message_id?: string };
27
+ }): ZaloSendResult {
28
+ if (response.ok && response.result) {
29
+ return { ok: true, messageId: response.result.message_id };
30
+ }
31
+ return { ok: false, error: "Failed to send message" };
32
+ }
33
+
34
+ async function runZaloSend(
35
+ failureMessage: string,
36
+ send: () => Promise<{ ok?: boolean; result?: { message_id?: string } }>,
37
+ ): Promise<ZaloSendResult> {
38
+ try {
39
+ const result = toZaloSendResult(await send());
40
+ return result.ok ? result : { ok: false, error: failureMessage };
41
+ } catch (err) {
42
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
43
+ }
44
+ }
45
+
24
46
  function resolveSendContext(options: ZaloSendOptions): {
25
47
  token: string;
26
48
  fetcher?: ZaloFetch;
@@ -55,15 +77,30 @@ function resolveValidatedSendContext(
55
77
  return { ok: true, chatId: trimmedChatId, token, fetcher };
56
78
  }
57
79
 
80
+ function resolveSendContextOrFailure(
81
+ chatId: string,
82
+ options: ZaloSendOptions,
83
+ ):
84
+ | { context: { chatId: string; token: string; fetcher?: ZaloFetch } }
85
+ | { failure: ZaloSendResult } {
86
+ const context = resolveValidatedSendContext(chatId, options);
87
+ return context.ok
88
+ ? { context }
89
+ : {
90
+ failure: { ok: false, error: context.error },
91
+ };
92
+ }
93
+
58
94
  export async function sendMessageZalo(
59
95
  chatId: string,
60
96
  text: string,
61
97
  options: ZaloSendOptions = {},
62
98
  ): Promise<ZaloSendResult> {
63
- const context = resolveValidatedSendContext(chatId, options);
64
- if (!context.ok) {
65
- return { ok: false, error: context.error };
99
+ const resolved = resolveSendContextOrFailure(chatId, options);
100
+ if ("failure" in resolved) {
101
+ return resolved.failure;
66
102
  }
103
+ const { context } = resolved;
67
104
 
68
105
  if (options.mediaUrl) {
69
106
  return sendPhotoZalo(context.chatId, options.mediaUrl, {
@@ -73,24 +110,16 @@ export async function sendMessageZalo(
73
110
  });
74
111
  }
75
112
 
76
- try {
77
- const response = await sendMessage(
113
+ return await runZaloSend("Failed to send message", () =>
114
+ sendMessage(
78
115
  context.token,
79
116
  {
80
117
  chat_id: context.chatId,
81
118
  text: text.slice(0, 2000),
82
119
  },
83
120
  context.fetcher,
84
- );
85
-
86
- if (response.ok && response.result) {
87
- return { ok: true, messageId: response.result.message_id };
88
- }
89
-
90
- return { ok: false, error: "Failed to send message" };
91
- } catch (err) {
92
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
93
- }
121
+ ),
122
+ );
94
123
  }
95
124
 
96
125
  export async function sendPhotoZalo(
@@ -98,17 +127,18 @@ export async function sendPhotoZalo(
98
127
  photoUrl: string,
99
128
  options: ZaloSendOptions = {},
100
129
  ): Promise<ZaloSendResult> {
101
- const context = resolveValidatedSendContext(chatId, options);
102
- if (!context.ok) {
103
- return { ok: false, error: context.error };
130
+ const resolved = resolveSendContextOrFailure(chatId, options);
131
+ if ("failure" in resolved) {
132
+ return resolved.failure;
104
133
  }
134
+ const { context } = resolved;
105
135
 
106
136
  if (!photoUrl?.trim()) {
107
137
  return { ok: false, error: "No photo URL provided" };
108
138
  }
109
139
 
110
- try {
111
- const response = await sendPhoto(
140
+ return await runZaloSend("Failed to send photo", () =>
141
+ sendPhoto(
112
142
  context.token,
113
143
  {
114
144
  chat_id: context.chatId,
@@ -116,14 +146,6 @@ export async function sendPhotoZalo(
116
146
  caption: options.caption?.slice(0, 2000),
117
147
  },
118
148
  context.fetcher,
119
- );
120
-
121
- if (response.ok && response.result) {
122
- return { ok: true, messageId: response.result.message_id };
123
- }
124
-
125
- return { ok: false, error: "Failed to send photo" };
126
- } catch (err) {
127
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
128
- }
149
+ ),
150
+ );
129
151
  }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { expectOpenDmPolicyConfigIssue } from "../../test-utils/status-issues.js";
3
+ import { collectZaloStatusIssues } from "./status-issues.js";
4
+
5
+ describe("collectZaloStatusIssues", () => {
6
+ it("warns when dmPolicy is open", () => {
7
+ expectOpenDmPolicyConfigIssue({
8
+ collectIssues: collectZaloStatusIssues,
9
+ account: {
10
+ accountId: "default",
11
+ enabled: true,
12
+ configured: true,
13
+ dmPolicy: "open",
14
+ },
15
+ });
16
+ });
17
+
18
+ it("skips unconfigured accounts", () => {
19
+ const issues = collectZaloStatusIssues([
20
+ {
21
+ accountId: "default",
22
+ enabled: true,
23
+ configured: false,
24
+ dmPolicy: "open",
25
+ },
26
+ ]);
27
+ expect(issues).toHaveLength(0);
28
+ });
29
+ });
@@ -1,38 +1,16 @@
1
1
  import type { ChannelAccountSnapshot, ChannelStatusIssue } from "openclaw/plugin-sdk/zalo";
2
+ import { coerceStatusIssueAccountId, readStatusIssueFields } from "../../shared/status-issues.js";
2
3
 
3
- type ZaloAccountStatus = {
4
- accountId?: unknown;
5
- enabled?: unknown;
6
- configured?: unknown;
7
- dmPolicy?: unknown;
8
- };
9
-
10
- const isRecord = (value: unknown): value is Record<string, unknown> =>
11
- Boolean(value && typeof value === "object");
12
-
13
- const asString = (value: unknown): string | undefined =>
14
- typeof value === "string" ? value : typeof value === "number" ? String(value) : undefined;
15
-
16
- function readZaloAccountStatus(value: ChannelAccountSnapshot): ZaloAccountStatus | null {
17
- if (!isRecord(value)) {
18
- return null;
19
- }
20
- return {
21
- accountId: value.accountId,
22
- enabled: value.enabled,
23
- configured: value.configured,
24
- dmPolicy: value.dmPolicy,
25
- };
26
- }
4
+ const ZALO_STATUS_FIELDS = ["accountId", "enabled", "configured", "dmPolicy"] as const;
27
5
 
28
6
  export function collectZaloStatusIssues(accounts: ChannelAccountSnapshot[]): ChannelStatusIssue[] {
29
7
  const issues: ChannelStatusIssue[] = [];
30
8
  for (const entry of accounts) {
31
- const account = readZaloAccountStatus(entry);
9
+ const account = readStatusIssueFields(entry, ZALO_STATUS_FIELDS);
32
10
  if (!account) {
33
11
  continue;
34
12
  }
35
- const accountId = asString(account.accountId) ?? "default";
13
+ const accountId = coerceStatusIssueAccountId(account.accountId) ?? "default";
36
14
  const enabled = account.enabled !== false;
37
15
  const configured = account.configured === true;
38
16
  if (!enabled || !configured) {