@openclaw/zalo 2026.2.13 → 2026.2.15

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.2.15
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.2.14
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
3
15
  ## 2026.2.13
4
16
 
5
17
  ### Changes
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@openclaw/zalo",
3
- "version": "2026.2.13",
3
+ "version": "2026.2.15",
4
4
  "description": "OpenClaw Zalo channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
7
- "undici": "7.21.0"
7
+ "undici": "7.22.0"
8
8
  },
9
9
  "devDependencies": {
10
10
  "openclaw": "workspace:*"
package/src/accounts.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
- import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
2
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
3
3
  import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
4
4
  import { resolveZaloToken } from "./token.js";
5
5
 
package/src/channel.ts CHANGED
@@ -9,10 +9,13 @@ import {
9
9
  buildChannelConfigSchema,
10
10
  DEFAULT_ACCOUNT_ID,
11
11
  deleteAccountFromConfigSection,
12
+ chunkTextForOutbound,
13
+ formatAllowFromLowercase,
12
14
  formatPairingApproveHint,
13
15
  migrateBaseNameToDefaultAccount,
14
16
  normalizeAccountId,
15
17
  PAIRING_APPROVED_MESSAGE,
18
+ resolveChannelAccountConfigBasePath,
16
19
  setAccountEnabledInConfigSection,
17
20
  } from "openclaw/plugin-sdk";
18
21
  import {
@@ -63,11 +66,7 @@ export const zaloDock: ChannelDock = {
63
66
  String(entry),
64
67
  ),
65
68
  formatAllowFrom: ({ allowFrom }) =>
66
- allowFrom
67
- .map((entry) => String(entry).trim())
68
- .filter(Boolean)
69
- .map((entry) => entry.replace(/^(zalo|zl):/i, ""))
70
- .map((entry) => entry.toLowerCase()),
69
+ formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
71
70
  },
72
71
  groups: {
73
72
  resolveRequireMention: () => true,
@@ -124,19 +123,16 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
124
123
  String(entry),
125
124
  ),
126
125
  formatAllowFrom: ({ allowFrom }) =>
127
- allowFrom
128
- .map((entry) => String(entry).trim())
129
- .filter(Boolean)
130
- .map((entry) => entry.replace(/^(zalo|zl):/i, ""))
131
- .map((entry) => entry.toLowerCase()),
126
+ formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
132
127
  },
133
128
  security: {
134
129
  resolveDmPolicy: ({ cfg, accountId, account }) => {
135
130
  const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
136
- const useAccountPath = Boolean(cfg.channels?.zalo?.accounts?.[resolvedAccountId]);
137
- const basePath = useAccountPath
138
- ? `channels.zalo.accounts.${resolvedAccountId}.`
139
- : "channels.zalo.";
131
+ const basePath = resolveChannelAccountConfigBasePath({
132
+ cfg,
133
+ channelKey: "zalo",
134
+ accountId: resolvedAccountId,
135
+ });
140
136
  return {
141
137
  policy: account.config.dmPolicy ?? "pairing",
142
138
  allowFrom: account.config.allowFrom ?? [],
@@ -275,37 +271,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
275
271
  },
276
272
  outbound: {
277
273
  deliveryMode: "direct",
278
- chunker: (text, limit) => {
279
- if (!text) {
280
- return [];
281
- }
282
- if (limit <= 0 || text.length <= limit) {
283
- return [text];
284
- }
285
- const chunks: string[] = [];
286
- let remaining = text;
287
- while (remaining.length > limit) {
288
- const window = remaining.slice(0, limit);
289
- const lastNewline = window.lastIndexOf("\n");
290
- const lastSpace = window.lastIndexOf(" ");
291
- let breakIdx = lastNewline > 0 ? lastNewline : lastSpace;
292
- if (breakIdx <= 0) {
293
- breakIdx = limit;
294
- }
295
- const rawChunk = remaining.slice(0, breakIdx);
296
- const chunk = rawChunk.trimEnd();
297
- if (chunk.length > 0) {
298
- chunks.push(chunk);
299
- }
300
- const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
301
- const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
302
- remaining = remaining.slice(nextStart).trimStart();
303
- }
304
- if (remaining.length) {
305
- chunks.push(remaining);
306
- }
307
- return chunks;
308
- },
274
+ chunker: chunkTextForOutbound,
309
275
  chunkerMode: "text",
310
276
  textChunkLimit: 2000,
311
277
  sendText: async ({ to, text, accountId, cfg }) => {
package/src/monitor.ts CHANGED
@@ -2,7 +2,9 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk";
3
3
  import {
4
4
  createReplyPrefixOptions,
5
+ normalizeWebhookPath,
5
6
  readJsonBodyWithLimit,
7
+ resolveWebhookPath,
6
8
  requestBodyErrorToText,
7
9
  } from "openclaw/plugin-sdk";
8
10
  import type { ResolvedZaloAccount } from "./accounts.js";
@@ -80,34 +82,6 @@ type WebhookTarget = {
80
82
 
81
83
  const webhookTargets = new Map<string, WebhookTarget[]>();
82
84
 
83
- function normalizeWebhookPath(raw: string): string {
84
- const trimmed = raw.trim();
85
- if (!trimmed) {
86
- return "/";
87
- }
88
- const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
89
- if (withSlash.length > 1 && withSlash.endsWith("/")) {
90
- return withSlash.slice(0, -1);
91
- }
92
- return withSlash;
93
- }
94
-
95
- function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null {
96
- const trimmedPath = webhookPath?.trim();
97
- if (trimmedPath) {
98
- return normalizeWebhookPath(trimmedPath);
99
- }
100
- if (webhookUrl?.trim()) {
101
- try {
102
- const parsed = new URL(webhookUrl);
103
- return normalizeWebhookPath(parsed.pathname || "/");
104
- } catch {
105
- return null;
106
- }
107
- }
108
- return null;
109
- }
110
-
111
85
  export function registerZaloWebhookTarget(target: WebhookTarget): () => void {
112
86
  const key = normalizeWebhookPath(target.path);
113
87
  const normalizedTarget = { ...target, path: key };
@@ -143,12 +117,18 @@ export async function handleZaloWebhookRequest(
143
117
  }
144
118
 
145
119
  const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
146
- const target = targets.find((entry) => entry.secret === headerToken);
147
- if (!target) {
120
+ const matching = targets.filter((entry) => entry.secret === headerToken);
121
+ if (matching.length === 0) {
148
122
  res.statusCode = 401;
149
123
  res.end("unauthorized");
150
124
  return true;
151
125
  }
126
+ if (matching.length > 1) {
127
+ res.statusCode = 401;
128
+ res.end("ambiguous webhook target");
129
+ return true;
130
+ }
131
+ const target = matching[0];
152
132
 
153
133
  const body = await readJsonBodyWithLimit(req, {
154
134
  maxBytes: 1024 * 1024,
@@ -694,7 +674,7 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
694
674
  throw new Error("Zalo webhook secret must be 8-256 characters");
695
675
  }
696
676
 
697
- const path = resolveWebhookPath(webhookPath, webhookUrl);
677
+ const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null });
698
678
  if (!path) {
699
679
  throw new Error("Zalo webhookPath could not be derived");
700
680
  }
@@ -1,7 +1,7 @@
1
1
  import type { AddressInfo } from "node:net";
2
2
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
3
3
  import { createServer } from "node:http";
4
- import { describe, expect, it } from "vitest";
4
+ import { describe, expect, it, vi } from "vitest";
5
5
  import type { ResolvedZaloAccount } from "./types.js";
6
6
  import { handleZaloWebhookRequest, registerZaloWebhookTarget } from "./monitor.js";
7
7
 
@@ -70,4 +70,68 @@ describe("handleZaloWebhookRequest", () => {
70
70
  unregister();
71
71
  }
72
72
  });
73
+
74
+ it("rejects ambiguous routing when multiple targets match the same secret", async () => {
75
+ const core = {} as PluginRuntime;
76
+ const account: ResolvedZaloAccount = {
77
+ accountId: "default",
78
+ enabled: true,
79
+ token: "tok",
80
+ tokenSource: "config",
81
+ config: {},
82
+ };
83
+ const sinkA = vi.fn();
84
+ const sinkB = vi.fn();
85
+ const unregisterA = registerZaloWebhookTarget({
86
+ token: "tok",
87
+ account,
88
+ config: {} as OpenClawConfig,
89
+ runtime: {},
90
+ core,
91
+ secret: "secret",
92
+ path: "/hook",
93
+ mediaMaxMb: 5,
94
+ statusSink: sinkA,
95
+ });
96
+ const unregisterB = registerZaloWebhookTarget({
97
+ token: "tok",
98
+ account,
99
+ config: {} as OpenClawConfig,
100
+ runtime: {},
101
+ core,
102
+ secret: "secret",
103
+ path: "/hook",
104
+ mediaMaxMb: 5,
105
+ statusSink: sinkB,
106
+ });
107
+
108
+ try {
109
+ await withServer(
110
+ async (req, res) => {
111
+ const handled = await handleZaloWebhookRequest(req, res);
112
+ if (!handled) {
113
+ res.statusCode = 404;
114
+ res.end("not found");
115
+ }
116
+ },
117
+ async (baseUrl) => {
118
+ const response = await fetch(`${baseUrl}/hook`, {
119
+ method: "POST",
120
+ headers: {
121
+ "x-bot-api-secret-token": "secret",
122
+ "content-type": "application/json",
123
+ },
124
+ body: "{}",
125
+ });
126
+
127
+ expect(response.status).toBe(401);
128
+ expect(sinkA).not.toHaveBeenCalled();
129
+ expect(sinkB).not.toHaveBeenCalled();
130
+ },
131
+ );
132
+ } finally {
133
+ unregisterA();
134
+ unregisterB();
135
+ }
136
+ });
73
137
  });
package/src/probe.ts CHANGED
@@ -1,9 +1,8 @@
1
+ import type { BaseProbeResult } from "openclaw/plugin-sdk";
1
2
  import { getMe, ZaloApiError, type ZaloBotInfo, type ZaloFetch } from "./api.js";
2
3
 
3
- export type ZaloProbeResult = {
4
- ok: boolean;
4
+ export type ZaloProbeResult = BaseProbeResult<string> & {
5
5
  bot?: ZaloBotInfo;
6
- error?: string;
7
6
  elapsedMs: number;
8
7
  };
9
8
 
package/src/token.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
2
+ import { type BaseTokenResolution, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
3
3
  import type { ZaloConfig } from "./types.js";
4
4
 
5
- export type ZaloTokenResolution = {
6
- token: string;
5
+ export type ZaloTokenResolution = BaseTokenResolution & {
7
6
  source: "env" | "config" | "configFile" | "none";
8
7
  };
9
8