@openclaw/zalo 2026.2.12 → 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 +12 -0
- package/package.json +2 -2
- package/src/accounts.ts +1 -1
- package/src/channel.ts +11 -45
- package/src/monitor.ts +25 -37
- 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
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createReplyPrefixOptions,
|
|
5
|
+
readJsonBodyWithLimit,
|
|
6
|
+
requestBodyErrorToText,
|
|
7
|
+
} from "openclaw/plugin-sdk";
|
|
4
8
|
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
5
9
|
import {
|
|
6
10
|
ZaloApiError,
|
|
@@ -61,37 +65,6 @@ function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
|
|
|
61
65
|
});
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
async function readJsonBody(req: IncomingMessage, maxBytes: number) {
|
|
65
|
-
const chunks: Buffer[] = [];
|
|
66
|
-
let total = 0;
|
|
67
|
-
return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
|
|
68
|
-
req.on("data", (chunk: Buffer) => {
|
|
69
|
-
total += chunk.length;
|
|
70
|
-
if (total > maxBytes) {
|
|
71
|
-
resolve({ ok: false, error: "payload too large" });
|
|
72
|
-
req.destroy();
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
chunks.push(chunk);
|
|
76
|
-
});
|
|
77
|
-
req.on("end", () => {
|
|
78
|
-
try {
|
|
79
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
80
|
-
if (!raw.trim()) {
|
|
81
|
-
resolve({ ok: false, error: "empty payload" });
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
resolve({ ok: true, value: JSON.parse(raw) as unknown });
|
|
85
|
-
} catch (err) {
|
|
86
|
-
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
req.on("error", (err) => {
|
|
90
|
-
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
68
|
type WebhookTarget = {
|
|
96
69
|
token: string;
|
|
97
70
|
account: ResolvedZaloAccount;
|
|
@@ -170,17 +143,32 @@ export async function handleZaloWebhookRequest(
|
|
|
170
143
|
}
|
|
171
144
|
|
|
172
145
|
const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
|
|
173
|
-
const
|
|
174
|
-
if (
|
|
146
|
+
const matching = targets.filter((entry) => entry.secret === headerToken);
|
|
147
|
+
if (matching.length === 0) {
|
|
175
148
|
res.statusCode = 401;
|
|
176
149
|
res.end("unauthorized");
|
|
177
150
|
return true;
|
|
178
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];
|
|
179
158
|
|
|
180
|
-
const body = await
|
|
159
|
+
const body = await readJsonBodyWithLimit(req, {
|
|
160
|
+
maxBytes: 1024 * 1024,
|
|
161
|
+
timeoutMs: 30_000,
|
|
162
|
+
emptyObjectOnEmpty: false,
|
|
163
|
+
});
|
|
181
164
|
if (!body.ok) {
|
|
182
|
-
res.statusCode =
|
|
183
|
-
|
|
165
|
+
res.statusCode =
|
|
166
|
+
body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
|
|
167
|
+
res.end(
|
|
168
|
+
body.code === "REQUEST_BODY_TIMEOUT"
|
|
169
|
+
? requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
|
|
170
|
+
: body.error,
|
|
171
|
+
);
|
|
184
172
|
return true;
|
|
185
173
|
}
|
|
186
174
|
|
|
@@ -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
|
});
|