@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 +12 -0
- package/package.json +2 -2
- package/src/accounts.ts +1 -1
- package/src/channel.ts +11 -45
- package/src/monitor.ts +11 -31
- package/src/monitor.webhook.test.ts +65 -1
- package/src/probe.ts +2 -3
- package/src/token.ts +2 -3
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.15",
|
|
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
|
@@ -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
|
|
147
|
-
if (
|
|
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
|
|