@openclaw/zalo 2026.3.2 → 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,17 @@
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
+
3
15
  ## 2026.3.2
4
16
 
5
17
  ### Changes
package/index.ts CHANGED
@@ -1,5 +1,5 @@
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
4
  import { setZaloRuntime } from "./src/runtime.js";
5
5
 
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@openclaw/zalo",
3
- "version": "2026.3.2",
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,
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
 
@@ -1,4 +1,4 @@
1
- import type { ReplyPayload } from "openclaw/plugin-sdk";
1
+ import type { ReplyPayload } from "openclaw/plugin-sdk/zalo";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { zaloPlugin } from "./channel.js";
4
4
 
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,
@@ -66,9 +76,7 @@ export const zaloDock: ChannelDock = {
66
76
  outbound: { textChunkLimit: 2000 },
67
77
  config: {
68
78
  resolveAllowFrom: ({ cfg, accountId }) =>
69
- (resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
70
- String(entry),
71
- ),
79
+ mapAllowFromEntries(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom),
72
80
  formatAllowFrom: ({ allowFrom }) =>
73
81
  formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
74
82
  },
@@ -123,53 +131,57 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
123
131
  tokenSource: account.tokenSource,
124
132
  }),
125
133
  resolveAllowFrom: ({ cfg, accountId }) =>
126
- (resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
127
- String(entry),
128
- ),
134
+ mapAllowFromEntries(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom),
129
135
  formatAllowFrom: ({ allowFrom }) =>
130
136
  formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
131
137
  },
132
138
  security: {
133
139
  resolveDmPolicy: ({ cfg, accountId, account }) => {
134
- const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
135
- const basePath = resolveChannelAccountConfigBasePath({
140
+ return buildAccountScopedDmSecurityPolicy({
136
141
  cfg,
137
142
  channelKey: "zalo",
138
- accountId: resolvedAccountId,
139
- });
140
- return {
141
- policy: account.config.dmPolicy ?? "pairing",
143
+ accountId,
144
+ fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
145
+ policy: account.config.dmPolicy,
142
146
  allowFrom: account.config.allowFrom ?? [],
143
- policyPath: `${basePath}dmPolicy`,
144
- allowFromPath: basePath,
145
- approveHint: formatPairingApproveHint("zalo"),
147
+ policyPathSuffix: "dmPolicy",
146
148
  normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""),
147
- };
149
+ });
148
150
  },
149
151
  collectWarnings: ({ account, cfg }) => {
150
- const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
151
- const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
152
+ return collectOpenProviderGroupPolicyWarnings({
153
+ cfg,
152
154
  providerConfigPresent: cfg.channels?.zalo !== undefined,
153
- groupPolicy: account.config.groupPolicy,
154
- 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
+ },
155
184
  });
156
- if (groupPolicy !== "open") {
157
- return [];
158
- }
159
- const explicitGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) =>
160
- String(entry),
161
- );
162
- const dmAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
163
- const effectiveAllowFrom =
164
- explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom;
165
- if (effectiveAllowFrom.length > 0) {
166
- return [
167
- `- Zalo groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom to restrict senders.`,
168
- ];
169
- }
170
- return [
171
- `- Zalo groups: groupPolicy="open" with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom.`,
172
- ];
173
185
  },
174
186
  },
175
187
  groups: {
@@ -182,13 +194,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
182
194
  messaging: {
183
195
  normalizeTarget: normalizeZaloMessagingTarget,
184
196
  targetResolver: {
185
- looksLikeId: (raw) => {
186
- const trimmed = raw.trim();
187
- if (!trimmed) {
188
- return false;
189
- }
190
- return /^\d{3,}$/.test(trimmed);
191
- },
197
+ looksLikeId: isNumericTargetId,
192
198
  hint: "<chatId>",
193
199
  },
194
200
  },
@@ -196,19 +202,12 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
196
202
  self: async () => null,
197
203
  listPeers: async ({ cfg, accountId, query, limit }) => {
198
204
  const account = resolveZaloAccount({ cfg: cfg, accountId });
199
- const q = query?.trim().toLowerCase() || "";
200
- const peers = Array.from(
201
- new Set(
202
- (account.config.allowFrom ?? [])
203
- .map((entry) => String(entry).trim())
204
- .filter((entry) => Boolean(entry) && entry !== "*")
205
- .map((entry) => entry.replace(/^(zalo|zl):/i, "")),
206
- ),
207
- )
208
- .filter((id) => (q ? id.toLowerCase().includes(q) : true))
209
- .slice(0, limit && limit > 0 ? limit : undefined)
210
- .map((id) => ({ kind: "user", id }) as const);
211
- return peers;
205
+ return listDirectoryUserEntriesFromAllowFrom({
206
+ allowFrom: account.config.allowFrom,
207
+ query,
208
+ limit,
209
+ normalizeId: (entry) => entry.replace(/^(zalo|zl):/i, ""),
210
+ });
212
211
  },
213
212
  listGroups: async () => [],
214
213
  },
@@ -244,47 +243,19 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
244
243
  channelKey: "zalo",
245
244
  })
246
245
  : namedConfig;
247
- if (accountId === DEFAULT_ACCOUNT_ID) {
248
- return {
249
- ...next,
250
- channels: {
251
- ...next.channels,
252
- zalo: {
253
- ...next.channels?.zalo,
254
- enabled: true,
255
- ...(input.useEnv
256
- ? {}
257
- : input.tokenFile
258
- ? { tokenFile: input.tokenFile }
259
- : input.token
260
- ? { botToken: input.token }
261
- : {}),
262
- },
263
- },
264
- } as OpenClawConfig;
265
- }
266
- return {
267
- ...next,
268
- channels: {
269
- ...next.channels,
270
- zalo: {
271
- ...next.channels?.zalo,
272
- enabled: true,
273
- accounts: {
274
- ...next.channels?.zalo?.accounts,
275
- [accountId]: {
276
- ...next.channels?.zalo?.accounts?.[accountId],
277
- enabled: true,
278
- ...(input.tokenFile
279
- ? { tokenFile: input.tokenFile }
280
- : input.token
281
- ? { botToken: input.token }
282
- : {}),
283
- },
284
- },
285
- },
286
- },
287
- } 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
+ });
288
259
  },
289
260
  },
290
261
  pairing: {
@@ -303,51 +274,21 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
303
274
  chunker: chunkTextForOutbound,
304
275
  chunkerMode: "text",
305
276
  textChunkLimit: 2000,
306
- sendPayload: async (ctx) => {
307
- const text = ctx.payload.text ?? "";
308
- const urls = ctx.payload.mediaUrls?.length
309
- ? ctx.payload.mediaUrls
310
- : ctx.payload.mediaUrl
311
- ? [ctx.payload.mediaUrl]
312
- : [];
313
- if (!text && urls.length === 0) {
314
- return { channel: "zalo", messageId: "" };
315
- }
316
- if (urls.length > 0) {
317
- let lastResult = await zaloPlugin.outbound!.sendMedia!({
318
- ...ctx,
319
- text,
320
- mediaUrl: urls[0],
321
- });
322
- for (let i = 1; i < urls.length; i++) {
323
- lastResult = await zaloPlugin.outbound!.sendMedia!({
324
- ...ctx,
325
- text: "",
326
- mediaUrl: urls[i],
327
- });
328
- }
329
- return lastResult;
330
- }
331
- const outbound = zaloPlugin.outbound!;
332
- const limit = outbound.textChunkLimit;
333
- const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text];
334
- let lastResult: Awaited<ReturnType<NonNullable<typeof outbound.sendText>>>;
335
- for (const chunk of chunks) {
336
- lastResult = await outbound.sendText!({ ...ctx, text: chunk });
337
- }
338
- return lastResult!;
339
- },
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
+ }),
340
286
  sendText: async ({ to, text, accountId, cfg }) => {
341
287
  const result = await sendMessageZalo(to, text, {
342
288
  accountId: accountId ?? undefined,
343
289
  cfg: cfg,
344
290
  });
345
- return {
346
- channel: "zalo",
347
- ok: result.ok,
348
- messageId: result.messageId ?? "",
349
- error: result.error ? new Error(result.error) : undefined,
350
- };
291
+ return buildChannelSendResult("zalo", result);
351
292
  },
352
293
  sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
353
294
  const result = await sendMessageZalo(to, text, {
@@ -355,12 +296,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
355
296
  mediaUrl,
356
297
  cfg: cfg,
357
298
  });
358
- return {
359
- channel: "zalo",
360
- ok: result.ok,
361
- messageId: result.messageId ?? "",
362
- error: result.error ? new Error(result.error) : undefined,
363
- };
299
+ return buildChannelSendResult("zalo", result);
364
300
  },
365
301
  },
366
302
  status: {
@@ -377,19 +313,19 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
377
313
  probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)),
378
314
  buildAccountSnapshot: ({ account, runtime }) => {
379
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
+ });
380
325
  return {
381
- accountId: account.accountId,
382
- name: account.name,
383
- enabled: account.enabled,
384
- configured,
326
+ ...base,
385
327
  tokenSource: account.tokenSource,
386
- running: runtime?.running ?? false,
387
- lastStartAt: runtime?.lastStartAt ?? null,
388
- lastStopAt: runtime?.lastStopAt ?? null,
389
- lastError: runtime?.lastError ?? null,
390
328
  mode: account.config.webhookUrl ? "webhook" : "polling",
391
- lastInboundAt: runtime?.lastInboundAt ?? null,
392
- lastOutboundAt: runtime?.lastOutboundAt ?? null,
393
329
  dmPolicy: account.config.dmPolicy ?? "pairing",
394
330
  };
395
331
  },
@@ -1,4 +1,4 @@
1
- import { MarkdownConfigSchema } from "openclaw/plugin-sdk";
1
+ import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
2
2
  import { z } from "zod";
3
3
  import { buildSecretInputSchema } from "./secret-input.js";
4
4
 
@@ -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
 
package/src/monitor.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk";
2
+ import type {
3
+ MarkdownTableMode,
4
+ OpenClawConfig,
5
+ OutboundReplyPayload,
6
+ } from "openclaw/plugin-sdk/zalo";
3
7
  import {
4
8
  createScopedPairingAccess,
5
9
  createReplyPrefixOptions,
10
+ issuePairingChallenge,
6
11
  resolveDirectDmAuthorizationOutcome,
7
12
  resolveSenderCommandAuthorizationWithRuntime,
8
13
  resolveOutboundMediaUrls,
@@ -11,7 +16,7 @@ import {
11
16
  sendMediaWithLeadingCaption,
12
17
  resolveWebhookPath,
13
18
  warnMissingProviderGroupPolicyFallbackOnce,
14
- } from "openclaw/plugin-sdk";
19
+ } from "openclaw/plugin-sdk/zalo";
15
20
  import type { ResolvedZaloAccount } from "./accounts.js";
16
21
  import {
17
22
  ZaloApiError,
@@ -410,31 +415,30 @@ async function processMessageWithPipeline(params: {
410
415
  }
411
416
  if (directDmOutcome === "unauthorized") {
412
417
  if (dmPolicy === "pairing") {
413
- const { code, created } = await pairing.upsertPairingRequest({
414
- id: senderId,
418
+ await issuePairingChallenge({
419
+ channel: "zalo",
420
+ senderId,
421
+ senderIdLine: `Your Zalo user id: ${senderId}`,
415
422
  meta: { name: senderName ?? undefined },
416
- });
417
-
418
- if (created) {
419
- logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
420
- try {
423
+ upsertPairingRequest: pairing.upsertPairingRequest,
424
+ onCreated: () => {
425
+ logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
426
+ },
427
+ sendPairingReply: async (text) => {
421
428
  await sendMessage(
422
429
  token,
423
430
  {
424
431
  chat_id: chatId,
425
- text: core.channel.pairing.buildPairingReply({
426
- channel: "zalo",
427
- idLine: `Your Zalo user id: ${senderId}`,
428
- code,
429
- }),
432
+ text,
430
433
  },
431
434
  fetcher,
432
435
  );
433
436
  statusSink?.({ lastOutboundAt: Date.now() });
434
- } catch (err) {
437
+ },
438
+ onReplyError: (err) => {
435
439
  logVerbose(core, runtime, `zalo pairing reply failed for ${senderId}: ${String(err)}`);
436
- }
437
- }
440
+ },
441
+ });
438
442
  } else {
439
443
  logVerbose(
440
444
  core,
@@ -1,6 +1,6 @@
1
1
  import { createServer, type RequestListener } from "node:http";
2
2
  import type { AddressInfo } from "node:net";
3
- import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
3
+ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/zalo";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
6
6
  import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
@@ -94,6 +94,33 @@ function createPairingAuthCore(params?: { storeAllowFrom?: string[]; pairingCrea
94
94
  return { core, readAllowFromStore, upsertPairingRequest };
95
95
  }
96
96
 
97
+ async function postUntilRateLimited(params: {
98
+ baseUrl: string;
99
+ path: string;
100
+ secret: string;
101
+ withNonceQuery?: boolean;
102
+ attempts?: number;
103
+ }): Promise<boolean> {
104
+ const attempts = params.attempts ?? 130;
105
+ for (let i = 0; i < attempts; i += 1) {
106
+ const url = params.withNonceQuery
107
+ ? `${params.baseUrl}${params.path}?nonce=${i}`
108
+ : `${params.baseUrl}${params.path}`;
109
+ const response = await fetch(url, {
110
+ method: "POST",
111
+ headers: {
112
+ "x-bot-api-secret-token": params.secret,
113
+ "content-type": "application/json",
114
+ },
115
+ body: "{}",
116
+ });
117
+ if (response.status === 429) {
118
+ return true;
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+
97
124
  describe("handleZaloWebhookRequest", () => {
98
125
  afterEach(() => {
99
126
  clearZaloWebhookSecurityStateForTest();
@@ -239,21 +266,11 @@ describe("handleZaloWebhookRequest", () => {
239
266
 
240
267
  try {
241
268
  await withServer(webhookRequestHandler, async (baseUrl) => {
242
- let saw429 = false;
243
- for (let i = 0; i < 130; i += 1) {
244
- const response = await fetch(`${baseUrl}/hook-rate`, {
245
- method: "POST",
246
- headers: {
247
- "x-bot-api-secret-token": "secret",
248
- "content-type": "application/json",
249
- },
250
- body: "{}",
251
- });
252
- if (response.status === 429) {
253
- saw429 = true;
254
- break;
255
- }
256
- }
269
+ const saw429 = await postUntilRateLimited({
270
+ baseUrl,
271
+ path: "/hook-rate",
272
+ secret: "secret", // pragma: allowlist secret
273
+ });
257
274
 
258
275
  expect(saw429).toBe(true);
259
276
  });
@@ -270,7 +287,7 @@ describe("handleZaloWebhookRequest", () => {
270
287
  const response = await fetch(`${baseUrl}/hook-query-status?nonce=${i}`, {
271
288
  method: "POST",
272
289
  headers: {
273
- "x-bot-api-secret-token": "invalid-token",
290
+ "x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
274
291
  "content-type": "application/json",
275
292
  },
276
293
  body: "{}",
@@ -290,21 +307,12 @@ describe("handleZaloWebhookRequest", () => {
290
307
 
291
308
  try {
292
309
  await withServer(webhookRequestHandler, async (baseUrl) => {
293
- let saw429 = false;
294
- for (let i = 0; i < 130; i += 1) {
295
- const response = await fetch(`${baseUrl}/hook-query-rate?nonce=${i}`, {
296
- method: "POST",
297
- headers: {
298
- "x-bot-api-secret-token": "secret",
299
- "content-type": "application/json",
300
- },
301
- body: "{}",
302
- });
303
- if (response.status === 429) {
304
- saw429 = true;
305
- break;
306
- }
307
- }
310
+ const saw429 = await postUntilRateLimited({
311
+ baseUrl,
312
+ path: "/hook-query-rate",
313
+ secret: "secret", // pragma: allowlist secret
314
+ withNonceQuery: true,
315
+ });
308
316
 
309
317
  expect(saw429).toBe(true);
310
318
  expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
@@ -1,6 +1,6 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import type { IncomingMessage, ServerResponse } from "node:http";
3
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
4
4
  import {
5
5
  createDedupeCache,
6
6
  createFixedWindowRateLimiter,
@@ -11,11 +11,11 @@ import {
11
11
  type RegisterWebhookTargetOptions,
12
12
  type RegisterWebhookPluginRouteOptions,
13
13
  registerWebhookTarget,
14
- resolveSingleWebhookTarget,
15
- resolveWebhookTargets,
14
+ resolveWebhookTargetWithAuthOrRejectSync,
15
+ withResolvedWebhookRequestPipeline,
16
16
  WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
17
17
  WEBHOOK_RATE_LIMIT_DEFAULTS,
18
- } from "openclaw/plugin-sdk";
18
+ } from "openclaw/plugin-sdk/zalo";
19
19
  import type { ResolvedZaloAccount } from "./accounts.js";
20
20
  import type { ZaloFetch, ZaloUpdate } from "./api.js";
21
21
  import type { ZaloRuntimeEnv } from "./monitor.js";
@@ -134,95 +134,80 @@ export async function handleZaloWebhookRequest(
134
134
  res: ServerResponse,
135
135
  processUpdate: ZaloWebhookProcessUpdate,
136
136
  ): Promise<boolean> {
137
- const resolved = resolveWebhookTargets(req, webhookTargets);
138
- if (!resolved) {
139
- return false;
140
- }
141
- const { targets, path } = resolved;
142
-
143
- if (
144
- !applyBasicWebhookRequestGuards({
145
- req,
146
- res,
147
- allowMethods: ["POST"],
148
- })
149
- ) {
150
- return true;
151
- }
152
-
153
- const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
154
- const matchedTarget = resolveSingleWebhookTarget(targets, (entry) =>
155
- timingSafeEquals(entry.secret, headerToken),
156
- );
157
- if (matchedTarget.kind === "none") {
158
- res.statusCode = 401;
159
- res.end("unauthorized");
160
- recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
161
- return true;
162
- }
163
- if (matchedTarget.kind === "ambiguous") {
164
- res.statusCode = 401;
165
- res.end("ambiguous webhook target");
166
- recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
167
- return true;
168
- }
169
- const target = matchedTarget.target;
170
- const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
171
- const nowMs = Date.now();
172
-
173
- if (
174
- !applyBasicWebhookRequestGuards({
175
- req,
176
- res,
177
- rateLimiter: webhookRateLimiter,
178
- rateLimitKey,
179
- nowMs,
180
- requireJsonContentType: true,
181
- })
182
- ) {
183
- recordWebhookStatus(target.runtime, path, res.statusCode);
184
- return true;
185
- }
186
- const body = await readJsonWebhookBodyOrReject({
137
+ return await withResolvedWebhookRequestPipeline({
187
138
  req,
188
139
  res,
189
- maxBytes: 1024 * 1024,
190
- timeoutMs: 30_000,
191
- emptyObjectOnEmpty: false,
192
- invalidJsonMessage: "Bad Request",
140
+ targetsByPath: webhookTargets,
141
+ allowMethods: ["POST"],
142
+ handle: async ({ targets, path }) => {
143
+ const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
144
+ const target = resolveWebhookTargetWithAuthOrRejectSync({
145
+ targets,
146
+ res,
147
+ isMatch: (entry) => timingSafeEquals(entry.secret, headerToken),
148
+ });
149
+ if (!target) {
150
+ recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
151
+ return true;
152
+ }
153
+ const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
154
+ const nowMs = Date.now();
155
+
156
+ if (
157
+ !applyBasicWebhookRequestGuards({
158
+ req,
159
+ res,
160
+ rateLimiter: webhookRateLimiter,
161
+ rateLimitKey,
162
+ nowMs,
163
+ requireJsonContentType: true,
164
+ })
165
+ ) {
166
+ recordWebhookStatus(target.runtime, path, res.statusCode);
167
+ return true;
168
+ }
169
+ const body = await readJsonWebhookBodyOrReject({
170
+ req,
171
+ res,
172
+ maxBytes: 1024 * 1024,
173
+ timeoutMs: 30_000,
174
+ emptyObjectOnEmpty: false,
175
+ invalidJsonMessage: "Bad Request",
176
+ });
177
+ if (!body.ok) {
178
+ recordWebhookStatus(target.runtime, path, res.statusCode);
179
+ return true;
180
+ }
181
+ const raw = body.value;
182
+
183
+ // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }.
184
+ const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
185
+ const update: ZaloUpdate | undefined =
186
+ record && record.ok === true && record.result
187
+ ? (record.result as ZaloUpdate)
188
+ : ((record as ZaloUpdate | null) ?? undefined);
189
+
190
+ if (!update?.event_name) {
191
+ res.statusCode = 400;
192
+ res.end("Bad Request");
193
+ recordWebhookStatus(target.runtime, path, res.statusCode);
194
+ return true;
195
+ }
196
+
197
+ if (isReplayEvent(update, nowMs)) {
198
+ res.statusCode = 200;
199
+ res.end("ok");
200
+ return true;
201
+ }
202
+
203
+ target.statusSink?.({ lastInboundAt: Date.now() });
204
+ processUpdate({ update, target }).catch((err) => {
205
+ target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
206
+ });
207
+
208
+ res.statusCode = 200;
209
+ res.end("ok");
210
+ return true;
211
+ },
193
212
  });
194
- if (!body.ok) {
195
- recordWebhookStatus(target.runtime, path, res.statusCode);
196
- return true;
197
- }
198
- const raw = body.value;
199
-
200
- // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }.
201
- const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
202
- const update: ZaloUpdate | undefined =
203
- record && record.ok === true && record.result
204
- ? (record.result as ZaloUpdate)
205
- : ((record as ZaloUpdate | null) ?? undefined);
206
-
207
- if (!update?.event_name) {
208
- res.statusCode = 400;
209
- res.end("Bad Request");
210
- recordWebhookStatus(target.runtime, path, res.statusCode);
211
- return true;
212
- }
213
-
214
- if (isReplayEvent(update, nowMs)) {
215
- res.statusCode = 200;
216
- res.end("ok");
217
- return true;
218
- }
219
-
220
- target.statusSink?.({ lastInboundAt: Date.now() });
221
- processUpdate({ update, target }).catch((err) => {
222
- target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
223
- });
224
-
225
- res.statusCode = 200;
226
- res.end("ok");
227
- return true;
228
213
  }
@@ -1,4 +1,4 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import { zaloOnboardingAdapter } from "./onboarding.js";
4
4
 
package/src/onboarding.ts CHANGED
@@ -4,16 +4,17 @@ import type {
4
4
  OpenClawConfig,
5
5
  SecretInput,
6
6
  WizardPrompter,
7
- } from "openclaw/plugin-sdk";
7
+ } from "openclaw/plugin-sdk/zalo";
8
8
  import {
9
- addWildcardAllowFrom,
9
+ buildSingleChannelSecretPromptState,
10
10
  DEFAULT_ACCOUNT_ID,
11
11
  hasConfiguredSecretInput,
12
12
  mergeAllowFromEntries,
13
13
  normalizeAccountId,
14
- promptAccountId,
15
14
  promptSingleChannelSecretInput,
16
- } from "openclaw/plugin-sdk";
15
+ resolveAccountIdForConfigure,
16
+ setTopLevelChannelDmPolicyWithAllowFrom,
17
+ } from "openclaw/plugin-sdk/zalo";
17
18
  import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
18
19
 
19
20
  const channel = "zalo" as const;
@@ -24,19 +25,11 @@ function setZaloDmPolicy(
24
25
  cfg: OpenClawConfig,
25
26
  dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
26
27
  ) {
27
- const allowFrom =
28
- dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.zalo?.allowFrom) : undefined;
29
- return {
30
- ...cfg,
31
- channels: {
32
- ...cfg.channels,
33
- zalo: {
34
- ...cfg.channels?.zalo,
35
- dmPolicy,
36
- ...(allowFrom ? { allowFrom } : {}),
37
- },
38
- },
39
- } as OpenClawConfig;
28
+ return setTopLevelChannelDmPolicyWithAllowFrom({
29
+ cfg,
30
+ channel: "zalo",
31
+ dmPolicy,
32
+ }) as OpenClawConfig;
40
33
  }
41
34
 
42
35
  function setZaloUpdateMode(
@@ -240,19 +233,16 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
240
233
  shouldPromptAccountIds,
241
234
  forceAllowFrom,
242
235
  }) => {
243
- const zaloOverride = accountOverrides.zalo?.trim();
244
236
  const defaultZaloAccountId = resolveDefaultZaloAccountId(cfg);
245
- let zaloAccountId = zaloOverride ? normalizeAccountId(zaloOverride) : defaultZaloAccountId;
246
- if (shouldPromptAccountIds && !zaloOverride) {
247
- zaloAccountId = await promptAccountId({
248
- cfg: cfg,
249
- prompter,
250
- label: "Zalo",
251
- currentId: zaloAccountId,
252
- listAccountIds: listZaloAccountIds,
253
- defaultAccountId: defaultZaloAccountId,
254
- });
255
- }
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
+ });
256
246
 
257
247
  let next = cfg;
258
248
  const resolvedAccount = resolveZaloAccount({
@@ -262,10 +252,15 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
262
252
  });
263
253
  const accountConfigured = Boolean(resolvedAccount.token);
264
254
  const allowEnv = zaloAccountId === DEFAULT_ACCOUNT_ID;
265
- const canUseEnv = allowEnv && Boolean(process.env.ZALO_BOT_TOKEN?.trim());
266
255
  const hasConfigToken = Boolean(
267
256
  hasConfiguredSecretInput(resolvedAccount.config.botToken) || resolvedAccount.config.tokenFile,
268
257
  );
258
+ const tokenPromptState = buildSingleChannelSecretPromptState({
259
+ accountConfigured,
260
+ hasConfigToken,
261
+ allowEnv,
262
+ envValue: process.env.ZALO_BOT_TOKEN,
263
+ });
269
264
 
270
265
  let token: SecretInput | null = null;
271
266
  if (!accountConfigured) {
@@ -276,9 +271,9 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
276
271
  prompter,
277
272
  providerHint: "zalo",
278
273
  credentialLabel: "bot token",
279
- accountConfigured,
280
- canUseEnv: canUseEnv && !hasConfigToken,
281
- hasConfigToken,
274
+ accountConfigured: tokenPromptState.accountConfigured,
275
+ canUseEnv: tokenPromptState.canUseEnv,
276
+ hasConfigToken: tokenPromptState.hasConfigToken,
282
277
  envPrompt: "ZALO_BOT_TOKEN detected. Use env var?",
283
278
  keepPrompt: "Zalo token already configured. Keep it?",
284
279
  inputPrompt: "Enter Zalo bot token",
@@ -360,9 +355,11 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
360
355
  prompter,
361
356
  providerHint: "zalo-webhook",
362
357
  credentialLabel: "webhook secret",
363
- accountConfigured: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
364
- canUseEnv: false,
365
- hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
358
+ ...buildSingleChannelSecretPromptState({
359
+ accountConfigured: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
360
+ hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
361
+ allowEnv: false,
362
+ }),
366
363
  envPrompt: "",
367
364
  keepPrompt: "Zalo webhook secret already configured. Keep it?",
368
365
  inputPrompt: "Webhook secret (8-256 chars)",
@@ -379,9 +376,11 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
379
376
  prompter,
380
377
  providerHint: "zalo-webhook",
381
378
  credentialLabel: "webhook secret",
382
- accountConfigured: false,
383
- canUseEnv: false,
384
- hasConfigToken: false,
379
+ ...buildSingleChannelSecretPromptState({
380
+ accountConfigured: false,
381
+ hasConfigToken: false,
382
+ allowEnv: false,
383
+ }),
385
384
  envPrompt: "",
386
385
  keepPrompt: "Zalo webhook secret already configured. Keep it?",
387
386
  inputPrompt: "Webhook secret (8-256 chars)",
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
 
@@ -1,19 +1,13 @@
1
1
  import {
2
+ buildSecretInputSchema,
2
3
  hasConfiguredSecretInput,
3
4
  normalizeResolvedSecretInputString,
4
5
  normalizeSecretInputString,
5
- } from "openclaw/plugin-sdk";
6
- import { z } from "zod";
6
+ } from "openclaw/plugin-sdk/zalo";
7
7
 
8
- export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString };
9
-
10
- export function buildSecretInputSchema() {
11
- return z.union([
12
- z.string(),
13
- z.object({
14
- source: z.enum(["env", "file", "exec"]),
15
- provider: z.string().min(1),
16
- id: z.string().min(1),
17
- }),
18
- ]);
19
- }
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;
package/src/token.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
- import type { BaseTokenResolution } from "openclaw/plugin-sdk";
3
2
  import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
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";
6
6
 
@@ -8,6 +8,19 @@ export type ZaloTokenResolution = BaseTokenResolution & {
8
8
  source: "env" | "config" | "configFile" | "none";
9
9
  };
10
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
+
11
24
  export function resolveZaloToken(
12
25
  config: ZaloConfig | undefined,
13
26
  accountId?: string | null,
@@ -44,28 +57,16 @@ export function resolveZaloToken(
44
57
  if (token) {
45
58
  return { token, source: "config" };
46
59
  }
47
- const tokenFile = accountConfig.tokenFile?.trim();
48
- if (tokenFile) {
49
- try {
50
- const fileToken = readFileSync(tokenFile, "utf8").trim();
51
- if (fileToken) {
52
- return { token: fileToken, source: "configFile" };
53
- }
54
- } catch {
55
- // ignore read failures
56
- }
60
+ const fileToken = readTokenFromFile(accountConfig.tokenFile);
61
+ if (fileToken) {
62
+ return { token: fileToken, source: "configFile" };
57
63
  }
58
64
  }
59
65
 
60
- const accountTokenFile = accountConfig?.tokenFile?.trim();
61
- if (!accountHasBotToken && accountTokenFile) {
62
- try {
63
- const fileToken = readFileSync(accountTokenFile, "utf8").trim();
64
- if (fileToken) {
65
- return { token: fileToken, source: "configFile" };
66
- }
67
- } catch {
68
- // ignore read failures
66
+ if (!accountHasBotToken) {
67
+ const fileToken = readTokenFromFile(accountConfig?.tokenFile);
68
+ if (fileToken) {
69
+ return { token: fileToken, source: "configFile" };
69
70
  }
70
71
  }
71
72
 
@@ -79,16 +80,9 @@ export function resolveZaloToken(
79
80
  if (token) {
80
81
  return { token, source: "config" };
81
82
  }
82
- const tokenFile = baseConfig?.tokenFile?.trim();
83
- if (tokenFile) {
84
- try {
85
- const fileToken = readFileSync(tokenFile, "utf8").trim();
86
- if (fileToken) {
87
- return { token: fileToken, source: "configFile" };
88
- }
89
- } catch {
90
- // ignore read failures
91
- }
83
+ const fileToken = readTokenFromFile(baseConfig?.tokenFile);
84
+ if (fileToken) {
85
+ return { token: fileToken, source: "configFile" };
92
86
  }
93
87
  }
94
88
 
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { SecretInput } from "openclaw/plugin-sdk";
1
+ import type { SecretInput } from "openclaw/plugin-sdk/zalo";
2
2
 
3
3
  export type ZaloAccountConfig = {
4
4
  /** Optional display name for this account (used in CLI/UI lists). */