@openclaw/zalo 2026.3.1 → 2026.3.7

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.3.7
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.3.3
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
15
+ ## 2026.3.2
16
+
17
+ ### Changes
18
+
19
+ - Version alignment with core OpenClaw release numbers.
20
+
3
21
  ## 2026.3.1
4
22
 
5
23
  ### Changes
package/index.ts CHANGED
@@ -1,7 +1,6 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/zalo";
2
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/zalo";
3
3
  import { zaloDock, zaloPlugin } from "./src/channel.js";
4
- import { handleZaloWebhookRequest } from "./src/monitor.js";
5
4
  import { setZaloRuntime } from "./src/runtime.js";
6
5
 
7
6
  const plugin = {
@@ -12,7 +11,6 @@ const plugin = {
12
11
  register(api: OpenClawPluginApi) {
13
12
  setZaloRuntime(api.runtime);
14
13
  api.registerChannel({ plugin: zaloPlugin, dock: zaloDock });
15
- api.registerHttpHandler(handleZaloWebhookRequest);
16
14
  },
17
15
  };
18
16
 
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@openclaw/zalo",
3
- "version": "2026.3.1",
3
+ "version": "2026.3.7",
4
4
  "description": "OpenClaw Zalo channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
7
- "undici": "7.22.0"
7
+ "undici": "7.22.0",
8
+ "zod": "^4.3.6"
8
9
  },
9
10
  "openclaw": {
10
11
  "extensions": [
package/src/accounts.ts CHANGED
@@ -1,45 +1,13 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
- import {
3
- DEFAULT_ACCOUNT_ID,
4
- normalizeAccountId,
5
- normalizeOptionalAccountId,
6
- } from "openclaw/plugin-sdk/account-id";
1
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
2
+ import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/zalo";
7
3
  import { resolveZaloToken } from "./token.js";
8
4
  import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
9
5
 
10
6
  export type { ResolvedZaloAccount };
11
7
 
12
- function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
13
- const accounts = (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts;
14
- if (!accounts || typeof accounts !== "object") {
15
- return [];
16
- }
17
- return Object.keys(accounts).filter(Boolean);
18
- }
19
-
20
- export function listZaloAccountIds(cfg: OpenClawConfig): string[] {
21
- const ids = listConfiguredAccountIds(cfg);
22
- if (ids.length === 0) {
23
- return [DEFAULT_ACCOUNT_ID];
24
- }
25
- return ids.toSorted((a, b) => a.localeCompare(b));
26
- }
27
-
28
- export function resolveDefaultZaloAccountId(cfg: OpenClawConfig): string {
29
- const zaloConfig = cfg.channels?.zalo as ZaloConfig | undefined;
30
- const preferred = normalizeOptionalAccountId(zaloConfig?.defaultAccount);
31
- if (
32
- preferred &&
33
- listZaloAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
34
- ) {
35
- return preferred;
36
- }
37
- const ids = listZaloAccountIds(cfg);
38
- if (ids.includes(DEFAULT_ACCOUNT_ID)) {
39
- return DEFAULT_ACCOUNT_ID;
40
- }
41
- return ids[0] ?? DEFAULT_ACCOUNT_ID;
42
- }
8
+ const { listAccountIds: listZaloAccountIds, resolveDefaultAccountId: resolveDefaultZaloAccountId } =
9
+ createAccountListHelpers("zalo");
10
+ export { listZaloAccountIds, resolveDefaultZaloAccountId };
43
11
 
44
12
  function resolveAccountConfig(
45
13
  cfg: OpenClawConfig,
@@ -62,6 +30,7 @@ function mergeZaloAccountConfig(cfg: OpenClawConfig, accountId: string): ZaloAcc
62
30
  export function resolveZaloAccount(params: {
63
31
  cfg: OpenClawConfig;
64
32
  accountId?: string | null;
33
+ allowUnresolvedSecretRef?: boolean;
65
34
  }): ResolvedZaloAccount {
66
35
  const accountId = normalizeAccountId(params.accountId);
67
36
  const baseEnabled = (params.cfg.channels?.zalo as ZaloConfig | undefined)?.enabled !== false;
@@ -71,6 +40,7 @@ export function resolveZaloAccount(params: {
71
40
  const tokenResolution = resolveZaloToken(
72
41
  params.cfg.channels?.zalo as ZaloConfig | undefined,
73
42
  accountId,
43
+ { allowUnresolvedSecretRef: params.allowUnresolvedSecretRef },
74
44
  );
75
45
 
76
46
  return {
package/src/actions.ts CHANGED
@@ -2,8 +2,8 @@ import type {
2
2
  ChannelMessageActionAdapter,
3
3
  ChannelMessageActionName,
4
4
  OpenClawConfig,
5
- } from "openclaw/plugin-sdk";
6
- import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk";
5
+ } from "openclaw/plugin-sdk/zalo";
6
+ import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk/zalo";
7
7
  import { listEnabledZaloAccounts } from "./accounts.js";
8
8
  import { sendMessageZalo } from "./send.js";
9
9
 
@@ -1,4 +1,4 @@
1
- import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
1
+ import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/zalo";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import { zaloPlugin } from "./channel.js";
4
4
 
@@ -0,0 +1,102 @@
1
+ import type { ReplyPayload } from "openclaw/plugin-sdk/zalo";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { zaloPlugin } from "./channel.js";
4
+
5
+ vi.mock("./send.js", () => ({
6
+ sendMessageZalo: vi.fn().mockResolvedValue({ ok: true, messageId: "zl-1" }),
7
+ }));
8
+
9
+ function baseCtx(payload: ReplyPayload) {
10
+ return {
11
+ cfg: {},
12
+ to: "123456789",
13
+ text: "",
14
+ payload,
15
+ };
16
+ }
17
+
18
+ describe("zaloPlugin outbound sendPayload", () => {
19
+ let mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalo"]>>;
20
+
21
+ beforeEach(async () => {
22
+ const mod = await import("./send.js");
23
+ mockedSend = vi.mocked(mod.sendMessageZalo);
24
+ mockedSend.mockClear();
25
+ mockedSend.mockResolvedValue({ ok: true, messageId: "zl-1" });
26
+ });
27
+
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" });
101
+ });
102
+ });
package/src/channel.ts CHANGED
@@ -1,26 +1,36 @@
1
+ import {
2
+ buildAccountScopedDmSecurityPolicy,
3
+ collectOpenProviderGroupPolicyWarnings,
4
+ buildOpenGroupPolicyRestrictSendersWarning,
5
+ buildOpenGroupPolicyWarning,
6
+ mapAllowFromEntries,
7
+ } from "openclaw/plugin-sdk/compat";
1
8
  import type {
2
9
  ChannelAccountSnapshot,
3
10
  ChannelDock,
4
11
  ChannelPlugin,
5
12
  OpenClawConfig,
6
- } from "openclaw/plugin-sdk";
13
+ } from "openclaw/plugin-sdk/zalo";
7
14
  import {
8
15
  applyAccountNameToChannelSection,
16
+ applySetupAccountConfigPatch,
17
+ buildBaseAccountStatusSnapshot,
9
18
  buildChannelConfigSchema,
10
19
  buildTokenChannelStatusSummary,
20
+ buildChannelSendResult,
11
21
  DEFAULT_ACCOUNT_ID,
12
22
  deleteAccountFromConfigSection,
13
23
  chunkTextForOutbound,
14
24
  formatAllowFromLowercase,
15
- formatPairingApproveHint,
16
25
  migrateBaseNameToDefaultAccount,
26
+ listDirectoryUserEntriesFromAllowFrom,
17
27
  normalizeAccountId,
28
+ isNumericTargetId,
18
29
  PAIRING_APPROVED_MESSAGE,
19
- resolveDefaultGroupPolicy,
20
- resolveOpenProviderRuntimeGroupPolicy,
21
- resolveChannelAccountConfigBasePath,
30
+ resolveOutboundMediaUrls,
31
+ sendPayloadWithChunkedTextAndMedia,
22
32
  setAccountEnabledInConfigSection,
23
- } from "openclaw/plugin-sdk";
33
+ } from "openclaw/plugin-sdk/zalo";
24
34
  import {
25
35
  listZaloAccountIds,
26
36
  resolveDefaultZaloAccountId,
@@ -32,6 +42,7 @@ import { ZaloConfigSchema } from "./config-schema.js";
32
42
  import { zaloOnboardingAdapter } from "./onboarding.js";
33
43
  import { probeZalo } from "./probe.js";
34
44
  import { resolveZaloProxyFetch } from "./proxy.js";
45
+ import { normalizeSecretInputString } from "./secret-input.js";
35
46
  import { sendMessageZalo } from "./send.js";
36
47
  import { collectZaloStatusIssues } from "./status-issues.js";
37
48
 
@@ -65,9 +76,7 @@ export const zaloDock: ChannelDock = {
65
76
  outbound: { textChunkLimit: 2000 },
66
77
  config: {
67
78
  resolveAllowFrom: ({ cfg, accountId }) =>
68
- (resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
69
- String(entry),
70
- ),
79
+ mapAllowFromEntries(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom),
71
80
  formatAllowFrom: ({ allowFrom }) =>
72
81
  formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
73
82
  },
@@ -122,53 +131,57 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
122
131
  tokenSource: account.tokenSource,
123
132
  }),
124
133
  resolveAllowFrom: ({ cfg, accountId }) =>
125
- (resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
126
- String(entry),
127
- ),
134
+ mapAllowFromEntries(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom),
128
135
  formatAllowFrom: ({ allowFrom }) =>
129
136
  formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
130
137
  },
131
138
  security: {
132
139
  resolveDmPolicy: ({ cfg, accountId, account }) => {
133
- const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
134
- const basePath = resolveChannelAccountConfigBasePath({
140
+ return buildAccountScopedDmSecurityPolicy({
135
141
  cfg,
136
142
  channelKey: "zalo",
137
- accountId: resolvedAccountId,
138
- });
139
- return {
140
- policy: account.config.dmPolicy ?? "pairing",
143
+ accountId,
144
+ fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
145
+ policy: account.config.dmPolicy,
141
146
  allowFrom: account.config.allowFrom ?? [],
142
- policyPath: `${basePath}dmPolicy`,
143
- allowFromPath: basePath,
144
- approveHint: formatPairingApproveHint("zalo"),
147
+ policyPathSuffix: "dmPolicy",
145
148
  normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""),
146
- };
149
+ });
147
150
  },
148
151
  collectWarnings: ({ account, cfg }) => {
149
- const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
150
- const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
152
+ return collectOpenProviderGroupPolicyWarnings({
153
+ cfg,
151
154
  providerConfigPresent: cfg.channels?.zalo !== undefined,
152
- groupPolicy: account.config.groupPolicy,
153
- defaultGroupPolicy,
155
+ configuredGroupPolicy: account.config.groupPolicy,
156
+ collect: (groupPolicy) => {
157
+ if (groupPolicy !== "open") {
158
+ return [];
159
+ }
160
+ const explicitGroupAllowFrom = mapAllowFromEntries(account.config.groupAllowFrom);
161
+ const dmAllowFrom = mapAllowFromEntries(account.config.allowFrom);
162
+ const effectiveAllowFrom =
163
+ explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom;
164
+ if (effectiveAllowFrom.length > 0) {
165
+ return [
166
+ buildOpenGroupPolicyRestrictSendersWarning({
167
+ surface: "Zalo groups",
168
+ openScope: "any member",
169
+ groupPolicyPath: "channels.zalo.groupPolicy",
170
+ groupAllowFromPath: "channels.zalo.groupAllowFrom",
171
+ }),
172
+ ];
173
+ }
174
+ return [
175
+ buildOpenGroupPolicyWarning({
176
+ surface: "Zalo groups",
177
+ openBehavior:
178
+ "with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated)",
179
+ remediation:
180
+ 'Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom',
181
+ }),
182
+ ];
183
+ },
154
184
  });
155
- if (groupPolicy !== "open") {
156
- return [];
157
- }
158
- const explicitGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) =>
159
- String(entry),
160
- );
161
- const dmAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
162
- const effectiveAllowFrom =
163
- explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom;
164
- if (effectiveAllowFrom.length > 0) {
165
- return [
166
- `- Zalo groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom to restrict senders.`,
167
- ];
168
- }
169
- return [
170
- `- Zalo groups: groupPolicy="open" with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom.`,
171
- ];
172
185
  },
173
186
  },
174
187
  groups: {
@@ -181,13 +194,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
181
194
  messaging: {
182
195
  normalizeTarget: normalizeZaloMessagingTarget,
183
196
  targetResolver: {
184
- looksLikeId: (raw) => {
185
- const trimmed = raw.trim();
186
- if (!trimmed) {
187
- return false;
188
- }
189
- return /^\d{3,}$/.test(trimmed);
190
- },
197
+ looksLikeId: isNumericTargetId,
191
198
  hint: "<chatId>",
192
199
  },
193
200
  },
@@ -195,19 +202,12 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
195
202
  self: async () => null,
196
203
  listPeers: async ({ cfg, accountId, query, limit }) => {
197
204
  const account = resolveZaloAccount({ cfg: cfg, accountId });
198
- const q = query?.trim().toLowerCase() || "";
199
- const peers = Array.from(
200
- new Set(
201
- (account.config.allowFrom ?? [])
202
- .map((entry) => String(entry).trim())
203
- .filter((entry) => Boolean(entry) && entry !== "*")
204
- .map((entry) => entry.replace(/^(zalo|zl):/i, "")),
205
- ),
206
- )
207
- .filter((id) => (q ? id.toLowerCase().includes(q) : true))
208
- .slice(0, limit && limit > 0 ? limit : undefined)
209
- .map((id) => ({ kind: "user", id }) as const);
210
- return peers;
205
+ return listDirectoryUserEntriesFromAllowFrom({
206
+ allowFrom: account.config.allowFrom,
207
+ query,
208
+ limit,
209
+ normalizeId: (entry) => entry.replace(/^(zalo|zl):/i, ""),
210
+ });
211
211
  },
212
212
  listGroups: async () => [],
213
213
  },
@@ -243,47 +243,19 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
243
243
  channelKey: "zalo",
244
244
  })
245
245
  : namedConfig;
246
- if (accountId === DEFAULT_ACCOUNT_ID) {
247
- return {
248
- ...next,
249
- channels: {
250
- ...next.channels,
251
- zalo: {
252
- ...next.channels?.zalo,
253
- enabled: true,
254
- ...(input.useEnv
255
- ? {}
256
- : input.tokenFile
257
- ? { tokenFile: input.tokenFile }
258
- : input.token
259
- ? { botToken: input.token }
260
- : {}),
261
- },
262
- },
263
- } as OpenClawConfig;
264
- }
265
- return {
266
- ...next,
267
- channels: {
268
- ...next.channels,
269
- zalo: {
270
- ...next.channels?.zalo,
271
- enabled: true,
272
- accounts: {
273
- ...next.channels?.zalo?.accounts,
274
- [accountId]: {
275
- ...next.channels?.zalo?.accounts?.[accountId],
276
- enabled: true,
277
- ...(input.tokenFile
278
- ? { tokenFile: input.tokenFile }
279
- : input.token
280
- ? { botToken: input.token }
281
- : {}),
282
- },
283
- },
284
- },
285
- },
286
- } as OpenClawConfig;
246
+ const patch = input.useEnv
247
+ ? {}
248
+ : input.tokenFile
249
+ ? { tokenFile: input.tokenFile }
250
+ : input.token
251
+ ? { botToken: input.token }
252
+ : {};
253
+ return applySetupAccountConfigPatch({
254
+ cfg: next,
255
+ channelKey: "zalo",
256
+ accountId,
257
+ patch,
258
+ });
287
259
  },
288
260
  },
289
261
  pairing: {
@@ -302,17 +274,21 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
302
274
  chunker: chunkTextForOutbound,
303
275
  chunkerMode: "text",
304
276
  textChunkLimit: 2000,
277
+ sendPayload: async (ctx) =>
278
+ await sendPayloadWithChunkedTextAndMedia({
279
+ ctx,
280
+ textChunkLimit: zaloPlugin.outbound!.textChunkLimit,
281
+ chunker: zaloPlugin.outbound!.chunker,
282
+ sendText: (nextCtx) => zaloPlugin.outbound!.sendText!(nextCtx),
283
+ sendMedia: (nextCtx) => zaloPlugin.outbound!.sendMedia!(nextCtx),
284
+ emptyResult: { channel: "zalo", messageId: "" },
285
+ }),
305
286
  sendText: async ({ to, text, accountId, cfg }) => {
306
287
  const result = await sendMessageZalo(to, text, {
307
288
  accountId: accountId ?? undefined,
308
289
  cfg: cfg,
309
290
  });
310
- return {
311
- channel: "zalo",
312
- ok: result.ok,
313
- messageId: result.messageId ?? "",
314
- error: result.error ? new Error(result.error) : undefined,
315
- };
291
+ return buildChannelSendResult("zalo", result);
316
292
  },
317
293
  sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
318
294
  const result = await sendMessageZalo(to, text, {
@@ -320,12 +296,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
320
296
  mediaUrl,
321
297
  cfg: cfg,
322
298
  });
323
- return {
324
- channel: "zalo",
325
- ok: result.ok,
326
- messageId: result.messageId ?? "",
327
- error: result.error ? new Error(result.error) : undefined,
328
- };
299
+ return buildChannelSendResult("zalo", result);
329
300
  },
330
301
  },
331
302
  status: {
@@ -342,19 +313,19 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
342
313
  probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)),
343
314
  buildAccountSnapshot: ({ account, runtime }) => {
344
315
  const configured = Boolean(account.token?.trim());
316
+ const base = buildBaseAccountStatusSnapshot({
317
+ account: {
318
+ accountId: account.accountId,
319
+ name: account.name,
320
+ enabled: account.enabled,
321
+ configured,
322
+ },
323
+ runtime,
324
+ });
345
325
  return {
346
- accountId: account.accountId,
347
- name: account.name,
348
- enabled: account.enabled,
349
- configured,
326
+ ...base,
350
327
  tokenSource: account.tokenSource,
351
- running: runtime?.running ?? false,
352
- lastStartAt: runtime?.lastStartAt ?? null,
353
- lastStopAt: runtime?.lastStopAt ?? null,
354
- lastError: runtime?.lastError ?? null,
355
328
  mode: account.config.webhookUrl ? "webhook" : "polling",
356
- lastInboundAt: runtime?.lastInboundAt ?? null,
357
- lastOutboundAt: runtime?.lastOutboundAt ?? null,
358
329
  dmPolicy: account.config.dmPolicy ?? "pairing",
359
330
  };
360
331
  },
@@ -388,7 +359,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
388
359
  abortSignal: ctx.abortSignal,
389
360
  useWebhook: Boolean(account.config.webhookUrl),
390
361
  webhookUrl: account.config.webhookUrl,
391
- webhookSecret: account.config.webhookSecret,
362
+ webhookSecret: normalizeSecretInputString(account.config.webhookSecret),
392
363
  webhookPath: account.config.webhookPath,
393
364
  fetcher,
394
365
  statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ZaloConfigSchema } from "./config-schema.js";
3
+
4
+ describe("ZaloConfigSchema SecretInput", () => {
5
+ it("accepts SecretRef botToken and webhookSecret at top-level", () => {
6
+ const result = ZaloConfigSchema.safeParse({
7
+ botToken: { source: "env", provider: "default", id: "ZALO_BOT_TOKEN" },
8
+ webhookUrl: "https://example.com/zalo",
9
+ webhookSecret: { source: "env", provider: "default", id: "ZALO_WEBHOOK_SECRET" },
10
+ });
11
+ expect(result.success).toBe(true);
12
+ });
13
+
14
+ it("accepts SecretRef botToken and webhookSecret on account", () => {
15
+ const result = ZaloConfigSchema.safeParse({
16
+ accounts: {
17
+ work: {
18
+ botToken: { source: "env", provider: "default", id: "ZALO_WORK_BOT_TOKEN" },
19
+ webhookUrl: "https://example.com/zalo/work",
20
+ webhookSecret: {
21
+ source: "env",
22
+ provider: "default",
23
+ id: "ZALO_WORK_WEBHOOK_SECRET",
24
+ },
25
+ },
26
+ },
27
+ });
28
+ expect(result.success).toBe(true);
29
+ });
30
+ });
@@ -1,5 +1,6 @@
1
- import { MarkdownConfigSchema } from "openclaw/plugin-sdk";
1
+ import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
2
2
  import { z } from "zod";
3
+ import { buildSecretInputSchema } from "./secret-input.js";
3
4
 
4
5
  const allowFromEntry = z.union([z.string(), z.number()]);
5
6
 
@@ -7,10 +8,10 @@ const zaloAccountSchema = z.object({
7
8
  name: z.string().optional(),
8
9
  enabled: z.boolean().optional(),
9
10
  markdown: MarkdownConfigSchema,
10
- botToken: z.string().optional(),
11
+ botToken: buildSecretInputSchema().optional(),
11
12
  tokenFile: z.string().optional(),
12
13
  webhookUrl: z.string().optional(),
13
- webhookSecret: z.string().optional(),
14
+ webhookSecret: buildSecretInputSchema().optional(),
14
15
  webhookPath: z.string().optional(),
15
16
  dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
16
17
  allowFrom: z.array(allowFromEntry).optional(),
@@ -1,9 +1,9 @@
1
- import type { GroupPolicy, SenderGroupAccessDecision } from "openclaw/plugin-sdk";
1
+ import type { GroupPolicy, SenderGroupAccessDecision } from "openclaw/plugin-sdk/zalo";
2
2
  import {
3
3
  evaluateSenderGroupAccess,
4
4
  isNormalizedSenderAllowed,
5
5
  resolveOpenProviderRuntimeGroupPolicy,
6
- } from "openclaw/plugin-sdk";
6
+ } from "openclaw/plugin-sdk/zalo";
7
7
 
8
8
  const ZALO_ALLOW_FROM_PREFIX_RE = /^(zalo|zl):/i;
9
9