@openclaw/zalo 2026.2.13 → 2026.2.14

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.2.14
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
3
9
  ## 2026.2.13
4
10
 
5
11
  ### 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.14",
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
@@ -143,12 +143,18 @@ export async function handleZaloWebhookRequest(
143
143
  }
144
144
 
145
145
  const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
146
- const target = targets.find((entry) => entry.secret === headerToken);
147
- if (!target) {
146
+ const matching = targets.filter((entry) => entry.secret === headerToken);
147
+ if (matching.length === 0) {
148
148
  res.statusCode = 401;
149
149
  res.end("unauthorized");
150
150
  return true;
151
151
  }
152
+ if (matching.length > 1) {
153
+ res.statusCode = 401;
154
+ res.end("ambiguous webhook target");
155
+ return true;
156
+ }
157
+ const target = matching[0];
152
158
 
153
159
  const body = await readJsonBodyWithLimit(req, {
154
160
  maxBytes: 1024 * 1024,
@@ -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
  });