@openclaw/zalo 2026.3.8-beta.1 → 2026.3.10

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.3.10
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.3.9
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
3
15
  ## 2026.3.8-beta.1
4
16
 
5
17
  ### Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/zalo",
3
- "version": "2026.3.8-beta.1",
3
+ "version": "2026.3.10",
4
4
  "description": "OpenClaw Zalo channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -1,5 +1,9 @@
1
1
  import type { ReplyPayload } from "openclaw/plugin-sdk/zalo";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ installSendPayloadContractSuite,
5
+ primeSendMock,
6
+ } from "../../../src/test-utils/send-payload-contract.js";
3
7
  import { zaloPlugin } from "./channel.js";
4
8
 
5
9
  vi.mock("./send.js", () => ({
@@ -25,78 +29,16 @@ describe("zaloPlugin outbound sendPayload", () => {
25
29
  mockedSend.mockResolvedValue({ ok: true, messageId: "zl-1" });
26
30
  });
27
31
 
28
- it("text-only delegates to sendText", async () => {
29
- mockedSend.mockResolvedValue({ ok: true, messageId: "zl-t1" });
30
-
31
- const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({ text: "hello" }));
32
-
33
- expect(mockedSend).toHaveBeenCalledWith("123456789", "hello", expect.any(Object));
34
- expect(result).toMatchObject({ channel: "zalo", messageId: "zl-t1" });
35
- });
36
-
37
- it("single media delegates to sendMedia", async () => {
38
- mockedSend.mockResolvedValue({ ok: true, messageId: "zl-m1" });
39
-
40
- const result = await zaloPlugin.outbound!.sendPayload!(
41
- baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }),
42
- );
43
-
44
- expect(mockedSend).toHaveBeenCalledWith(
45
- "123456789",
46
- "cap",
47
- expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }),
48
- );
49
- expect(result).toMatchObject({ channel: "zalo" });
50
- });
51
-
52
- it("multi-media iterates URLs with caption on first", async () => {
53
- mockedSend
54
- .mockResolvedValueOnce({ ok: true, messageId: "zl-1" })
55
- .mockResolvedValueOnce({ ok: true, messageId: "zl-2" });
56
-
57
- const result = await zaloPlugin.outbound!.sendPayload!(
58
- baseCtx({
59
- text: "caption",
60
- mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
61
- }),
62
- );
63
-
64
- expect(mockedSend).toHaveBeenCalledTimes(2);
65
- expect(mockedSend).toHaveBeenNthCalledWith(
66
- 1,
67
- "123456789",
68
- "caption",
69
- expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }),
70
- );
71
- expect(mockedSend).toHaveBeenNthCalledWith(
72
- 2,
73
- "123456789",
74
- "",
75
- expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }),
76
- );
77
- expect(result).toMatchObject({ channel: "zalo", messageId: "zl-2" });
78
- });
79
-
80
- it("empty payload returns no-op", async () => {
81
- const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({}));
82
-
83
- expect(mockedSend).not.toHaveBeenCalled();
84
- expect(result).toEqual({ channel: "zalo", messageId: "" });
85
- });
86
-
87
- it("chunking splits long text", async () => {
88
- mockedSend
89
- .mockResolvedValueOnce({ ok: true, messageId: "zl-c1" })
90
- .mockResolvedValueOnce({ ok: true, messageId: "zl-c2" });
91
-
92
- const longText = "a".repeat(3000);
93
- const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({ text: longText }));
94
-
95
- // textChunkLimit is 2000 with chunkTextForOutbound, so it should split
96
- expect(mockedSend.mock.calls.length).toBeGreaterThanOrEqual(2);
97
- for (const call of mockedSend.mock.calls) {
98
- expect((call[1] as string).length).toBeLessThanOrEqual(2000);
99
- }
100
- expect(result).toMatchObject({ channel: "zalo" });
32
+ installSendPayloadContractSuite({
33
+ channel: "zalo",
34
+ chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
35
+ createHarness: ({ payload, sendResults }) => {
36
+ primeSendMock(mockedSend, { ok: true, messageId: "zl-1" }, sendResults);
37
+ return {
38
+ run: async () => await zaloPlugin.outbound!.sendPayload!(baseCtx(payload)),
39
+ sendMock: mockedSend,
40
+ to: "123456789",
41
+ };
42
+ },
101
43
  });
102
44
  });
package/src/channel.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import {
2
2
  buildAccountScopedDmSecurityPolicy,
3
- collectOpenProviderGroupPolicyWarnings,
4
3
  buildOpenGroupPolicyRestrictSendersWarning,
5
4
  buildOpenGroupPolicyWarning,
5
+ collectOpenProviderGroupPolicyWarnings,
6
+ createAccountStatusSink,
6
7
  mapAllowFromEntries,
7
8
  } from "openclaw/plugin-sdk/compat";
8
9
  import type {
@@ -357,6 +358,10 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
357
358
  `[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
358
359
  );
359
360
  }
361
+ const statusSink = createAccountStatusSink({
362
+ accountId: ctx.accountId,
363
+ setStatus: ctx.setStatus,
364
+ });
360
365
  ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`);
361
366
  const { monitorZaloProvider } = await import("./monitor.js");
362
367
  return monitorZaloProvider({
@@ -370,7 +375,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
370
375
  webhookSecret: normalizeSecretInputString(account.config.webhookSecret),
371
376
  webhookPath: account.config.webhookPath,
372
377
  fetcher,
373
- statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
378
+ statusSink,
374
379
  });
375
380
  },
376
381
  },
@@ -1,6 +1,8 @@
1
1
  import {
2
- AllowFromEntrySchema,
2
+ AllowFromListSchema,
3
3
  buildCatchallMultiAccountChannelSchema,
4
+ DmPolicySchema,
5
+ GroupPolicySchema,
4
6
  } from "openclaw/plugin-sdk/compat";
5
7
  import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
6
8
  import { z } from "zod";
@@ -15,10 +17,10 @@ const zaloAccountSchema = z.object({
15
17
  webhookUrl: z.string().optional(),
16
18
  webhookSecret: buildSecretInputSchema().optional(),
17
19
  webhookPath: z.string().optional(),
18
- dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
19
- allowFrom: z.array(AllowFromEntrySchema).optional(),
20
- groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
21
- groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
20
+ dmPolicy: DmPolicySchema.optional(),
21
+ allowFrom: AllowFromListSchema,
22
+ groupPolicy: GroupPolicySchema.optional(),
23
+ groupAllowFrom: AllowFromListSchema,
22
24
  mediaMaxMb: z.number().optional(),
23
25
  proxy: z.string().optional(),
24
26
  responsePrefix: z.string().optional(),
package/src/onboarding.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  mergeAllowFromEntries,
13
13
  normalizeAccountId,
14
14
  promptSingleChannelSecretInput,
15
+ runSingleChannelSecretStep,
15
16
  resolveAccountIdForConfigure,
16
17
  setTopLevelChannelDmPolicyWithAllowFrom,
17
18
  } from "openclaw/plugin-sdk/zalo";
@@ -255,80 +256,66 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
255
256
  const hasConfigToken = Boolean(
256
257
  hasConfiguredSecretInput(resolvedAccount.config.botToken) || resolvedAccount.config.tokenFile,
257
258
  );
258
- const tokenPromptState = buildSingleChannelSecretPromptState({
259
- accountConfigured,
260
- hasConfigToken,
261
- allowEnv,
262
- envValue: process.env.ZALO_BOT_TOKEN,
263
- });
264
-
265
- let token: SecretInput | null = null;
266
- if (!accountConfigured) {
267
- await noteZaloTokenHelp(prompter);
268
- }
269
- const tokenResult = await promptSingleChannelSecretInput({
259
+ const tokenStep = await runSingleChannelSecretStep({
270
260
  cfg: next,
271
261
  prompter,
272
262
  providerHint: "zalo",
273
263
  credentialLabel: "bot token",
274
- accountConfigured: tokenPromptState.accountConfigured,
275
- canUseEnv: tokenPromptState.canUseEnv,
276
- hasConfigToken: tokenPromptState.hasConfigToken,
264
+ accountConfigured,
265
+ hasConfigToken,
266
+ allowEnv,
267
+ envValue: process.env.ZALO_BOT_TOKEN,
277
268
  envPrompt: "ZALO_BOT_TOKEN detected. Use env var?",
278
269
  keepPrompt: "Zalo token already configured. Keep it?",
279
270
  inputPrompt: "Enter Zalo bot token",
280
271
  preferredEnvVar: "ZALO_BOT_TOKEN",
281
- });
282
- if (tokenResult.action === "set") {
283
- token = tokenResult.value;
284
- }
285
- if (tokenResult.action === "use-env" && zaloAccountId === DEFAULT_ACCOUNT_ID) {
286
- next = {
287
- ...next,
288
- channels: {
289
- ...next.channels,
290
- zalo: {
291
- ...next.channels?.zalo,
292
- enabled: true,
293
- },
294
- },
295
- } as OpenClawConfig;
296
- }
297
-
298
- if (token) {
299
- if (zaloAccountId === DEFAULT_ACCOUNT_ID) {
300
- next = {
301
- ...next,
302
- channels: {
303
- ...next.channels,
304
- zalo: {
305
- ...next.channels?.zalo,
306
- enabled: true,
307
- botToken: token,
308
- },
309
- },
310
- } as OpenClawConfig;
311
- } else {
312
- next = {
313
- ...next,
314
- channels: {
315
- ...next.channels,
316
- zalo: {
317
- ...next.channels?.zalo,
318
- enabled: true,
319
- accounts: {
320
- ...next.channels?.zalo?.accounts,
321
- [zaloAccountId]: {
322
- ...next.channels?.zalo?.accounts?.[zaloAccountId],
272
+ onMissingConfigured: async () => await noteZaloTokenHelp(prompter),
273
+ applyUseEnv: async (cfg) =>
274
+ zaloAccountId === DEFAULT_ACCOUNT_ID
275
+ ? ({
276
+ ...cfg,
277
+ channels: {
278
+ ...cfg.channels,
279
+ zalo: {
280
+ ...cfg.channels?.zalo,
323
281
  enabled: true,
324
- botToken: token,
325
282
  },
326
283
  },
327
- },
328
- },
329
- } as OpenClawConfig;
330
- }
331
- }
284
+ } as OpenClawConfig)
285
+ : cfg,
286
+ applySet: async (cfg, value) =>
287
+ zaloAccountId === DEFAULT_ACCOUNT_ID
288
+ ? ({
289
+ ...cfg,
290
+ channels: {
291
+ ...cfg.channels,
292
+ zalo: {
293
+ ...cfg.channels?.zalo,
294
+ enabled: true,
295
+ botToken: value,
296
+ },
297
+ },
298
+ } as OpenClawConfig)
299
+ : ({
300
+ ...cfg,
301
+ channels: {
302
+ ...cfg.channels,
303
+ zalo: {
304
+ ...cfg.channels?.zalo,
305
+ enabled: true,
306
+ accounts: {
307
+ ...cfg.channels?.zalo?.accounts,
308
+ [zaloAccountId]: {
309
+ ...cfg.channels?.zalo?.accounts?.[zaloAccountId],
310
+ enabled: true,
311
+ botToken: value,
312
+ },
313
+ },
314
+ },
315
+ },
316
+ } as OpenClawConfig),
317
+ });
318
+ next = tokenStep.cfg;
332
319
 
333
320
  const wantsWebhook = await prompter.confirm({
334
321
  message: "Use webhook mode for Zalo?",
package/src/token.test.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
1
4
  import { describe, expect, it } from "vitest";
2
5
  import { resolveZaloToken } from "./token.js";
3
6
  import type { ZaloConfig } from "./types.js";
@@ -55,4 +58,20 @@ describe("resolveZaloToken", () => {
55
58
  expect(res.token).toBe("work-token");
56
59
  expect(res.source).toBe("config");
57
60
  });
61
+
62
+ it.runIf(process.platform !== "win32")("rejects symlinked token files", () => {
63
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-zalo-token-"));
64
+ const tokenFile = path.join(dir, "token.txt");
65
+ const tokenLink = path.join(dir, "token-link.txt");
66
+ fs.writeFileSync(tokenFile, "file-token\n", "utf8");
67
+ fs.symlinkSync(tokenFile, tokenLink);
68
+
69
+ const cfg = {
70
+ tokenFile: tokenLink,
71
+ } as ZaloConfig;
72
+ const res = resolveZaloToken(cfg);
73
+ expect(res.token).toBe("");
74
+ expect(res.source).toBe("none");
75
+ fs.rmSync(dir, { recursive: true, force: true });
76
+ });
58
77
  });
package/src/token.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
2
+ import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core";
3
3
  import type { BaseTokenResolution } from "openclaw/plugin-sdk/zalo";
4
4
  import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js";
5
5
  import type { ZaloConfig } from "./types.js";
@@ -9,16 +9,7 @@ export type ZaloTokenResolution = BaseTokenResolution & {
9
9
  };
10
10
 
11
11
  function readTokenFromFile(tokenFile: string | undefined): string {
12
- const trimmedPath = tokenFile?.trim();
13
- if (!trimmedPath) {
14
- return "";
15
- }
16
- try {
17
- return readFileSync(trimmedPath, "utf8").trim();
18
- } catch {
19
- // ignore read failures
20
- return "";
21
- }
12
+ return tryReadSecretFileSync(tokenFile, "Zalo token file", { rejectSymlink: true }) ?? "";
22
13
  }
23
14
 
24
15
  export function resolveZaloToken(