@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.
@@ -0,0 +1,24 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
2
+ import { describe, expect, it } from "vitest";
3
+ import { zaloOnboardingAdapter } from "./onboarding.js";
4
+
5
+ describe("zalo onboarding status", () => {
6
+ it("treats SecretRef botToken as configured", async () => {
7
+ const status = await zaloOnboardingAdapter.getStatus({
8
+ cfg: {
9
+ channels: {
10
+ zalo: {
11
+ botToken: {
12
+ source: "env",
13
+ provider: "default",
14
+ id: "ZALO_BOT_TOKEN",
15
+ },
16
+ },
17
+ },
18
+ } as OpenClawConfig,
19
+ accountOverrides: {},
20
+ });
21
+
22
+ expect(status.configured).toBe(true);
23
+ });
24
+ });
package/src/onboarding.ts CHANGED
@@ -2,15 +2,19 @@ import type {
2
2
  ChannelOnboardingAdapter,
3
3
  ChannelOnboardingDmPolicy,
4
4
  OpenClawConfig,
5
+ SecretInput,
5
6
  WizardPrompter,
6
- } from "openclaw/plugin-sdk";
7
+ } from "openclaw/plugin-sdk/zalo";
7
8
  import {
8
- addWildcardAllowFrom,
9
+ buildSingleChannelSecretPromptState,
9
10
  DEFAULT_ACCOUNT_ID,
11
+ hasConfiguredSecretInput,
10
12
  mergeAllowFromEntries,
11
13
  normalizeAccountId,
12
- promptAccountId,
13
- } from "openclaw/plugin-sdk";
14
+ promptSingleChannelSecretInput,
15
+ resolveAccountIdForConfigure,
16
+ setTopLevelChannelDmPolicyWithAllowFrom,
17
+ } from "openclaw/plugin-sdk/zalo";
14
18
  import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
15
19
 
16
20
  const channel = "zalo" as const;
@@ -21,19 +25,11 @@ function setZaloDmPolicy(
21
25
  cfg: OpenClawConfig,
22
26
  dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
23
27
  ) {
24
- const allowFrom =
25
- dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.zalo?.allowFrom) : undefined;
26
- return {
27
- ...cfg,
28
- channels: {
29
- ...cfg.channels,
30
- zalo: {
31
- ...cfg.channels?.zalo,
32
- dmPolicy,
33
- ...(allowFrom ? { allowFrom } : {}),
34
- },
35
- },
36
- } as OpenClawConfig;
28
+ return setTopLevelChannelDmPolicyWithAllowFrom({
29
+ cfg,
30
+ channel: "zalo",
31
+ dmPolicy,
32
+ }) as OpenClawConfig;
37
33
  }
38
34
 
39
35
  function setZaloUpdateMode(
@@ -41,7 +37,7 @@ function setZaloUpdateMode(
41
37
  accountId: string,
42
38
  mode: UpdateMode,
43
39
  webhookUrl?: string,
44
- webhookSecret?: string,
40
+ webhookSecret?: SecretInput,
45
41
  webhookPath?: string,
46
42
  ): OpenClawConfig {
47
43
  const isDefault = accountId === DEFAULT_ACCOUNT_ID;
@@ -210,9 +206,18 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
210
206
  channel,
211
207
  dmPolicy,
212
208
  getStatus: async ({ cfg }) => {
213
- const configured = listZaloAccountIds(cfg).some((accountId) =>
214
- Boolean(resolveZaloAccount({ cfg: cfg, accountId }).token),
215
- );
209
+ const configured = listZaloAccountIds(cfg).some((accountId) => {
210
+ const account = resolveZaloAccount({
211
+ cfg: cfg,
212
+ accountId,
213
+ allowUnresolvedSecretRef: true,
214
+ });
215
+ return (
216
+ Boolean(account.token) ||
217
+ hasConfiguredSecretInput(account.config.botToken) ||
218
+ Boolean(account.config.tokenFile?.trim())
219
+ );
220
+ });
216
221
  return {
217
222
  channel,
218
223
  configured,
@@ -228,77 +233,66 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
228
233
  shouldPromptAccountIds,
229
234
  forceAllowFrom,
230
235
  }) => {
231
- const zaloOverride = accountOverrides.zalo?.trim();
232
236
  const defaultZaloAccountId = resolveDefaultZaloAccountId(cfg);
233
- let zaloAccountId = zaloOverride ? normalizeAccountId(zaloOverride) : defaultZaloAccountId;
234
- if (shouldPromptAccountIds && !zaloOverride) {
235
- zaloAccountId = await promptAccountId({
236
- cfg: cfg,
237
- prompter,
238
- label: "Zalo",
239
- currentId: zaloAccountId,
240
- listAccountIds: listZaloAccountIds,
241
- defaultAccountId: defaultZaloAccountId,
242
- });
243
- }
237
+ const zaloAccountId = await resolveAccountIdForConfigure({
238
+ cfg,
239
+ prompter,
240
+ label: "Zalo",
241
+ accountOverride: accountOverrides.zalo,
242
+ shouldPromptAccountIds,
243
+ listAccountIds: listZaloAccountIds,
244
+ defaultAccountId: defaultZaloAccountId,
245
+ });
244
246
 
245
247
  let next = cfg;
246
- const resolvedAccount = resolveZaloAccount({ cfg: next, accountId: zaloAccountId });
248
+ const resolvedAccount = resolveZaloAccount({
249
+ cfg: next,
250
+ accountId: zaloAccountId,
251
+ allowUnresolvedSecretRef: true,
252
+ });
247
253
  const accountConfigured = Boolean(resolvedAccount.token);
248
254
  const allowEnv = zaloAccountId === DEFAULT_ACCOUNT_ID;
249
- const canUseEnv = allowEnv && Boolean(process.env.ZALO_BOT_TOKEN?.trim());
250
255
  const hasConfigToken = Boolean(
251
- resolvedAccount.config.botToken || resolvedAccount.config.tokenFile,
256
+ hasConfiguredSecretInput(resolvedAccount.config.botToken) || resolvedAccount.config.tokenFile,
252
257
  );
258
+ const tokenPromptState = buildSingleChannelSecretPromptState({
259
+ accountConfigured,
260
+ hasConfigToken,
261
+ allowEnv,
262
+ envValue: process.env.ZALO_BOT_TOKEN,
263
+ });
253
264
 
254
- let token: string | null = null;
265
+ let token: SecretInput | null = null;
255
266
  if (!accountConfigured) {
256
267
  await noteZaloTokenHelp(prompter);
257
268
  }
258
- if (canUseEnv && !resolvedAccount.config.botToken) {
259
- const keepEnv = await prompter.confirm({
260
- message: "ZALO_BOT_TOKEN detected. Use env var?",
261
- initialValue: true,
262
- });
263
- if (keepEnv) {
264
- next = {
265
- ...next,
266
- channels: {
267
- ...next.channels,
268
- zalo: {
269
- ...next.channels?.zalo,
270
- enabled: true,
271
- },
269
+ const tokenResult = await promptSingleChannelSecretInput({
270
+ cfg: next,
271
+ prompter,
272
+ providerHint: "zalo",
273
+ credentialLabel: "bot token",
274
+ accountConfigured: tokenPromptState.accountConfigured,
275
+ canUseEnv: tokenPromptState.canUseEnv,
276
+ hasConfigToken: tokenPromptState.hasConfigToken,
277
+ envPrompt: "ZALO_BOT_TOKEN detected. Use env var?",
278
+ keepPrompt: "Zalo token already configured. Keep it?",
279
+ inputPrompt: "Enter Zalo bot token",
280
+ 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,
272
293
  },
273
- } as OpenClawConfig;
274
- } else {
275
- token = String(
276
- await prompter.text({
277
- message: "Enter Zalo bot token",
278
- validate: (value) => (value?.trim() ? undefined : "Required"),
279
- }),
280
- ).trim();
281
- }
282
- } else if (hasConfigToken) {
283
- const keep = await prompter.confirm({
284
- message: "Zalo token already configured. Keep it?",
285
- initialValue: true,
286
- });
287
- if (!keep) {
288
- token = String(
289
- await prompter.text({
290
- message: "Enter Zalo bot token",
291
- validate: (value) => (value?.trim() ? undefined : "Required"),
292
- }),
293
- ).trim();
294
- }
295
- } else {
296
- token = String(
297
- await prompter.text({
298
- message: "Enter Zalo bot token",
299
- validate: (value) => (value?.trim() ? undefined : "Required"),
300
- }),
301
- ).trim();
294
+ },
295
+ } as OpenClawConfig;
302
296
  }
303
297
 
304
298
  if (token) {
@@ -338,12 +332,13 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
338
332
 
339
333
  const wantsWebhook = await prompter.confirm({
340
334
  message: "Use webhook mode for Zalo?",
341
- initialValue: false,
335
+ initialValue: Boolean(resolvedAccount.config.webhookUrl),
342
336
  });
343
337
  if (wantsWebhook) {
344
338
  const webhookUrl = String(
345
339
  await prompter.text({
346
340
  message: "Webhook URL (https://...) ",
341
+ initialValue: resolvedAccount.config.webhookUrl,
347
342
  validate: (value) =>
348
343
  value?.trim()?.startsWith("https://") ? undefined : "HTTPS URL required",
349
344
  }),
@@ -355,22 +350,51 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
355
350
  return "/zalo-webhook";
356
351
  }
357
352
  })();
358
- const webhookSecret = String(
359
- await prompter.text({
360
- message: "Webhook secret (8-256 chars)",
361
- validate: (value) => {
362
- const raw = String(value ?? "");
363
- if (raw.length < 8 || raw.length > 256) {
364
- return "8-256 chars";
365
- }
366
- return undefined;
367
- },
353
+ let webhookSecretResult = await promptSingleChannelSecretInput({
354
+ cfg: next,
355
+ prompter,
356
+ providerHint: "zalo-webhook",
357
+ credentialLabel: "webhook secret",
358
+ ...buildSingleChannelSecretPromptState({
359
+ accountConfigured: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
360
+ hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
361
+ allowEnv: false,
368
362
  }),
369
- ).trim();
363
+ envPrompt: "",
364
+ keepPrompt: "Zalo webhook secret already configured. Keep it?",
365
+ inputPrompt: "Webhook secret (8-256 chars)",
366
+ preferredEnvVar: "ZALO_WEBHOOK_SECRET",
367
+ });
368
+ while (
369
+ webhookSecretResult.action === "set" &&
370
+ typeof webhookSecretResult.value === "string" &&
371
+ (webhookSecretResult.value.length < 8 || webhookSecretResult.value.length > 256)
372
+ ) {
373
+ await prompter.note("Webhook secret must be between 8 and 256 characters.", "Zalo webhook");
374
+ webhookSecretResult = await promptSingleChannelSecretInput({
375
+ cfg: next,
376
+ prompter,
377
+ providerHint: "zalo-webhook",
378
+ credentialLabel: "webhook secret",
379
+ ...buildSingleChannelSecretPromptState({
380
+ accountConfigured: false,
381
+ hasConfigToken: false,
382
+ allowEnv: false,
383
+ }),
384
+ envPrompt: "",
385
+ keepPrompt: "Zalo webhook secret already configured. Keep it?",
386
+ inputPrompt: "Webhook secret (8-256 chars)",
387
+ preferredEnvVar: "ZALO_WEBHOOK_SECRET",
388
+ });
389
+ }
390
+ const webhookSecret =
391
+ webhookSecretResult.action === "set"
392
+ ? webhookSecretResult.value
393
+ : resolvedAccount.config.webhookSecret;
370
394
  const webhookPath = String(
371
395
  await prompter.text({
372
396
  message: "Webhook path (optional)",
373
- initialValue: defaultPath,
397
+ initialValue: resolvedAccount.config.webhookPath ?? defaultPath,
374
398
  }),
375
399
  ).trim();
376
400
  next = setZaloUpdateMode(
package/src/probe.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BaseProbeResult } from "openclaw/plugin-sdk";
1
+ import type { BaseProbeResult } from "openclaw/plugin-sdk/zalo";
2
2
  import { getMe, ZaloApiError, type ZaloBotInfo, type ZaloFetch } from "./api.js";
3
3
 
4
4
  export type ZaloProbeResult = BaseProbeResult<string> & {
package/src/runtime.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PluginRuntime } from "openclaw/plugin-sdk";
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk/zalo";
2
2
 
3
3
  let runtime: PluginRuntime | null = null;
4
4
 
@@ -0,0 +1,13 @@
1
+ import {
2
+ buildSecretInputSchema,
3
+ hasConfiguredSecretInput,
4
+ normalizeResolvedSecretInputString,
5
+ normalizeSecretInputString,
6
+ } from "openclaw/plugin-sdk/zalo";
7
+
8
+ export {
9
+ buildSecretInputSchema,
10
+ hasConfiguredSecretInput,
11
+ normalizeResolvedSecretInputString,
12
+ normalizeSecretInputString,
13
+ };
package/src/send.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
2
2
  import { resolveZaloAccount } from "./accounts.js";
3
3
  import type { ZaloFetch } from "./api.js";
4
4
  import { sendMessage, sendPhoto } from "./api.js";
@@ -40,37 +40,47 @@ function resolveSendContext(options: ZaloSendOptions): {
40
40
  return { token, fetcher: resolveZaloProxyFetch(proxy) };
41
41
  }
42
42
 
43
- export async function sendMessageZalo(
43
+ function resolveValidatedSendContext(
44
44
  chatId: string,
45
- text: string,
46
- options: ZaloSendOptions = {},
47
- ): Promise<ZaloSendResult> {
45
+ options: ZaloSendOptions,
46
+ ): { ok: true; chatId: string; token: string; fetcher?: ZaloFetch } | { ok: false; error: string } {
48
47
  const { token, fetcher } = resolveSendContext(options);
49
-
50
48
  if (!token) {
51
49
  return { ok: false, error: "No Zalo bot token configured" };
52
50
  }
53
-
54
- if (!chatId?.trim()) {
51
+ const trimmedChatId = chatId?.trim();
52
+ if (!trimmedChatId) {
55
53
  return { ok: false, error: "No chat_id provided" };
56
54
  }
55
+ return { ok: true, chatId: trimmedChatId, token, fetcher };
56
+ }
57
+
58
+ export async function sendMessageZalo(
59
+ chatId: string,
60
+ text: string,
61
+ options: ZaloSendOptions = {},
62
+ ): Promise<ZaloSendResult> {
63
+ const context = resolveValidatedSendContext(chatId, options);
64
+ if (!context.ok) {
65
+ return { ok: false, error: context.error };
66
+ }
57
67
 
58
68
  if (options.mediaUrl) {
59
- return sendPhotoZalo(chatId, options.mediaUrl, {
69
+ return sendPhotoZalo(context.chatId, options.mediaUrl, {
60
70
  ...options,
61
- token,
71
+ token: context.token,
62
72
  caption: text || options.caption,
63
73
  });
64
74
  }
65
75
 
66
76
  try {
67
77
  const response = await sendMessage(
68
- token,
78
+ context.token,
69
79
  {
70
- chat_id: chatId.trim(),
80
+ chat_id: context.chatId,
71
81
  text: text.slice(0, 2000),
72
82
  },
73
- fetcher,
83
+ context.fetcher,
74
84
  );
75
85
 
76
86
  if (response.ok && response.result) {
@@ -88,14 +98,9 @@ export async function sendPhotoZalo(
88
98
  photoUrl: string,
89
99
  options: ZaloSendOptions = {},
90
100
  ): Promise<ZaloSendResult> {
91
- const { token, fetcher } = resolveSendContext(options);
92
-
93
- if (!token) {
94
- return { ok: false, error: "No Zalo bot token configured" };
95
- }
96
-
97
- if (!chatId?.trim()) {
98
- return { ok: false, error: "No chat_id provided" };
101
+ const context = resolveValidatedSendContext(chatId, options);
102
+ if (!context.ok) {
103
+ return { ok: false, error: context.error };
99
104
  }
100
105
 
101
106
  if (!photoUrl?.trim()) {
@@ -104,13 +109,13 @@ export async function sendPhotoZalo(
104
109
 
105
110
  try {
106
111
  const response = await sendPhoto(
107
- token,
112
+ context.token,
108
113
  {
109
- chat_id: chatId.trim(),
114
+ chat_id: context.chatId,
110
115
  photo: photoUrl.trim(),
111
116
  caption: options.caption?.slice(0, 2000),
112
117
  },
113
- fetcher,
118
+ context.fetcher,
114
119
  );
115
120
 
116
121
  if (response.ok && response.result) {
@@ -1,4 +1,4 @@
1
- import type { ChannelAccountSnapshot, ChannelStatusIssue } from "openclaw/plugin-sdk";
1
+ import type { ChannelAccountSnapshot, ChannelStatusIssue } from "openclaw/plugin-sdk/zalo";
2
2
 
3
3
  type ZaloAccountStatus = {
4
4
  accountId?: unknown;
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveZaloToken } from "./token.js";
3
+ import type { ZaloConfig } from "./types.js";
4
+
5
+ describe("resolveZaloToken", () => {
6
+ it("falls back to top-level token for non-default accounts without overrides", () => {
7
+ const cfg = {
8
+ botToken: "top-level-token",
9
+ accounts: {
10
+ work: {},
11
+ },
12
+ } as ZaloConfig;
13
+ const res = resolveZaloToken(cfg, "work");
14
+ expect(res.token).toBe("top-level-token");
15
+ expect(res.source).toBe("config");
16
+ });
17
+
18
+ it("uses accounts.default botToken for default account when configured", () => {
19
+ const cfg = {
20
+ botToken: "top-level-token",
21
+ accounts: {
22
+ default: {
23
+ botToken: "default-account-token",
24
+ },
25
+ },
26
+ } as ZaloConfig;
27
+ const res = resolveZaloToken(cfg, "default");
28
+ expect(res.token).toBe("default-account-token");
29
+ expect(res.source).toBe("config");
30
+ });
31
+
32
+ it("does not inherit top-level token when account token is explicitly blank", () => {
33
+ const cfg = {
34
+ botToken: "top-level-token",
35
+ accounts: {
36
+ work: {
37
+ botToken: "",
38
+ },
39
+ },
40
+ } as ZaloConfig;
41
+ const res = resolveZaloToken(cfg, "work");
42
+ expect(res.token).toBe("");
43
+ expect(res.source).toBe("none");
44
+ });
45
+
46
+ it("resolves account token when account key casing differs from normalized id", () => {
47
+ const cfg = {
48
+ accounts: {
49
+ Work: {
50
+ botToken: "work-token",
51
+ },
52
+ },
53
+ } as ZaloConfig;
54
+ const res = resolveZaloToken(cfg, "work");
55
+ expect(res.token).toBe("work-token");
56
+ expect(res.source).toBe("config");
57
+ });
58
+ });
package/src/token.ts CHANGED
@@ -1,57 +1,92 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { type BaseTokenResolution, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
2
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
3
+ import type { BaseTokenResolution } from "openclaw/plugin-sdk/zalo";
4
+ import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js";
3
5
  import type { ZaloConfig } from "./types.js";
4
6
 
5
7
  export type ZaloTokenResolution = BaseTokenResolution & {
6
8
  source: "env" | "config" | "configFile" | "none";
7
9
  };
8
10
 
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
+ }
22
+ }
23
+
9
24
  export function resolveZaloToken(
10
25
  config: ZaloConfig | undefined,
11
26
  accountId?: string | null,
27
+ options?: { allowUnresolvedSecretRef?: boolean },
12
28
  ): ZaloTokenResolution {
13
29
  const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
14
30
  const isDefaultAccount = resolvedAccountId === DEFAULT_ACCOUNT_ID;
15
31
  const baseConfig = config;
16
- const accountConfig =
17
- resolvedAccountId !== DEFAULT_ACCOUNT_ID
18
- ? (baseConfig?.accounts?.[resolvedAccountId] as ZaloConfig | undefined)
19
- : undefined;
32
+ const resolveAccountConfig = (id: string): ZaloConfig | undefined => {
33
+ const accounts = baseConfig?.accounts;
34
+ if (!accounts || typeof accounts !== "object") {
35
+ return undefined;
36
+ }
37
+ const direct = accounts[id] as ZaloConfig | undefined;
38
+ if (direct) {
39
+ return direct;
40
+ }
41
+ const normalized = normalizeAccountId(id);
42
+ const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized);
43
+ return matchKey ? ((accounts as Record<string, ZaloConfig>)[matchKey] ?? undefined) : undefined;
44
+ };
45
+ const accountConfig = resolveAccountConfig(resolvedAccountId);
46
+ const accountHasBotToken = Boolean(
47
+ accountConfig && Object.prototype.hasOwnProperty.call(accountConfig, "botToken"),
48
+ );
20
49
 
21
- if (accountConfig) {
22
- const token = accountConfig.botToken?.trim();
50
+ if (accountConfig && accountHasBotToken) {
51
+ const token = options?.allowUnresolvedSecretRef
52
+ ? normalizeSecretInputString(accountConfig.botToken)
53
+ : normalizeResolvedSecretInputString({
54
+ value: accountConfig.botToken,
55
+ path: `channels.zalo.accounts.${resolvedAccountId}.botToken`,
56
+ });
23
57
  if (token) {
24
58
  return { token, source: "config" };
25
59
  }
26
- const tokenFile = accountConfig.tokenFile?.trim();
27
- if (tokenFile) {
28
- try {
29
- const fileToken = readFileSync(tokenFile, "utf8").trim();
30
- if (fileToken) {
31
- return { token: fileToken, source: "configFile" };
32
- }
33
- } catch {
34
- // ignore read failures
35
- }
60
+ const fileToken = readTokenFromFile(accountConfig.tokenFile);
61
+ if (fileToken) {
62
+ return { token: fileToken, source: "configFile" };
36
63
  }
37
64
  }
38
65
 
39
- if (isDefaultAccount) {
40
- const token = baseConfig?.botToken?.trim();
66
+ if (!accountHasBotToken) {
67
+ const fileToken = readTokenFromFile(accountConfig?.tokenFile);
68
+ if (fileToken) {
69
+ return { token: fileToken, source: "configFile" };
70
+ }
71
+ }
72
+
73
+ if (!accountHasBotToken) {
74
+ const token = options?.allowUnresolvedSecretRef
75
+ ? normalizeSecretInputString(baseConfig?.botToken)
76
+ : normalizeResolvedSecretInputString({
77
+ value: baseConfig?.botToken,
78
+ path: "channels.zalo.botToken",
79
+ });
41
80
  if (token) {
42
81
  return { token, source: "config" };
43
82
  }
44
- const tokenFile = baseConfig?.tokenFile?.trim();
45
- if (tokenFile) {
46
- try {
47
- const fileToken = readFileSync(tokenFile, "utf8").trim();
48
- if (fileToken) {
49
- return { token: fileToken, source: "configFile" };
50
- }
51
- } catch {
52
- // ignore read failures
53
- }
83
+ const fileToken = readTokenFromFile(baseConfig?.tokenFile);
84
+ if (fileToken) {
85
+ return { token: fileToken, source: "configFile" };
54
86
  }
87
+ }
88
+
89
+ if (isDefaultAccount) {
55
90
  const envToken = process.env.ZALO_BOT_TOKEN?.trim();
56
91
  if (envToken) {
57
92
  return { token: envToken, source: "env" };
package/src/types.ts CHANGED
@@ -1,16 +1,18 @@
1
+ import type { SecretInput } from "openclaw/plugin-sdk/zalo";
2
+
1
3
  export type ZaloAccountConfig = {
2
4
  /** Optional display name for this account (used in CLI/UI lists). */
3
5
  name?: string;
4
6
  /** If false, do not start this Zalo account. Default: true. */
5
7
  enabled?: boolean;
6
8
  /** Bot token from Zalo Bot Creator. */
7
- botToken?: string;
9
+ botToken?: SecretInput;
8
10
  /** Path to file containing the bot token. */
9
11
  tokenFile?: string;
10
12
  /** Webhook URL for receiving updates (HTTPS required). */
11
13
  webhookUrl?: string;
12
14
  /** Webhook secret token (8-256 chars) for request verification. */
13
- webhookSecret?: string;
15
+ webhookSecret?: SecretInput;
14
16
  /** Webhook path for the gateway HTTP server (defaults to webhook URL path). */
15
17
  webhookPath?: string;
16
18
  /** Direct message access policy (default: pairing). */