@openclaw/zalo 2026.3.1 → 2026.3.2

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.3.2
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
3
9
  ## 2026.3.1
4
10
 
5
11
  ### Changes
package/index.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
2
  import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/zalo",
3
- "version": "2026.3.1",
3
+ "version": "2026.3.2",
4
4
  "description": "OpenClaw Zalo channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
package/src/accounts.ts CHANGED
@@ -62,6 +62,7 @@ function mergeZaloAccountConfig(cfg: OpenClawConfig, accountId: string): ZaloAcc
62
62
  export function resolveZaloAccount(params: {
63
63
  cfg: OpenClawConfig;
64
64
  accountId?: string | null;
65
+ allowUnresolvedSecretRef?: boolean;
65
66
  }): ResolvedZaloAccount {
66
67
  const accountId = normalizeAccountId(params.accountId);
67
68
  const baseEnabled = (params.cfg.channels?.zalo as ZaloConfig | undefined)?.enabled !== false;
@@ -71,6 +72,7 @@ export function resolveZaloAccount(params: {
71
72
  const tokenResolution = resolveZaloToken(
72
73
  params.cfg.channels?.zalo as ZaloConfig | undefined,
73
74
  accountId,
75
+ { allowUnresolvedSecretRef: params.allowUnresolvedSecretRef },
74
76
  );
75
77
 
76
78
  return {
@@ -0,0 +1,102 @@
1
+ import type { ReplyPayload } from "openclaw/plugin-sdk";
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
@@ -32,6 +32,7 @@ import { ZaloConfigSchema } from "./config-schema.js";
32
32
  import { zaloOnboardingAdapter } from "./onboarding.js";
33
33
  import { probeZalo } from "./probe.js";
34
34
  import { resolveZaloProxyFetch } from "./proxy.js";
35
+ import { normalizeSecretInputString } from "./secret-input.js";
35
36
  import { sendMessageZalo } from "./send.js";
36
37
  import { collectZaloStatusIssues } from "./status-issues.js";
37
38
 
@@ -302,6 +303,40 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
302
303
  chunker: chunkTextForOutbound,
303
304
  chunkerMode: "text",
304
305
  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
+ },
305
340
  sendText: async ({ to, text, accountId, cfg }) => {
306
341
  const result = await sendMessageZalo(to, text, {
307
342
  accountId: accountId ?? undefined,
@@ -388,7 +423,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
388
423
  abortSignal: ctx.abortSignal,
389
424
  useWebhook: Boolean(account.config.webhookUrl),
390
425
  webhookUrl: account.config.webhookUrl,
391
- webhookSecret: account.config.webhookSecret,
426
+ webhookSecret: normalizeSecretInputString(account.config.webhookSecret),
392
427
  webhookPath: account.config.webhookPath,
393
428
  fetcher,
394
429
  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
1
  import { MarkdownConfigSchema } from "openclaw/plugin-sdk";
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(),
package/src/monitor.ts CHANGED
@@ -3,9 +3,11 @@ import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "op
3
3
  import {
4
4
  createScopedPairingAccess,
5
5
  createReplyPrefixOptions,
6
- resolveSenderCommandAuthorization,
6
+ resolveDirectDmAuthorizationOutcome,
7
+ resolveSenderCommandAuthorizationWithRuntime,
7
8
  resolveOutboundMediaUrls,
8
9
  resolveDefaultGroupPolicy,
10
+ resolveInboundRouteEnvelopeBuilderWithRuntime,
9
11
  sendMediaWithLeadingCaption,
10
12
  resolveWebhookPath,
11
13
  warnMissingProviderGroupPolicyFallbackOnce,
@@ -73,7 +75,24 @@ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: str
73
75
  }
74
76
 
75
77
  export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
76
- return registerZaloWebhookTargetInternal(target);
78
+ return registerZaloWebhookTargetInternal(target, {
79
+ route: {
80
+ auth: "plugin",
81
+ match: "exact",
82
+ pluginId: "zalo",
83
+ source: "zalo-webhook",
84
+ accountId: target.account.accountId,
85
+ log: target.runtime.log,
86
+ handler: async (req, res) => {
87
+ const handled = await handleZaloWebhookRequest(req, res);
88
+ if (!handled && !res.headersSent) {
89
+ res.statusCode = 404;
90
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
91
+ res.end("Not Found");
92
+ }
93
+ },
94
+ },
95
+ });
77
96
  }
78
97
 
79
98
  export {
@@ -366,82 +385,76 @@ async function processMessageWithPipeline(params: {
366
385
  }
367
386
 
368
387
  const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
369
- const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
370
- cfg: config,
371
- rawBody,
388
+ const { senderAllowedForCommands, commandAuthorized } =
389
+ await resolveSenderCommandAuthorizationWithRuntime({
390
+ cfg: config,
391
+ rawBody,
392
+ isGroup,
393
+ dmPolicy,
394
+ configuredAllowFrom: configAllowFrom,
395
+ configuredGroupAllowFrom: groupAllowFrom,
396
+ senderId,
397
+ isSenderAllowed: isZaloSenderAllowed,
398
+ readAllowFromStore: pairing.readAllowFromStore,
399
+ runtime: core.channel.commands,
400
+ });
401
+
402
+ const directDmOutcome = resolveDirectDmAuthorizationOutcome({
372
403
  isGroup,
373
404
  dmPolicy,
374
- configuredAllowFrom: configAllowFrom,
375
- configuredGroupAllowFrom: groupAllowFrom,
376
- senderId,
377
- isSenderAllowed: isZaloSenderAllowed,
378
- readAllowFromStore: pairing.readAllowFromStore,
379
- shouldComputeCommandAuthorized: (body, cfg) =>
380
- core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
381
- resolveCommandAuthorizedFromAuthorizers: (params) =>
382
- core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params),
405
+ senderAllowedForCommands,
383
406
  });
384
-
385
- if (!isGroup) {
386
- if (dmPolicy === "disabled") {
387
- logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
388
- return;
389
- }
390
-
391
- if (dmPolicy !== "open") {
392
- const allowed = senderAllowedForCommands;
393
-
394
- if (!allowed) {
395
- if (dmPolicy === "pairing") {
396
- const { code, created } = await pairing.upsertPairingRequest({
397
- id: senderId,
398
- meta: { name: senderName ?? undefined },
399
- });
400
-
401
- if (created) {
402
- logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
403
- try {
404
- await sendMessage(
405
- token,
406
- {
407
- chat_id: chatId,
408
- text: core.channel.pairing.buildPairingReply({
409
- channel: "zalo",
410
- idLine: `Your Zalo user id: ${senderId}`,
411
- code,
412
- }),
413
- },
414
- fetcher,
415
- );
416
- statusSink?.({ lastOutboundAt: Date.now() });
417
- } catch (err) {
418
- logVerbose(
419
- core,
420
- runtime,
421
- `zalo pairing reply failed for ${senderId}: ${String(err)}`,
422
- );
423
- }
424
- }
425
- } else {
426
- logVerbose(
427
- core,
428
- runtime,
429
- `Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
407
+ if (directDmOutcome === "disabled") {
408
+ logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
409
+ return;
410
+ }
411
+ if (directDmOutcome === "unauthorized") {
412
+ if (dmPolicy === "pairing") {
413
+ const { code, created } = await pairing.upsertPairingRequest({
414
+ id: senderId,
415
+ meta: { name: senderName ?? undefined },
416
+ });
417
+
418
+ if (created) {
419
+ logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
420
+ try {
421
+ await sendMessage(
422
+ token,
423
+ {
424
+ chat_id: chatId,
425
+ text: core.channel.pairing.buildPairingReply({
426
+ channel: "zalo",
427
+ idLine: `Your Zalo user id: ${senderId}`,
428
+ code,
429
+ }),
430
+ },
431
+ fetcher,
430
432
  );
433
+ statusSink?.({ lastOutboundAt: Date.now() });
434
+ } catch (err) {
435
+ logVerbose(core, runtime, `zalo pairing reply failed for ${senderId}: ${String(err)}`);
431
436
  }
432
- return;
433
437
  }
438
+ } else {
439
+ logVerbose(
440
+ core,
441
+ runtime,
442
+ `Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
443
+ );
434
444
  }
445
+ return;
435
446
  }
436
447
 
437
- const route = core.channel.routing.resolveAgentRoute({
448
+ const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
438
449
  cfg: config,
439
450
  channel: "zalo",
440
451
  accountId: account.accountId,
441
452
  peer: {
442
- kind: isGroup ? "group" : "direct",
453
+ kind: isGroup ? ("group" as const) : ("direct" as const),
443
454
  id: chatId,
444
455
  },
456
+ runtime: core.channel,
457
+ sessionStore: config.session?.store,
445
458
  });
446
459
 
447
460
  if (
@@ -454,20 +467,10 @@ async function processMessageWithPipeline(params: {
454
467
  }
455
468
 
456
469
  const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
457
- const storePath = core.channel.session.resolveStorePath(config.session?.store, {
458
- agentId: route.agentId,
459
- });
460
- const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
461
- const previousTimestamp = core.channel.session.readSessionUpdatedAt({
462
- storePath,
463
- sessionKey: route.sessionKey,
464
- });
465
- const body = core.channel.reply.formatAgentEnvelope({
470
+ const { storePath, body } = buildEnvelope({
466
471
  channel: "Zalo",
467
472
  from: fromLabel,
468
473
  timestamp: date ? date * 1000 : undefined,
469
- previousTimestamp,
470
- envelope: envelopeOptions,
471
474
  body: rawBody,
472
475
  });
473
476
 
@@ -2,6 +2,8 @@ import { createServer, type RequestListener } from "node:http";
2
2
  import type { AddressInfo } from "node:net";
3
3
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
6
+ import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
5
7
  import {
6
8
  clearZaloWebhookSecurityStateForTest,
7
9
  getZaloWebhookRateLimitStateSizeForTest,
@@ -47,13 +49,16 @@ function registerTarget(params: {
47
49
  path: string;
48
50
  secret?: string;
49
51
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
52
+ account?: ResolvedZaloAccount;
53
+ config?: OpenClawConfig;
54
+ core?: PluginRuntime;
50
55
  }): () => void {
51
56
  return registerZaloWebhookTarget({
52
57
  token: "tok",
53
- account: DEFAULT_ACCOUNT,
54
- config: {} as OpenClawConfig,
58
+ account: params.account ?? DEFAULT_ACCOUNT,
59
+ config: params.config ?? ({} as OpenClawConfig),
55
60
  runtime: {},
56
- core: {} as PluginRuntime,
61
+ core: params.core ?? ({} as PluginRuntime),
57
62
  secret: params.secret ?? "secret",
58
63
  path: params.path,
59
64
  mediaMaxMb: 5,
@@ -61,9 +66,59 @@ function registerTarget(params: {
61
66
  });
62
67
  }
63
68
 
69
+ function createPairingAuthCore(params?: { storeAllowFrom?: string[]; pairingCreated?: boolean }): {
70
+ core: PluginRuntime;
71
+ readAllowFromStore: ReturnType<typeof vi.fn>;
72
+ upsertPairingRequest: ReturnType<typeof vi.fn>;
73
+ } {
74
+ const readAllowFromStore = vi.fn().mockResolvedValue(params?.storeAllowFrom ?? []);
75
+ const upsertPairingRequest = vi
76
+ .fn()
77
+ .mockResolvedValue({ code: "PAIRCODE", created: params?.pairingCreated ?? false });
78
+ const core = {
79
+ logging: {
80
+ shouldLogVerbose: () => false,
81
+ },
82
+ channel: {
83
+ pairing: {
84
+ readAllowFromStore,
85
+ upsertPairingRequest,
86
+ buildPairingReply: vi.fn(() => "Pairing code: PAIRCODE"),
87
+ },
88
+ commands: {
89
+ shouldComputeCommandAuthorized: vi.fn(() => false),
90
+ resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
91
+ },
92
+ },
93
+ } as unknown as PluginRuntime;
94
+ return { core, readAllowFromStore, upsertPairingRequest };
95
+ }
96
+
64
97
  describe("handleZaloWebhookRequest", () => {
65
98
  afterEach(() => {
66
99
  clearZaloWebhookSecurityStateForTest();
100
+ setActivePluginRegistry(createEmptyPluginRegistry());
101
+ });
102
+
103
+ it("registers and unregisters plugin HTTP route at path boundaries", () => {
104
+ const registry = createEmptyPluginRegistry();
105
+ setActivePluginRegistry(registry);
106
+ const unregisterA = registerTarget({ path: "/hook" });
107
+ const unregisterB = registerTarget({ path: "/hook" });
108
+
109
+ expect(registry.httpRoutes).toHaveLength(1);
110
+ expect(registry.httpRoutes[0]).toEqual(
111
+ expect.objectContaining({
112
+ pluginId: "zalo",
113
+ path: "/hook",
114
+ source: "zalo-webhook",
115
+ }),
116
+ );
117
+
118
+ unregisterA();
119
+ expect(registry.httpRoutes).toHaveLength(1);
120
+ unregisterB();
121
+ expect(registry.httpRoutes).toHaveLength(0);
67
122
  });
68
123
 
69
124
  it("returns 400 for non-object payloads", async () => {
@@ -206,7 +261,6 @@ describe("handleZaloWebhookRequest", () => {
206
261
  unregister();
207
262
  }
208
263
  });
209
-
210
264
  it("does not grow status counters when query strings churn on unauthorized requests", async () => {
211
265
  const unregister = registerTarget({ path: "/hook-query-status" });
212
266
 
@@ -259,4 +313,65 @@ describe("handleZaloWebhookRequest", () => {
259
313
  unregister();
260
314
  }
261
315
  });
316
+
317
+ it("scopes DM pairing store reads and writes to accountId", async () => {
318
+ const { core, readAllowFromStore, upsertPairingRequest } = createPairingAuthCore({
319
+ pairingCreated: false,
320
+ });
321
+ const account: ResolvedZaloAccount = {
322
+ ...DEFAULT_ACCOUNT,
323
+ accountId: "work",
324
+ config: {
325
+ dmPolicy: "pairing",
326
+ allowFrom: [],
327
+ },
328
+ };
329
+ const unregister = registerTarget({
330
+ path: "/hook-account-scope",
331
+ account,
332
+ core,
333
+ });
334
+
335
+ const payload = {
336
+ event_name: "message.text.received",
337
+ message: {
338
+ from: { id: "123", name: "Attacker" },
339
+ chat: { id: "dm-work", chat_type: "PRIVATE" },
340
+ message_id: "msg-work-1",
341
+ date: Math.floor(Date.now() / 1000),
342
+ text: "hello",
343
+ },
344
+ };
345
+
346
+ try {
347
+ await withServer(webhookRequestHandler, async (baseUrl) => {
348
+ const response = await fetch(`${baseUrl}/hook-account-scope`, {
349
+ method: "POST",
350
+ headers: {
351
+ "x-bot-api-secret-token": "secret",
352
+ "content-type": "application/json",
353
+ },
354
+ body: JSON.stringify(payload),
355
+ });
356
+
357
+ expect(response.status).toBe(200);
358
+ });
359
+ } finally {
360
+ unregister();
361
+ }
362
+
363
+ expect(readAllowFromStore).toHaveBeenCalledWith(
364
+ expect.objectContaining({
365
+ channel: "zalo",
366
+ accountId: "work",
367
+ }),
368
+ );
369
+ expect(upsertPairingRequest).toHaveBeenCalledWith(
370
+ expect.objectContaining({
371
+ channel: "zalo",
372
+ id: "123",
373
+ accountId: "work",
374
+ }),
375
+ );
376
+ });
262
377
  });
@@ -7,6 +7,9 @@ import {
7
7
  createWebhookAnomalyTracker,
8
8
  readJsonWebhookBodyOrReject,
9
9
  applyBasicWebhookRequestGuards,
10
+ registerWebhookTargetWithPluginRoute,
11
+ type RegisterWebhookTargetOptions,
12
+ type RegisterWebhookPluginRouteOptions,
10
13
  registerWebhookTarget,
11
14
  resolveSingleWebhookTarget,
12
15
  resolveWebhookTargets,
@@ -106,8 +109,24 @@ function recordWebhookStatus(
106
109
  });
107
110
  }
108
111
 
109
- export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
110
- return registerWebhookTarget(webhookTargets, target).unregister;
112
+ export function registerZaloWebhookTarget(
113
+ target: ZaloWebhookTarget,
114
+ opts?: {
115
+ route?: RegisterWebhookPluginRouteOptions;
116
+ } & Pick<
117
+ RegisterWebhookTargetOptions<ZaloWebhookTarget>,
118
+ "onFirstPathTarget" | "onLastPathTargetRemoved"
119
+ >,
120
+ ): () => void {
121
+ if (opts?.route) {
122
+ return registerWebhookTargetWithPluginRoute({
123
+ targetsByPath: webhookTargets,
124
+ target,
125
+ route: opts.route,
126
+ onLastPathTargetRemoved: opts.onLastPathTargetRemoved,
127
+ }).unregister;
128
+ }
129
+ return registerWebhookTarget(webhookTargets, target, opts).unregister;
111
130
  }
112
131
 
113
132
  export async function handleZaloWebhookRequest(
@@ -0,0 +1,24 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
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,14 +2,17 @@ import type {
2
2
  ChannelOnboardingAdapter,
3
3
  ChannelOnboardingDmPolicy,
4
4
  OpenClawConfig,
5
+ SecretInput,
5
6
  WizardPrompter,
6
7
  } from "openclaw/plugin-sdk";
7
8
  import {
8
9
  addWildcardAllowFrom,
9
10
  DEFAULT_ACCOUNT_ID,
11
+ hasConfiguredSecretInput,
10
12
  mergeAllowFromEntries,
11
13
  normalizeAccountId,
12
14
  promptAccountId,
15
+ promptSingleChannelSecretInput,
13
16
  } from "openclaw/plugin-sdk";
14
17
  import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
15
18
 
@@ -41,7 +44,7 @@ function setZaloUpdateMode(
41
44
  accountId: string,
42
45
  mode: UpdateMode,
43
46
  webhookUrl?: string,
44
- webhookSecret?: string,
47
+ webhookSecret?: SecretInput,
45
48
  webhookPath?: string,
46
49
  ): OpenClawConfig {
47
50
  const isDefault = accountId === DEFAULT_ACCOUNT_ID;
@@ -210,9 +213,18 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
210
213
  channel,
211
214
  dmPolicy,
212
215
  getStatus: async ({ cfg }) => {
213
- const configured = listZaloAccountIds(cfg).some((accountId) =>
214
- Boolean(resolveZaloAccount({ cfg: cfg, accountId }).token),
215
- );
216
+ const configured = listZaloAccountIds(cfg).some((accountId) => {
217
+ const account = resolveZaloAccount({
218
+ cfg: cfg,
219
+ accountId,
220
+ allowUnresolvedSecretRef: true,
221
+ });
222
+ return (
223
+ Boolean(account.token) ||
224
+ hasConfiguredSecretInput(account.config.botToken) ||
225
+ Boolean(account.config.tokenFile?.trim())
226
+ );
227
+ });
216
228
  return {
217
229
  channel,
218
230
  configured,
@@ -243,62 +255,49 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
243
255
  }
244
256
 
245
257
  let next = cfg;
246
- const resolvedAccount = resolveZaloAccount({ cfg: next, accountId: zaloAccountId });
258
+ const resolvedAccount = resolveZaloAccount({
259
+ cfg: next,
260
+ accountId: zaloAccountId,
261
+ allowUnresolvedSecretRef: true,
262
+ });
247
263
  const accountConfigured = Boolean(resolvedAccount.token);
248
264
  const allowEnv = zaloAccountId === DEFAULT_ACCOUNT_ID;
249
265
  const canUseEnv = allowEnv && Boolean(process.env.ZALO_BOT_TOKEN?.trim());
250
266
  const hasConfigToken = Boolean(
251
- resolvedAccount.config.botToken || resolvedAccount.config.tokenFile,
267
+ hasConfiguredSecretInput(resolvedAccount.config.botToken) || resolvedAccount.config.tokenFile,
252
268
  );
253
269
 
254
- let token: string | null = null;
270
+ let token: SecretInput | null = null;
255
271
  if (!accountConfigured) {
256
272
  await noteZaloTokenHelp(prompter);
257
273
  }
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
- },
274
+ const tokenResult = await promptSingleChannelSecretInput({
275
+ cfg: next,
276
+ prompter,
277
+ providerHint: "zalo",
278
+ credentialLabel: "bot token",
279
+ accountConfigured,
280
+ canUseEnv: canUseEnv && !hasConfigToken,
281
+ hasConfigToken,
282
+ envPrompt: "ZALO_BOT_TOKEN detected. Use env var?",
283
+ keepPrompt: "Zalo token already configured. Keep it?",
284
+ inputPrompt: "Enter Zalo bot token",
285
+ preferredEnvVar: "ZALO_BOT_TOKEN",
286
+ });
287
+ if (tokenResult.action === "set") {
288
+ token = tokenResult.value;
289
+ }
290
+ if (tokenResult.action === "use-env" && zaloAccountId === DEFAULT_ACCOUNT_ID) {
291
+ next = {
292
+ ...next,
293
+ channels: {
294
+ ...next.channels,
295
+ zalo: {
296
+ ...next.channels?.zalo,
297
+ enabled: true,
272
298
  },
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();
299
+ },
300
+ } as OpenClawConfig;
302
301
  }
303
302
 
304
303
  if (token) {
@@ -338,12 +337,13 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
338
337
 
339
338
  const wantsWebhook = await prompter.confirm({
340
339
  message: "Use webhook mode for Zalo?",
341
- initialValue: false,
340
+ initialValue: Boolean(resolvedAccount.config.webhookUrl),
342
341
  });
343
342
  if (wantsWebhook) {
344
343
  const webhookUrl = String(
345
344
  await prompter.text({
346
345
  message: "Webhook URL (https://...) ",
346
+ initialValue: resolvedAccount.config.webhookUrl,
347
347
  validate: (value) =>
348
348
  value?.trim()?.startsWith("https://") ? undefined : "HTTPS URL required",
349
349
  }),
@@ -355,22 +355,47 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
355
355
  return "/zalo-webhook";
356
356
  }
357
357
  })();
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
- },
368
- }),
369
- ).trim();
358
+ let webhookSecretResult = await promptSingleChannelSecretInput({
359
+ cfg: next,
360
+ prompter,
361
+ providerHint: "zalo-webhook",
362
+ credentialLabel: "webhook secret",
363
+ accountConfigured: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
364
+ canUseEnv: false,
365
+ hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
366
+ envPrompt: "",
367
+ keepPrompt: "Zalo webhook secret already configured. Keep it?",
368
+ inputPrompt: "Webhook secret (8-256 chars)",
369
+ preferredEnvVar: "ZALO_WEBHOOK_SECRET",
370
+ });
371
+ while (
372
+ webhookSecretResult.action === "set" &&
373
+ typeof webhookSecretResult.value === "string" &&
374
+ (webhookSecretResult.value.length < 8 || webhookSecretResult.value.length > 256)
375
+ ) {
376
+ await prompter.note("Webhook secret must be between 8 and 256 characters.", "Zalo webhook");
377
+ webhookSecretResult = await promptSingleChannelSecretInput({
378
+ cfg: next,
379
+ prompter,
380
+ providerHint: "zalo-webhook",
381
+ credentialLabel: "webhook secret",
382
+ accountConfigured: false,
383
+ canUseEnv: false,
384
+ hasConfigToken: false,
385
+ envPrompt: "",
386
+ keepPrompt: "Zalo webhook secret already configured. Keep it?",
387
+ inputPrompt: "Webhook secret (8-256 chars)",
388
+ preferredEnvVar: "ZALO_WEBHOOK_SECRET",
389
+ });
390
+ }
391
+ const webhookSecret =
392
+ webhookSecretResult.action === "set"
393
+ ? webhookSecretResult.value
394
+ : resolvedAccount.config.webhookSecret;
370
395
  const webhookPath = String(
371
396
  await prompter.text({
372
397
  message: "Webhook path (optional)",
373
- initialValue: defaultPath,
398
+ initialValue: resolvedAccount.config.webhookPath ?? defaultPath,
374
399
  }),
375
400
  ).trim();
376
401
  next = setZaloUpdateMode(
@@ -0,0 +1,19 @@
1
+ import {
2
+ hasConfiguredSecretInput,
3
+ normalizeResolvedSecretInputString,
4
+ normalizeSecretInputString,
5
+ } from "openclaw/plugin-sdk";
6
+ import { z } from "zod";
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
+ }
@@ -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,5 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { type BaseTokenResolution, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
2
+ import type { BaseTokenResolution } from "openclaw/plugin-sdk";
3
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
4
+ import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js";
3
5
  import type { ZaloConfig } from "./types.js";
4
6
 
5
7
  export type ZaloTokenResolution = BaseTokenResolution & {
@@ -9,17 +11,36 @@ export type ZaloTokenResolution = BaseTokenResolution & {
9
11
  export function resolveZaloToken(
10
12
  config: ZaloConfig | undefined,
11
13
  accountId?: string | null,
14
+ options?: { allowUnresolvedSecretRef?: boolean },
12
15
  ): ZaloTokenResolution {
13
16
  const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
14
17
  const isDefaultAccount = resolvedAccountId === DEFAULT_ACCOUNT_ID;
15
18
  const baseConfig = config;
16
- const accountConfig =
17
- resolvedAccountId !== DEFAULT_ACCOUNT_ID
18
- ? (baseConfig?.accounts?.[resolvedAccountId] as ZaloConfig | undefined)
19
- : undefined;
19
+ const resolveAccountConfig = (id: string): ZaloConfig | undefined => {
20
+ const accounts = baseConfig?.accounts;
21
+ if (!accounts || typeof accounts !== "object") {
22
+ return undefined;
23
+ }
24
+ const direct = accounts[id] as ZaloConfig | undefined;
25
+ if (direct) {
26
+ return direct;
27
+ }
28
+ const normalized = normalizeAccountId(id);
29
+ const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized);
30
+ return matchKey ? ((accounts as Record<string, ZaloConfig>)[matchKey] ?? undefined) : undefined;
31
+ };
32
+ const accountConfig = resolveAccountConfig(resolvedAccountId);
33
+ const accountHasBotToken = Boolean(
34
+ accountConfig && Object.prototype.hasOwnProperty.call(accountConfig, "botToken"),
35
+ );
20
36
 
21
- if (accountConfig) {
22
- const token = accountConfig.botToken?.trim();
37
+ if (accountConfig && accountHasBotToken) {
38
+ const token = options?.allowUnresolvedSecretRef
39
+ ? normalizeSecretInputString(accountConfig.botToken)
40
+ : normalizeResolvedSecretInputString({
41
+ value: accountConfig.botToken,
42
+ path: `channels.zalo.accounts.${resolvedAccountId}.botToken`,
43
+ });
23
44
  if (token) {
24
45
  return { token, source: "config" };
25
46
  }
@@ -36,8 +57,25 @@ export function resolveZaloToken(
36
57
  }
37
58
  }
38
59
 
39
- if (isDefaultAccount) {
40
- const token = baseConfig?.botToken?.trim();
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
69
+ }
70
+ }
71
+
72
+ if (!accountHasBotToken) {
73
+ const token = options?.allowUnresolvedSecretRef
74
+ ? normalizeSecretInputString(baseConfig?.botToken)
75
+ : normalizeResolvedSecretInputString({
76
+ value: baseConfig?.botToken,
77
+ path: "channels.zalo.botToken",
78
+ });
41
79
  if (token) {
42
80
  return { token, source: "config" };
43
81
  }
@@ -52,6 +90,9 @@ export function resolveZaloToken(
52
90
  // ignore read failures
53
91
  }
54
92
  }
93
+ }
94
+
95
+ if (isDefaultAccount) {
55
96
  const envToken = process.env.ZALO_BOT_TOKEN?.trim();
56
97
  if (envToken) {
57
98
  return { token: envToken, source: "env" };
package/src/types.ts CHANGED
@@ -1,16 +1,18 @@
1
+ import type { SecretInput } from "openclaw/plugin-sdk";
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). */