@openclaw/zalo 2026.2.17 → 2026.2.21
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 +0 -132
- package/package.json +1 -1
- package/src/monitor.ts +133 -10
- package/src/monitor.webhook.test.ts +173 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,137 +1,5 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 2026.2.17
|
|
4
|
-
|
|
5
|
-
### Changes
|
|
6
|
-
|
|
7
|
-
- Version alignment with core OpenClaw release numbers.
|
|
8
|
-
|
|
9
|
-
## 2026.2.16
|
|
10
|
-
|
|
11
|
-
### Changes
|
|
12
|
-
|
|
13
|
-
- Version alignment with core OpenClaw release numbers.
|
|
14
|
-
|
|
15
|
-
## 2026.2.15
|
|
16
|
-
|
|
17
|
-
### Changes
|
|
18
|
-
|
|
19
|
-
- Version alignment with core OpenClaw release numbers.
|
|
20
|
-
|
|
21
|
-
## 2026.2.14
|
|
22
|
-
|
|
23
|
-
### Changes
|
|
24
|
-
|
|
25
|
-
- Version alignment with core OpenClaw release numbers.
|
|
26
|
-
|
|
27
|
-
## 2026.2.13
|
|
28
|
-
|
|
29
|
-
### Changes
|
|
30
|
-
|
|
31
|
-
- Version alignment with core OpenClaw release numbers.
|
|
32
|
-
|
|
33
|
-
## 2026.2.6-3
|
|
34
|
-
|
|
35
|
-
### Changes
|
|
36
|
-
|
|
37
|
-
- Version alignment with core OpenClaw release numbers.
|
|
38
|
-
|
|
39
|
-
## 2026.2.6-2
|
|
40
|
-
|
|
41
|
-
### Changes
|
|
42
|
-
|
|
43
|
-
- Version alignment with core OpenClaw release numbers.
|
|
44
|
-
|
|
45
|
-
## 2026.2.6
|
|
46
|
-
|
|
47
|
-
### Changes
|
|
48
|
-
|
|
49
|
-
- Version alignment with core OpenClaw release numbers.
|
|
50
|
-
|
|
51
|
-
## 2026.2.4
|
|
52
|
-
|
|
53
|
-
### Changes
|
|
54
|
-
|
|
55
|
-
- Version alignment with core OpenClaw release numbers.
|
|
56
|
-
|
|
57
|
-
## 2026.2.2
|
|
58
|
-
|
|
59
|
-
### Changes
|
|
60
|
-
|
|
61
|
-
- Version alignment with core OpenClaw release numbers.
|
|
62
|
-
|
|
63
|
-
## 2026.1.31
|
|
64
|
-
|
|
65
|
-
### Changes
|
|
66
|
-
|
|
67
|
-
- Version alignment with core OpenClaw release numbers.
|
|
68
|
-
|
|
69
|
-
## 2026.1.30
|
|
70
|
-
|
|
71
|
-
### Changes
|
|
72
|
-
|
|
73
|
-
- Version alignment with core OpenClaw release numbers.
|
|
74
|
-
|
|
75
|
-
## 2026.1.29
|
|
76
|
-
|
|
77
|
-
### Changes
|
|
78
|
-
|
|
79
|
-
- Version alignment with core OpenClaw release numbers.
|
|
80
|
-
|
|
81
|
-
## 2026.1.23
|
|
82
|
-
|
|
83
|
-
### Changes
|
|
84
|
-
|
|
85
|
-
- Version alignment with core OpenClaw release numbers.
|
|
86
|
-
|
|
87
|
-
## 2026.1.22
|
|
88
|
-
|
|
89
|
-
### Changes
|
|
90
|
-
|
|
91
|
-
- Version alignment with core OpenClaw release numbers.
|
|
92
|
-
|
|
93
|
-
## 2026.1.21
|
|
94
|
-
|
|
95
|
-
### Changes
|
|
96
|
-
|
|
97
|
-
- Version alignment with core OpenClaw release numbers.
|
|
98
|
-
|
|
99
|
-
## 2026.1.20
|
|
100
|
-
|
|
101
|
-
### Changes
|
|
102
|
-
|
|
103
|
-
- Version alignment with core OpenClaw release numbers.
|
|
104
|
-
|
|
105
|
-
## 2026.1.17-1
|
|
106
|
-
|
|
107
|
-
### Changes
|
|
108
|
-
|
|
109
|
-
- Version alignment with core OpenClaw release numbers.
|
|
110
|
-
|
|
111
|
-
## 2026.1.17
|
|
112
|
-
|
|
113
|
-
### Changes
|
|
114
|
-
|
|
115
|
-
- Version alignment with core OpenClaw release numbers.
|
|
116
|
-
|
|
117
|
-
## 2026.1.16
|
|
118
|
-
|
|
119
|
-
### Changes
|
|
120
|
-
|
|
121
|
-
- Version alignment with core OpenClaw release numbers.
|
|
122
|
-
|
|
123
|
-
## 2026.1.15
|
|
124
|
-
|
|
125
|
-
### Changes
|
|
126
|
-
|
|
127
|
-
- Version alignment with core OpenClaw release numbers.
|
|
128
|
-
|
|
129
|
-
## 2026.1.14
|
|
130
|
-
|
|
131
|
-
### Changes
|
|
132
|
-
|
|
133
|
-
- Version alignment with core OpenClaw release numbers.
|
|
134
|
-
|
|
135
3
|
## 0.1.0
|
|
136
4
|
|
|
137
5
|
### Features
|
package/package.json
CHANGED
package/src/monitor.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
1
2
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
3
|
import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk";
|
|
3
4
|
import {
|
|
@@ -5,6 +6,7 @@ import {
|
|
|
5
6
|
readJsonBodyWithLimit,
|
|
6
7
|
registerWebhookTarget,
|
|
7
8
|
rejectNonPostWebhookRequest,
|
|
9
|
+
resolveSingleWebhookTarget,
|
|
8
10
|
resolveSenderCommandAuthorization,
|
|
9
11
|
resolveWebhookPath,
|
|
10
12
|
resolveWebhookTargets,
|
|
@@ -50,8 +52,13 @@ export type ZaloMonitorResult = {
|
|
|
50
52
|
|
|
51
53
|
const ZALO_TEXT_LIMIT = 2000;
|
|
52
54
|
const DEFAULT_MEDIA_MAX_MB = 5;
|
|
55
|
+
const ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
56
|
+
const ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120;
|
|
57
|
+
const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000;
|
|
58
|
+
const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25;
|
|
53
59
|
|
|
54
60
|
type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
|
|
61
|
+
type WebhookRateLimitState = { count: number; windowStartMs: number };
|
|
55
62
|
|
|
56
63
|
function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
|
|
57
64
|
if (core.logging.shouldLogVerbose()) {
|
|
@@ -84,6 +91,91 @@ type WebhookTarget = {
|
|
|
84
91
|
};
|
|
85
92
|
|
|
86
93
|
const webhookTargets = new Map<string, WebhookTarget[]>();
|
|
94
|
+
const webhookRateLimits = new Map<string, WebhookRateLimitState>();
|
|
95
|
+
const recentWebhookEvents = new Map<string, number>();
|
|
96
|
+
const webhookStatusCounters = new Map<string, number>();
|
|
97
|
+
|
|
98
|
+
function isJsonContentType(value: string | string[] | undefined): boolean {
|
|
99
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
100
|
+
if (!first) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const mediaType = first.split(";", 1)[0]?.trim().toLowerCase();
|
|
104
|
+
return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json"));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function timingSafeEquals(left: string, right: string): boolean {
|
|
108
|
+
const leftBuffer = Buffer.from(left);
|
|
109
|
+
const rightBuffer = Buffer.from(right);
|
|
110
|
+
|
|
111
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
112
|
+
const length = Math.max(1, leftBuffer.length, rightBuffer.length);
|
|
113
|
+
const paddedLeft = Buffer.alloc(length);
|
|
114
|
+
const paddedRight = Buffer.alloc(length);
|
|
115
|
+
leftBuffer.copy(paddedLeft);
|
|
116
|
+
rightBuffer.copy(paddedRight);
|
|
117
|
+
timingSafeEqual(paddedLeft, paddedRight);
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isWebhookRateLimited(key: string, nowMs: number): boolean {
|
|
125
|
+
const state = webhookRateLimits.get(key);
|
|
126
|
+
if (!state || nowMs - state.windowStartMs >= ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS) {
|
|
127
|
+
webhookRateLimits.set(key, { count: 1, windowStartMs: nowMs });
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
state.count += 1;
|
|
132
|
+
if (state.count > ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean {
|
|
139
|
+
const messageId = update.message?.message_id;
|
|
140
|
+
if (!messageId) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
const key = `${update.event_name}:${messageId}`;
|
|
144
|
+
const seenAt = recentWebhookEvents.get(key);
|
|
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;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function recordWebhookStatus(
|
|
163
|
+
runtime: ZaloRuntimeEnv | undefined,
|
|
164
|
+
path: string,
|
|
165
|
+
statusCode: number,
|
|
166
|
+
): void {
|
|
167
|
+
if (![400, 401, 408, 413, 415, 429].includes(statusCode)) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const key = `${path}:${statusCode}`;
|
|
171
|
+
const next = (webhookStatusCounters.get(key) ?? 0) + 1;
|
|
172
|
+
webhookStatusCounters.set(key, next);
|
|
173
|
+
if (next === 1 || next % ZALO_WEBHOOK_COUNTER_LOG_EVERY === 0) {
|
|
174
|
+
runtime?.log?.(
|
|
175
|
+
`[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(next)}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
87
179
|
|
|
88
180
|
export function registerZaloWebhookTarget(target: WebhookTarget): () => void {
|
|
89
181
|
return registerWebhookTarget(webhookTargets, target).unregister;
|
|
@@ -104,18 +196,39 @@ export async function handleZaloWebhookRequest(
|
|
|
104
196
|
}
|
|
105
197
|
|
|
106
198
|
const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
|
|
107
|
-
const
|
|
108
|
-
|
|
199
|
+
const matchedTarget = resolveSingleWebhookTarget(targets, (entry) =>
|
|
200
|
+
timingSafeEquals(entry.secret, headerToken),
|
|
201
|
+
);
|
|
202
|
+
if (matchedTarget.kind === "none") {
|
|
109
203
|
res.statusCode = 401;
|
|
110
204
|
res.end("unauthorized");
|
|
205
|
+
recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
|
|
111
206
|
return true;
|
|
112
207
|
}
|
|
113
|
-
if (
|
|
208
|
+
if (matchedTarget.kind === "ambiguous") {
|
|
114
209
|
res.statusCode = 401;
|
|
115
210
|
res.end("ambiguous webhook target");
|
|
211
|
+
recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
const target = matchedTarget.target;
|
|
215
|
+
const path = req.url ?? "<unknown>";
|
|
216
|
+
const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
|
|
217
|
+
const nowMs = Date.now();
|
|
218
|
+
|
|
219
|
+
if (isWebhookRateLimited(rateLimitKey, nowMs)) {
|
|
220
|
+
res.statusCode = 429;
|
|
221
|
+
res.end("Too Many Requests");
|
|
222
|
+
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!isJsonContentType(req.headers["content-type"])) {
|
|
227
|
+
res.statusCode = 415;
|
|
228
|
+
res.end("Unsupported Media Type");
|
|
229
|
+
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
116
230
|
return true;
|
|
117
231
|
}
|
|
118
|
-
const target = matching[0];
|
|
119
232
|
|
|
120
233
|
const body = await readJsonBodyWithLimit(req, {
|
|
121
234
|
maxBytes: 1024 * 1024,
|
|
@@ -125,11 +238,14 @@ export async function handleZaloWebhookRequest(
|
|
|
125
238
|
if (!body.ok) {
|
|
126
239
|
res.statusCode =
|
|
127
240
|
body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
|
|
128
|
-
|
|
129
|
-
body.code === "
|
|
130
|
-
? requestBodyErrorToText("
|
|
131
|
-
: body.
|
|
132
|
-
|
|
241
|
+
const message =
|
|
242
|
+
body.code === "PAYLOAD_TOO_LARGE"
|
|
243
|
+
? requestBodyErrorToText("PAYLOAD_TOO_LARGE")
|
|
244
|
+
: body.code === "REQUEST_BODY_TIMEOUT"
|
|
245
|
+
? requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
|
|
246
|
+
: "Bad Request";
|
|
247
|
+
res.end(message);
|
|
248
|
+
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
133
249
|
return true;
|
|
134
250
|
}
|
|
135
251
|
|
|
@@ -143,7 +259,14 @@ export async function handleZaloWebhookRequest(
|
|
|
143
259
|
|
|
144
260
|
if (!update?.event_name) {
|
|
145
261
|
res.statusCode = 400;
|
|
146
|
-
res.end("
|
|
262
|
+
res.end("Bad Request");
|
|
263
|
+
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (isReplayEvent(update, nowMs)) {
|
|
268
|
+
res.statusCode = 200;
|
|
269
|
+
res.end("ok");
|
|
147
270
|
return true;
|
|
148
271
|
}
|
|
149
272
|
|
|
@@ -56,11 +56,13 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
56
56
|
method: "POST",
|
|
57
57
|
headers: {
|
|
58
58
|
"x-bot-api-secret-token": "secret",
|
|
59
|
+
"content-type": "application/json",
|
|
59
60
|
},
|
|
60
61
|
body: "null",
|
|
61
62
|
});
|
|
62
63
|
|
|
63
64
|
expect(response.status).toBe(400);
|
|
65
|
+
expect(await response.text()).toBe("Bad Request");
|
|
64
66
|
},
|
|
65
67
|
);
|
|
66
68
|
} finally {
|
|
@@ -131,4 +133,175 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
131
133
|
unregisterB();
|
|
132
134
|
}
|
|
133
135
|
});
|
|
136
|
+
|
|
137
|
+
it("returns 415 for non-json content-type", async () => {
|
|
138
|
+
const core = {} as PluginRuntime;
|
|
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
|
+
});
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
await withServer(
|
|
159
|
+
async (req, res) => {
|
|
160
|
+
const handled = await handleZaloWebhookRequest(req, res);
|
|
161
|
+
if (!handled) {
|
|
162
|
+
res.statusCode = 404;
|
|
163
|
+
res.end("not found");
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
async (baseUrl) => {
|
|
167
|
+
const response = await fetch(`${baseUrl}/hook-content-type`, {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: {
|
|
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
|
+
);
|
|
179
|
+
} finally {
|
|
180
|
+
unregister();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
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
|
+
const sink = vi.fn();
|
|
194
|
+
const unregister = registerZaloWebhookTarget({
|
|
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
|
+
});
|
|
205
|
+
|
|
206
|
+
const payload = {
|
|
207
|
+
event_name: "message.text.received",
|
|
208
|
+
message: {
|
|
209
|
+
from: { id: "123" },
|
|
210
|
+
chat: { id: "123", chat_type: "PRIVATE" },
|
|
211
|
+
message_id: "msg-replay-1",
|
|
212
|
+
date: Math.floor(Date.now() / 1000),
|
|
213
|
+
text: "hello",
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
await withServer(
|
|
219
|
+
async (req, res) => {
|
|
220
|
+
const handled = await handleZaloWebhookRequest(req, res);
|
|
221
|
+
if (!handled) {
|
|
222
|
+
res.statusCode = 404;
|
|
223
|
+
res.end("not found");
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
async (baseUrl) => {
|
|
227
|
+
const first = await fetch(`${baseUrl}/hook-replay`, {
|
|
228
|
+
method: "POST",
|
|
229
|
+
headers: {
|
|
230
|
+
"x-bot-api-secret-token": "secret",
|
|
231
|
+
"content-type": "application/json",
|
|
232
|
+
},
|
|
233
|
+
body: JSON.stringify(payload),
|
|
234
|
+
});
|
|
235
|
+
const second = await fetch(`${baseUrl}/hook-replay`, {
|
|
236
|
+
method: "POST",
|
|
237
|
+
headers: {
|
|
238
|
+
"x-bot-api-secret-token": "secret",
|
|
239
|
+
"content-type": "application/json",
|
|
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
|
+
);
|
|
249
|
+
} finally {
|
|
250
|
+
unregister();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("returns 429 when per-path request rate exceeds threshold", async () => {
|
|
255
|
+
const core = {} as PluginRuntime;
|
|
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
|
+
});
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
await withServer(
|
|
276
|
+
async (req, res) => {
|
|
277
|
+
const handled = await handleZaloWebhookRequest(req, res);
|
|
278
|
+
if (!handled) {
|
|
279
|
+
res.statusCode = 404;
|
|
280
|
+
res.end("not found");
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
async (baseUrl) => {
|
|
284
|
+
let saw429 = false;
|
|
285
|
+
for (let i = 0; i < 130; i += 1) {
|
|
286
|
+
const response = await fetch(`${baseUrl}/hook-rate`, {
|
|
287
|
+
method: "POST",
|
|
288
|
+
headers: {
|
|
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
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
expect(saw429).toBe(true);
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
} finally {
|
|
304
|
+
unregister();
|
|
305
|
+
}
|
|
306
|
+
});
|
|
134
307
|
});
|