@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 +6 -0
- package/package.json +2 -2
- package/src/accounts.ts +1 -1
- package/src/channel.ts +11 -45
- package/src/monitor.ts +8 -2
- package/src/monitor.webhook.test.ts +65 -1
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/zalo",
|
|
3
|
-
"version": "2026.2.
|
|
3
|
+
"version": "2026.2.14",
|
|
4
4
|
"description": "OpenClaw Zalo channel plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
|
-
"undici": "7.
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
:
|
|
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:
|
|
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
|
|
147
|
-
if (
|
|
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
|
});
|