@openclaw/zalo 2026.2.21 → 2026.2.22
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 +1 -1
- package/src/monitor.ts +7 -18
- package/src/monitor.webhook.test.ts +118 -226
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
package/src/monitor.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { timingSafeEqual } from "node:crypto";
|
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
3
|
import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk";
|
|
4
4
|
import {
|
|
5
|
+
createDedupeCache,
|
|
5
6
|
createReplyPrefixOptions,
|
|
6
7
|
readJsonBodyWithLimit,
|
|
7
8
|
registerWebhookTarget,
|
|
@@ -92,7 +93,10 @@ type WebhookTarget = {
|
|
|
92
93
|
|
|
93
94
|
const webhookTargets = new Map<string, WebhookTarget[]>();
|
|
94
95
|
const webhookRateLimits = new Map<string, WebhookRateLimitState>();
|
|
95
|
-
const recentWebhookEvents =
|
|
96
|
+
const recentWebhookEvents = createDedupeCache({
|
|
97
|
+
ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS,
|
|
98
|
+
maxSize: 5000,
|
|
99
|
+
});
|
|
96
100
|
const webhookStatusCounters = new Map<string, number>();
|
|
97
101
|
|
|
98
102
|
function isJsonContentType(value: string | string[] | undefined): boolean {
|
|
@@ -141,22 +145,7 @@ function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean {
|
|
|
141
145
|
return false;
|
|
142
146
|
}
|
|
143
147
|
const key = `${update.event_name}:${messageId}`;
|
|
144
|
-
|
|
145
|
-
recentWebhookEvents.set(key, nowMs);
|
|
146
|
-
|
|
147
|
-
if (seenAt && nowMs - seenAt < ZALO_WEBHOOK_REPLAY_WINDOW_MS) {
|
|
148
|
-
return true;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (recentWebhookEvents.size > 5000) {
|
|
152
|
-
for (const [eventKey, timestamp] of recentWebhookEvents) {
|
|
153
|
-
if (nowMs - timestamp >= ZALO_WEBHOOK_REPLAY_WINDOW_MS) {
|
|
154
|
-
recentWebhookEvents.delete(eventKey);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return false;
|
|
148
|
+
return recentWebhookEvents.check(key, nowMs);
|
|
160
149
|
}
|
|
161
150
|
|
|
162
151
|
function recordWebhookStatus(
|
|
@@ -447,7 +436,7 @@ async function handleImageMessage(
|
|
|
447
436
|
if (photo) {
|
|
448
437
|
try {
|
|
449
438
|
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
450
|
-
const fetched = await core.channel.media.fetchRemoteMedia({ url: photo });
|
|
439
|
+
const fetched = await core.channel.media.fetchRemoteMedia({ url: photo, maxBytes });
|
|
451
440
|
const saved = await core.channel.media.saveMediaBuffer(
|
|
452
441
|
fetched.buffer,
|
|
453
442
|
fetched.contentType,
|
|
@@ -21,113 +21,84 @@ async function withServer(handler: RequestListener, fn: (baseUrl: string) => Pro
|
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
const DEFAULT_ACCOUNT: ResolvedZaloAccount = {
|
|
25
|
+
accountId: "default",
|
|
26
|
+
enabled: true,
|
|
27
|
+
token: "tok",
|
|
28
|
+
tokenSource: "config",
|
|
29
|
+
config: {},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const webhookRequestHandler: RequestListener = async (req, res) => {
|
|
33
|
+
const handled = await handleZaloWebhookRequest(req, res);
|
|
34
|
+
if (!handled) {
|
|
35
|
+
res.statusCode = 404;
|
|
36
|
+
res.end("not found");
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function registerTarget(params: {
|
|
41
|
+
path: string;
|
|
42
|
+
secret?: string;
|
|
43
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
44
|
+
}): () => void {
|
|
45
|
+
return registerZaloWebhookTarget({
|
|
46
|
+
token: "tok",
|
|
47
|
+
account: DEFAULT_ACCOUNT,
|
|
48
|
+
config: {} as OpenClawConfig,
|
|
49
|
+
runtime: {},
|
|
50
|
+
core: {} as PluginRuntime,
|
|
51
|
+
secret: params.secret ?? "secret",
|
|
52
|
+
path: params.path,
|
|
53
|
+
mediaMaxMb: 5,
|
|
54
|
+
statusSink: params.statusSink,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
24
58
|
describe("handleZaloWebhookRequest", () => {
|
|
25
59
|
it("returns 400 for non-object payloads", async () => {
|
|
26
|
-
const
|
|
27
|
-
const account: ResolvedZaloAccount = {
|
|
28
|
-
accountId: "default",
|
|
29
|
-
enabled: true,
|
|
30
|
-
token: "tok",
|
|
31
|
-
tokenSource: "config",
|
|
32
|
-
config: {},
|
|
33
|
-
};
|
|
34
|
-
const unregister = registerZaloWebhookTarget({
|
|
35
|
-
token: "tok",
|
|
36
|
-
account,
|
|
37
|
-
config: {} as OpenClawConfig,
|
|
38
|
-
runtime: {},
|
|
39
|
-
core,
|
|
40
|
-
secret: "secret",
|
|
41
|
-
path: "/hook",
|
|
42
|
-
mediaMaxMb: 5,
|
|
43
|
-
});
|
|
60
|
+
const unregister = registerTarget({ path: "/hook" });
|
|
44
61
|
|
|
45
62
|
try {
|
|
46
|
-
await withServer(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"content-type": "application/json",
|
|
60
|
-
},
|
|
61
|
-
body: "null",
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
expect(response.status).toBe(400);
|
|
65
|
-
expect(await response.text()).toBe("Bad Request");
|
|
66
|
-
},
|
|
67
|
-
);
|
|
63
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
64
|
+
const response = await fetch(`${baseUrl}/hook`, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: {
|
|
67
|
+
"x-bot-api-secret-token": "secret",
|
|
68
|
+
"content-type": "application/json",
|
|
69
|
+
},
|
|
70
|
+
body: "null",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(response.status).toBe(400);
|
|
74
|
+
expect(await response.text()).toBe("Bad Request");
|
|
75
|
+
});
|
|
68
76
|
} finally {
|
|
69
77
|
unregister();
|
|
70
78
|
}
|
|
71
79
|
});
|
|
72
80
|
|
|
73
81
|
it("rejects ambiguous routing when multiple targets match the same secret", async () => {
|
|
74
|
-
const core = {} as PluginRuntime;
|
|
75
|
-
const account: ResolvedZaloAccount = {
|
|
76
|
-
accountId: "default",
|
|
77
|
-
enabled: true,
|
|
78
|
-
token: "tok",
|
|
79
|
-
tokenSource: "config",
|
|
80
|
-
config: {},
|
|
81
|
-
};
|
|
82
82
|
const sinkA = vi.fn();
|
|
83
83
|
const sinkB = vi.fn();
|
|
84
|
-
const unregisterA =
|
|
85
|
-
|
|
86
|
-
account,
|
|
87
|
-
config: {} as OpenClawConfig,
|
|
88
|
-
runtime: {},
|
|
89
|
-
core,
|
|
90
|
-
secret: "secret",
|
|
91
|
-
path: "/hook",
|
|
92
|
-
mediaMaxMb: 5,
|
|
93
|
-
statusSink: sinkA,
|
|
94
|
-
});
|
|
95
|
-
const unregisterB = registerZaloWebhookTarget({
|
|
96
|
-
token: "tok",
|
|
97
|
-
account,
|
|
98
|
-
config: {} as OpenClawConfig,
|
|
99
|
-
runtime: {},
|
|
100
|
-
core,
|
|
101
|
-
secret: "secret",
|
|
102
|
-
path: "/hook",
|
|
103
|
-
mediaMaxMb: 5,
|
|
104
|
-
statusSink: sinkB,
|
|
105
|
-
});
|
|
84
|
+
const unregisterA = registerTarget({ path: "/hook", statusSink: sinkA });
|
|
85
|
+
const unregisterB = registerTarget({ path: "/hook", statusSink: sinkB });
|
|
106
86
|
|
|
107
87
|
try {
|
|
108
|
-
await withServer(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
},
|
|
123
|
-
body: "{}",
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
expect(response.status).toBe(401);
|
|
127
|
-
expect(sinkA).not.toHaveBeenCalled();
|
|
128
|
-
expect(sinkB).not.toHaveBeenCalled();
|
|
129
|
-
},
|
|
130
|
-
);
|
|
88
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
89
|
+
const response = await fetch(`${baseUrl}/hook`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: {
|
|
92
|
+
"x-bot-api-secret-token": "secret",
|
|
93
|
+
"content-type": "application/json",
|
|
94
|
+
},
|
|
95
|
+
body: "{}",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(response.status).toBe(401);
|
|
99
|
+
expect(sinkA).not.toHaveBeenCalled();
|
|
100
|
+
expect(sinkB).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
131
102
|
} finally {
|
|
132
103
|
unregisterA();
|
|
133
104
|
unregisterB();
|
|
@@ -135,73 +106,29 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
135
106
|
});
|
|
136
107
|
|
|
137
108
|
it("returns 415 for non-json content-type", async () => {
|
|
138
|
-
const
|
|
139
|
-
const account: ResolvedZaloAccount = {
|
|
140
|
-
accountId: "default",
|
|
141
|
-
enabled: true,
|
|
142
|
-
token: "tok",
|
|
143
|
-
tokenSource: "config",
|
|
144
|
-
config: {},
|
|
145
|
-
};
|
|
146
|
-
const unregister = registerZaloWebhookTarget({
|
|
147
|
-
token: "tok",
|
|
148
|
-
account,
|
|
149
|
-
config: {} as OpenClawConfig,
|
|
150
|
-
runtime: {},
|
|
151
|
-
core,
|
|
152
|
-
secret: "secret",
|
|
153
|
-
path: "/hook-content-type",
|
|
154
|
-
mediaMaxMb: 5,
|
|
155
|
-
});
|
|
109
|
+
const unregister = registerTarget({ path: "/hook-content-type" });
|
|
156
110
|
|
|
157
111
|
try {
|
|
158
|
-
await withServer(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
"x-bot-api-secret-token": "secret",
|
|
171
|
-
"content-type": "text/plain",
|
|
172
|
-
},
|
|
173
|
-
body: "{}",
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
expect(response.status).toBe(415);
|
|
177
|
-
},
|
|
178
|
-
);
|
|
112
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
113
|
+
const response = await fetch(`${baseUrl}/hook-content-type`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: {
|
|
116
|
+
"x-bot-api-secret-token": "secret",
|
|
117
|
+
"content-type": "text/plain",
|
|
118
|
+
},
|
|
119
|
+
body: "{}",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(response.status).toBe(415);
|
|
123
|
+
});
|
|
179
124
|
} finally {
|
|
180
125
|
unregister();
|
|
181
126
|
}
|
|
182
127
|
});
|
|
183
128
|
|
|
184
129
|
it("deduplicates webhook replay by event_name + message_id", async () => {
|
|
185
|
-
const core = {} as PluginRuntime;
|
|
186
|
-
const account: ResolvedZaloAccount = {
|
|
187
|
-
accountId: "default",
|
|
188
|
-
enabled: true,
|
|
189
|
-
token: "tok",
|
|
190
|
-
tokenSource: "config",
|
|
191
|
-
config: {},
|
|
192
|
-
};
|
|
193
130
|
const sink = vi.fn();
|
|
194
|
-
const unregister =
|
|
195
|
-
token: "tok",
|
|
196
|
-
account,
|
|
197
|
-
config: {} as OpenClawConfig,
|
|
198
|
-
runtime: {},
|
|
199
|
-
core,
|
|
200
|
-
secret: "secret",
|
|
201
|
-
path: "/hook-replay",
|
|
202
|
-
mediaMaxMb: 5,
|
|
203
|
-
statusSink: sink,
|
|
204
|
-
});
|
|
131
|
+
const unregister = registerTarget({ path: "/hook-replay", statusSink: sink });
|
|
205
132
|
|
|
206
133
|
const payload = {
|
|
207
134
|
event_name: "message.text.received",
|
|
@@ -215,91 +142,56 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
215
142
|
};
|
|
216
143
|
|
|
217
144
|
try {
|
|
218
|
-
await withServer(
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
},
|
|
241
|
-
body: JSON.stringify(payload),
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
expect(first.status).toBe(200);
|
|
245
|
-
expect(second.status).toBe(200);
|
|
246
|
-
expect(sink).toHaveBeenCalledTimes(1);
|
|
247
|
-
},
|
|
248
|
-
);
|
|
145
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
146
|
+
const first = await fetch(`${baseUrl}/hook-replay`, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: {
|
|
149
|
+
"x-bot-api-secret-token": "secret",
|
|
150
|
+
"content-type": "application/json",
|
|
151
|
+
},
|
|
152
|
+
body: JSON.stringify(payload),
|
|
153
|
+
});
|
|
154
|
+
const second = await fetch(`${baseUrl}/hook-replay`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: {
|
|
157
|
+
"x-bot-api-secret-token": "secret",
|
|
158
|
+
"content-type": "application/json",
|
|
159
|
+
},
|
|
160
|
+
body: JSON.stringify(payload),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(first.status).toBe(200);
|
|
164
|
+
expect(second.status).toBe(200);
|
|
165
|
+
expect(sink).toHaveBeenCalledTimes(1);
|
|
166
|
+
});
|
|
249
167
|
} finally {
|
|
250
168
|
unregister();
|
|
251
169
|
}
|
|
252
170
|
});
|
|
253
171
|
|
|
254
172
|
it("returns 429 when per-path request rate exceeds threshold", async () => {
|
|
255
|
-
const
|
|
256
|
-
const account: ResolvedZaloAccount = {
|
|
257
|
-
accountId: "default",
|
|
258
|
-
enabled: true,
|
|
259
|
-
token: "tok",
|
|
260
|
-
tokenSource: "config",
|
|
261
|
-
config: {},
|
|
262
|
-
};
|
|
263
|
-
const unregister = registerZaloWebhookTarget({
|
|
264
|
-
token: "tok",
|
|
265
|
-
account,
|
|
266
|
-
config: {} as OpenClawConfig,
|
|
267
|
-
runtime: {},
|
|
268
|
-
core,
|
|
269
|
-
secret: "secret",
|
|
270
|
-
path: "/hook-rate",
|
|
271
|
-
mediaMaxMb: 5,
|
|
272
|
-
});
|
|
173
|
+
const unregister = registerTarget({ path: "/hook-rate" });
|
|
273
174
|
|
|
274
175
|
try {
|
|
275
|
-
await withServer(
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
"x-bot-api-secret-token": "secret",
|
|
290
|
-
"content-type": "application/json",
|
|
291
|
-
},
|
|
292
|
-
body: "{}",
|
|
293
|
-
});
|
|
294
|
-
if (response.status === 429) {
|
|
295
|
-
saw429 = true;
|
|
296
|
-
break;
|
|
297
|
-
}
|
|
176
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
177
|
+
let saw429 = false;
|
|
178
|
+
for (let i = 0; i < 130; i += 1) {
|
|
179
|
+
const response = await fetch(`${baseUrl}/hook-rate`, {
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: {
|
|
182
|
+
"x-bot-api-secret-token": "secret",
|
|
183
|
+
"content-type": "application/json",
|
|
184
|
+
},
|
|
185
|
+
body: "{}",
|
|
186
|
+
});
|
|
187
|
+
if (response.status === 429) {
|
|
188
|
+
saw429 = true;
|
|
189
|
+
break;
|
|
298
190
|
}
|
|
191
|
+
}
|
|
299
192
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
);
|
|
193
|
+
expect(saw429).toBe(true);
|
|
194
|
+
});
|
|
303
195
|
} finally {
|
|
304
196
|
unregister();
|
|
305
197
|
}
|