@openclaw/feishu 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/package.json +1 -1
- package/src/bot.checkBotMentioned.test.ts +22 -1
- package/src/bot.stripBotMention.test.ts +38 -0
- package/src/bot.ts +23 -12
- package/src/channel.ts +4 -0
- package/src/config-schema.test.ts +66 -0
- package/src/config-schema.ts +34 -0
- package/src/external-keys.test.ts +20 -0
- package/src/external-keys.ts +19 -0
- package/src/media.test.ts +95 -1
- package/src/media.ts +19 -13
- package/src/mention.ts +8 -1
- package/src/monitor.ts +70 -3
- package/src/monitor.webhook-security.test.ts +174 -0
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import { parseFeishuMessageEvent } from "./bot.js";
|
|
|
5
5
|
function makeEvent(
|
|
6
6
|
chatType: "p2p" | "group",
|
|
7
7
|
mentions?: Array<{ key: string; name: string; id: { open_id?: string } }>,
|
|
8
|
+
text = "hello",
|
|
8
9
|
) {
|
|
9
10
|
return {
|
|
10
11
|
sender: {
|
|
@@ -15,7 +16,7 @@ function makeEvent(
|
|
|
15
16
|
chat_id: "oc_chat1",
|
|
16
17
|
chat_type: chatType,
|
|
17
18
|
message_type: "text",
|
|
18
|
-
content: JSON.stringify({ text
|
|
19
|
+
content: JSON.stringify({ text }),
|
|
19
20
|
mentions,
|
|
20
21
|
},
|
|
21
22
|
};
|
|
@@ -62,6 +63,26 @@ describe("parseFeishuMessageEvent – mentionedBot", () => {
|
|
|
62
63
|
expect(ctx.mentionedBot).toBe(false);
|
|
63
64
|
});
|
|
64
65
|
|
|
66
|
+
it("treats mention.name regex metacharacters as literals when stripping", () => {
|
|
67
|
+
const event = makeEvent(
|
|
68
|
+
"group",
|
|
69
|
+
[{ key: "@_bot_1", name: ".*", id: { open_id: BOT_OPEN_ID } }],
|
|
70
|
+
"@NotBot hello",
|
|
71
|
+
);
|
|
72
|
+
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
|
|
73
|
+
expect(ctx.content).toBe("@NotBot hello");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("treats mention.key regex metacharacters as literals when stripping", () => {
|
|
77
|
+
const event = makeEvent(
|
|
78
|
+
"group",
|
|
79
|
+
[{ key: ".*", name: "Bot", id: { open_id: BOT_OPEN_ID } }],
|
|
80
|
+
"hello world",
|
|
81
|
+
);
|
|
82
|
+
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
|
|
83
|
+
expect(ctx.content).toBe("hello world");
|
|
84
|
+
});
|
|
85
|
+
|
|
65
86
|
it("returns mentionedBot=true for post message with at (no top-level mentions)", () => {
|
|
66
87
|
const BOT_OPEN_ID = "ou_bot_123";
|
|
67
88
|
const postContent = JSON.stringify({
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { stripBotMention, type FeishuMessageEvent } from "./bot.js";
|
|
3
|
+
|
|
4
|
+
type Mentions = FeishuMessageEvent["message"]["mentions"];
|
|
5
|
+
|
|
6
|
+
describe("stripBotMention", () => {
|
|
7
|
+
it("returns original text when mentions are missing", () => {
|
|
8
|
+
expect(stripBotMention("hello world", undefined)).toBe("hello world");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("strips mention name and key for normal mentions", () => {
|
|
12
|
+
const mentions: Mentions = [{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }];
|
|
13
|
+
expect(stripBotMention("@Bot hello @_bot_1", mentions)).toBe("hello");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("treats mention.name regex metacharacters as literal text", () => {
|
|
17
|
+
const mentions: Mentions = [{ key: "@_bot_1", name: ".*", id: { open_id: "ou_bot" } }];
|
|
18
|
+
expect(stripBotMention("@NotBot hello", mentions)).toBe("@NotBot hello");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("treats mention.key regex metacharacters as literal text", () => {
|
|
22
|
+
const mentions: Mentions = [{ key: ".*", name: "Bot", id: { open_id: "ou_bot" } }];
|
|
23
|
+
expect(stripBotMention("hello world", mentions)).toBe("hello world");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("trims once after all mention replacements", () => {
|
|
27
|
+
const mentions: Mentions = [{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }];
|
|
28
|
+
expect(stripBotMention(" @_bot_1 hello ", mentions)).toBe("hello");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("strips multiple mentions in one pass", () => {
|
|
32
|
+
const mentions: Mentions = [
|
|
33
|
+
{ key: "@_bot_1", name: "Bot One", id: { open_id: "ou_bot_1" } },
|
|
34
|
+
{ key: "@_bot_2", name: "Bot Two", id: { open_id: "ou_bot_2" } },
|
|
35
|
+
];
|
|
36
|
+
expect(stripBotMention("@Bot One @_bot_1 hi @Bot Two @_bot_2", mentions)).toBe("hi");
|
|
37
|
+
});
|
|
38
|
+
});
|
package/src/bot.ts
CHANGED
|
@@ -11,8 +11,14 @@ import { resolveFeishuAccount } from "./accounts.js";
|
|
|
11
11
|
import { createFeishuClient } from "./client.js";
|
|
12
12
|
import { tryRecordMessage } from "./dedup.js";
|
|
13
13
|
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
|
15
|
+
import { downloadMessageResourceFeishu } from "./media.js";
|
|
16
|
+
import {
|
|
17
|
+
escapeRegExp,
|
|
18
|
+
extractMentionTargets,
|
|
19
|
+
extractMessageBody,
|
|
20
|
+
isMentionForwardRequest,
|
|
21
|
+
} from "./mention.js";
|
|
16
22
|
import {
|
|
17
23
|
resolveFeishuGroupConfig,
|
|
18
24
|
resolveFeishuReplyPolicy,
|
|
@@ -198,17 +204,17 @@ function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boole
|
|
|
198
204
|
return false;
|
|
199
205
|
}
|
|
200
206
|
|
|
201
|
-
function stripBotMention(
|
|
207
|
+
export function stripBotMention(
|
|
202
208
|
text: string,
|
|
203
209
|
mentions?: FeishuMessageEvent["message"]["mentions"],
|
|
204
210
|
): string {
|
|
205
211
|
if (!mentions || mentions.length === 0) return text;
|
|
206
212
|
let result = text;
|
|
207
213
|
for (const mention of mentions) {
|
|
208
|
-
result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "")
|
|
209
|
-
result = result.replace(new RegExp(mention.key, "g"), "")
|
|
214
|
+
result = result.replace(new RegExp(`@${escapeRegExp(mention.name)}\\s*`, "g"), "");
|
|
215
|
+
result = result.replace(new RegExp(escapeRegExp(mention.key), "g"), "");
|
|
210
216
|
}
|
|
211
|
-
return result;
|
|
217
|
+
return result.trim();
|
|
212
218
|
}
|
|
213
219
|
|
|
214
220
|
/**
|
|
@@ -224,18 +230,20 @@ function parseMediaKeys(
|
|
|
224
230
|
} {
|
|
225
231
|
try {
|
|
226
232
|
const parsed = JSON.parse(content);
|
|
233
|
+
const imageKey = normalizeFeishuExternalKey(parsed.image_key);
|
|
234
|
+
const fileKey = normalizeFeishuExternalKey(parsed.file_key);
|
|
227
235
|
switch (messageType) {
|
|
228
236
|
case "image":
|
|
229
|
-
return { imageKey
|
|
237
|
+
return { imageKey };
|
|
230
238
|
case "file":
|
|
231
|
-
return { fileKey
|
|
239
|
+
return { fileKey, fileName: parsed.file_name };
|
|
232
240
|
case "audio":
|
|
233
|
-
return { fileKey
|
|
241
|
+
return { fileKey };
|
|
234
242
|
case "video":
|
|
235
243
|
// Video has both file_key (video) and image_key (thumbnail)
|
|
236
|
-
return { fileKey
|
|
244
|
+
return { fileKey, imageKey };
|
|
237
245
|
case "sticker":
|
|
238
|
-
return { fileKey
|
|
246
|
+
return { fileKey };
|
|
239
247
|
default:
|
|
240
248
|
return {};
|
|
241
249
|
}
|
|
@@ -277,7 +285,10 @@ function parsePostContent(content: string): {
|
|
|
277
285
|
}
|
|
278
286
|
} else if (element.tag === "img" && element.image_key) {
|
|
279
287
|
// Embedded image
|
|
280
|
-
|
|
288
|
+
const imageKey = normalizeFeishuExternalKey(element.image_key);
|
|
289
|
+
if (imageKey) {
|
|
290
|
+
imageKeys.push(imageKey);
|
|
291
|
+
}
|
|
281
292
|
}
|
|
282
293
|
}
|
|
283
294
|
textContent += "\n";
|
package/src/channel.ts
CHANGED
|
@@ -89,6 +89,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
89
89
|
},
|
|
90
90
|
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
|
|
91
91
|
webhookPath: { type: "string" },
|
|
92
|
+
webhookHost: { type: "string" },
|
|
92
93
|
webhookPort: { type: "integer", minimum: 1 },
|
|
93
94
|
dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
|
|
94
95
|
allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
|
|
@@ -118,6 +119,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
118
119
|
verificationToken: { type: "string" },
|
|
119
120
|
domain: { type: "string", enum: ["feishu", "lark"] },
|
|
120
121
|
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
|
|
122
|
+
webhookHost: { type: "string" },
|
|
123
|
+
webhookPath: { type: "string" },
|
|
124
|
+
webhookPort: { type: "integer", minimum: 1 },
|
|
121
125
|
},
|
|
122
126
|
},
|
|
123
127
|
},
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { FeishuConfigSchema } from "./config-schema.js";
|
|
3
|
+
|
|
4
|
+
describe("FeishuConfigSchema webhook validation", () => {
|
|
5
|
+
it("rejects top-level webhook mode without verificationToken", () => {
|
|
6
|
+
const result = FeishuConfigSchema.safeParse({
|
|
7
|
+
connectionMode: "webhook",
|
|
8
|
+
appId: "cli_top",
|
|
9
|
+
appSecret: "secret_top",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
expect(result.success).toBe(false);
|
|
13
|
+
if (!result.success) {
|
|
14
|
+
expect(
|
|
15
|
+
result.error.issues.some((issue) => issue.path.join(".") === "verificationToken"),
|
|
16
|
+
).toBe(true);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("accepts top-level webhook mode with verificationToken", () => {
|
|
21
|
+
const result = FeishuConfigSchema.safeParse({
|
|
22
|
+
connectionMode: "webhook",
|
|
23
|
+
verificationToken: "token_top",
|
|
24
|
+
appId: "cli_top",
|
|
25
|
+
appSecret: "secret_top",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(result.success).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects account webhook mode without verificationToken", () => {
|
|
32
|
+
const result = FeishuConfigSchema.safeParse({
|
|
33
|
+
accounts: {
|
|
34
|
+
main: {
|
|
35
|
+
connectionMode: "webhook",
|
|
36
|
+
appId: "cli_main",
|
|
37
|
+
appSecret: "secret_main",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(result.success).toBe(false);
|
|
43
|
+
if (!result.success) {
|
|
44
|
+
expect(
|
|
45
|
+
result.error.issues.some(
|
|
46
|
+
(issue) => issue.path.join(".") === "accounts.main.verificationToken",
|
|
47
|
+
),
|
|
48
|
+
).toBe(true);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("accepts account webhook mode inheriting top-level verificationToken", () => {
|
|
53
|
+
const result = FeishuConfigSchema.safeParse({
|
|
54
|
+
verificationToken: "token_top",
|
|
55
|
+
accounts: {
|
|
56
|
+
main: {
|
|
57
|
+
connectionMode: "webhook",
|
|
58
|
+
appId: "cli_main",
|
|
59
|
+
appSecret: "secret_main",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(result.success).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
package/src/config-schema.ts
CHANGED
|
@@ -127,6 +127,7 @@ export const FeishuAccountConfigSchema = z
|
|
|
127
127
|
domain: FeishuDomainSchema.optional(),
|
|
128
128
|
connectionMode: FeishuConnectionModeSchema.optional(),
|
|
129
129
|
webhookPath: z.string().optional(),
|
|
130
|
+
webhookHost: z.string().optional(),
|
|
130
131
|
webhookPort: z.number().int().positive().optional(),
|
|
131
132
|
capabilities: z.array(z.string()).optional(),
|
|
132
133
|
markdown: MarkdownConfigSchema,
|
|
@@ -162,6 +163,7 @@ export const FeishuConfigSchema = z
|
|
|
162
163
|
domain: FeishuDomainSchema.optional().default("feishu"),
|
|
163
164
|
connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
|
|
164
165
|
webhookPath: z.string().optional().default("/feishu/events"),
|
|
166
|
+
webhookHost: z.string().optional(),
|
|
165
167
|
webhookPort: z.number().int().positive().optional(),
|
|
166
168
|
capabilities: z.array(z.string()).optional(),
|
|
167
169
|
markdown: MarkdownConfigSchema,
|
|
@@ -191,6 +193,38 @@ export const FeishuConfigSchema = z
|
|
|
191
193
|
})
|
|
192
194
|
.strict()
|
|
193
195
|
.superRefine((value, ctx) => {
|
|
196
|
+
const defaultConnectionMode = value.connectionMode ?? "websocket";
|
|
197
|
+
const defaultVerificationToken = value.verificationToken?.trim();
|
|
198
|
+
if (defaultConnectionMode === "webhook" && !defaultVerificationToken) {
|
|
199
|
+
ctx.addIssue({
|
|
200
|
+
code: z.ZodIssueCode.custom,
|
|
201
|
+
path: ["verificationToken"],
|
|
202
|
+
message:
|
|
203
|
+
'channels.feishu.connectionMode="webhook" requires channels.feishu.verificationToken',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const [accountId, account] of Object.entries(value.accounts ?? {})) {
|
|
208
|
+
if (!account) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const accountConnectionMode = account.connectionMode ?? defaultConnectionMode;
|
|
212
|
+
if (accountConnectionMode !== "webhook") {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const accountVerificationToken =
|
|
216
|
+
account.verificationToken?.trim() || defaultVerificationToken;
|
|
217
|
+
if (!accountVerificationToken) {
|
|
218
|
+
ctx.addIssue({
|
|
219
|
+
code: z.ZodIssueCode.custom,
|
|
220
|
+
path: ["accounts", accountId, "verificationToken"],
|
|
221
|
+
message:
|
|
222
|
+
`channels.feishu.accounts.${accountId}.connectionMode="webhook" requires ` +
|
|
223
|
+
"a verificationToken (account-level or top-level)",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
194
228
|
if (value.dmPolicy === "open") {
|
|
195
229
|
const allowFrom = value.allowFrom ?? [];
|
|
196
230
|
const hasWildcard = allowFrom.some((entry) => String(entry).trim() === "*");
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
|
3
|
+
|
|
4
|
+
describe("normalizeFeishuExternalKey", () => {
|
|
5
|
+
it("accepts a normal feishu key and trims surrounding spaces", () => {
|
|
6
|
+
expect(normalizeFeishuExternalKey(" img_v3_01abcDEF123 ")).toBe("img_v3_01abcDEF123");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("rejects traversal and path separator patterns", () => {
|
|
10
|
+
expect(normalizeFeishuExternalKey("../etc/passwd")).toBeUndefined();
|
|
11
|
+
expect(normalizeFeishuExternalKey("a/../../b")).toBeUndefined();
|
|
12
|
+
expect(normalizeFeishuExternalKey("a\\..\\b")).toBeUndefined();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("rejects empty, non-string, and control-char values", () => {
|
|
16
|
+
expect(normalizeFeishuExternalKey(" ")).toBeUndefined();
|
|
17
|
+
expect(normalizeFeishuExternalKey(123)).toBeUndefined();
|
|
18
|
+
expect(normalizeFeishuExternalKey("abc\u0000def")).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const CONTROL_CHARS_RE = /[\u0000-\u001f\u007f]/;
|
|
2
|
+
const MAX_EXTERNAL_KEY_LENGTH = 512;
|
|
3
|
+
|
|
4
|
+
export function normalizeFeishuExternalKey(value: unknown): string | undefined {
|
|
5
|
+
if (typeof value !== "string") {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
const normalized = value.trim();
|
|
9
|
+
if (!normalized || normalized.length > MAX_EXTERNAL_KEY_LENGTH) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
if (CONTROL_CHARS_RE.test(normalized)) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
if (normalized.includes("/") || normalized.includes("\\") || normalized.includes("..")) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
return normalized;
|
|
19
|
+
}
|
package/src/media.test.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
1
4
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
5
|
|
|
3
6
|
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
|
@@ -7,7 +10,9 @@ const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
|
|
|
7
10
|
const loadWebMediaMock = vi.hoisted(() => vi.fn());
|
|
8
11
|
|
|
9
12
|
const fileCreateMock = vi.hoisted(() => vi.fn());
|
|
13
|
+
const imageGetMock = vi.hoisted(() => vi.fn());
|
|
10
14
|
const messageCreateMock = vi.hoisted(() => vi.fn());
|
|
15
|
+
const messageResourceGetMock = vi.hoisted(() => vi.fn());
|
|
11
16
|
const messageReplyMock = vi.hoisted(() => vi.fn());
|
|
12
17
|
|
|
13
18
|
vi.mock("./client.js", () => ({
|
|
@@ -31,7 +36,7 @@ vi.mock("./runtime.js", () => ({
|
|
|
31
36
|
}),
|
|
32
37
|
}));
|
|
33
38
|
|
|
34
|
-
import { sendMediaFeishu } from "./media.js";
|
|
39
|
+
import { downloadImageFeishu, downloadMessageResourceFeishu, sendMediaFeishu } from "./media.js";
|
|
35
40
|
|
|
36
41
|
describe("sendMediaFeishu msg_type routing", () => {
|
|
37
42
|
beforeEach(() => {
|
|
@@ -54,10 +59,16 @@ describe("sendMediaFeishu msg_type routing", () => {
|
|
|
54
59
|
file: {
|
|
55
60
|
create: fileCreateMock,
|
|
56
61
|
},
|
|
62
|
+
image: {
|
|
63
|
+
get: imageGetMock,
|
|
64
|
+
},
|
|
57
65
|
message: {
|
|
58
66
|
create: messageCreateMock,
|
|
59
67
|
reply: messageReplyMock,
|
|
60
68
|
},
|
|
69
|
+
messageResource: {
|
|
70
|
+
get: messageResourceGetMock,
|
|
71
|
+
},
|
|
61
72
|
},
|
|
62
73
|
});
|
|
63
74
|
|
|
@@ -82,6 +93,9 @@ describe("sendMediaFeishu msg_type routing", () => {
|
|
|
82
93
|
kind: "audio",
|
|
83
94
|
contentType: "audio/ogg",
|
|
84
95
|
});
|
|
96
|
+
|
|
97
|
+
imageGetMock.mockResolvedValue(Buffer.from("image-bytes"));
|
|
98
|
+
messageResourceGetMock.mockResolvedValue(Buffer.from("resource-bytes"));
|
|
85
99
|
});
|
|
86
100
|
|
|
87
101
|
it("uses msg_type=media for mp4", async () => {
|
|
@@ -184,4 +198,84 @@ describe("sendMediaFeishu msg_type routing", () => {
|
|
|
184
198
|
expect(messageCreateMock).not.toHaveBeenCalled();
|
|
185
199
|
expect(messageReplyMock).not.toHaveBeenCalled();
|
|
186
200
|
});
|
|
201
|
+
|
|
202
|
+
it("uses isolated temp paths for image downloads", async () => {
|
|
203
|
+
const imageKey = "img_v3_01abc123";
|
|
204
|
+
let capturedPath: string | undefined;
|
|
205
|
+
|
|
206
|
+
imageGetMock.mockResolvedValueOnce({
|
|
207
|
+
writeFile: async (tmpPath: string) => {
|
|
208
|
+
capturedPath = tmpPath;
|
|
209
|
+
await fs.writeFile(tmpPath, Buffer.from("image-data"));
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const result = await downloadImageFeishu({
|
|
214
|
+
cfg: {} as any,
|
|
215
|
+
imageKey,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(result.buffer).toEqual(Buffer.from("image-data"));
|
|
219
|
+
expect(capturedPath).toBeDefined();
|
|
220
|
+
expect(capturedPath).not.toContain(imageKey);
|
|
221
|
+
expect(capturedPath).not.toContain("..");
|
|
222
|
+
|
|
223
|
+
const tmpRoot = path.resolve(os.tmpdir());
|
|
224
|
+
const resolved = path.resolve(capturedPath as string);
|
|
225
|
+
const rel = path.relative(tmpRoot, resolved);
|
|
226
|
+
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("uses isolated temp paths for message resource downloads", async () => {
|
|
230
|
+
const fileKey = "file_v3_01abc123";
|
|
231
|
+
let capturedPath: string | undefined;
|
|
232
|
+
|
|
233
|
+
messageResourceGetMock.mockResolvedValueOnce({
|
|
234
|
+
writeFile: async (tmpPath: string) => {
|
|
235
|
+
capturedPath = tmpPath;
|
|
236
|
+
await fs.writeFile(tmpPath, Buffer.from("resource-data"));
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const result = await downloadMessageResourceFeishu({
|
|
241
|
+
cfg: {} as any,
|
|
242
|
+
messageId: "om_123",
|
|
243
|
+
fileKey,
|
|
244
|
+
type: "image",
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(result.buffer).toEqual(Buffer.from("resource-data"));
|
|
248
|
+
expect(capturedPath).toBeDefined();
|
|
249
|
+
expect(capturedPath).not.toContain(fileKey);
|
|
250
|
+
expect(capturedPath).not.toContain("..");
|
|
251
|
+
|
|
252
|
+
const tmpRoot = path.resolve(os.tmpdir());
|
|
253
|
+
const resolved = path.resolve(capturedPath as string);
|
|
254
|
+
const rel = path.relative(tmpRoot, resolved);
|
|
255
|
+
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("rejects invalid image keys before calling feishu api", async () => {
|
|
259
|
+
await expect(
|
|
260
|
+
downloadImageFeishu({
|
|
261
|
+
cfg: {} as any,
|
|
262
|
+
imageKey: "a/../../bad",
|
|
263
|
+
}),
|
|
264
|
+
).rejects.toThrow("invalid image_key");
|
|
265
|
+
|
|
266
|
+
expect(imageGetMock).not.toHaveBeenCalled();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("rejects invalid file keys before calling feishu api", async () => {
|
|
270
|
+
await expect(
|
|
271
|
+
downloadMessageResourceFeishu({
|
|
272
|
+
cfg: {} as any,
|
|
273
|
+
messageId: "om_123",
|
|
274
|
+
fileKey: "x/../../bad",
|
|
275
|
+
type: "file",
|
|
276
|
+
}),
|
|
277
|
+
).rejects.toThrow("invalid file_key");
|
|
278
|
+
|
|
279
|
+
expect(messageResourceGetMock).not.toHaveBeenCalled();
|
|
280
|
+
});
|
|
187
281
|
});
|
package/src/media.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
|
-
import os from "os";
|
|
3
2
|
import path from "path";
|
|
4
3
|
import { Readable } from "stream";
|
|
5
|
-
import type
|
|
4
|
+
import { withTempDownloadPath, type ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
6
5
|
import { resolveFeishuAccount } from "./accounts.js";
|
|
7
6
|
import { createFeishuClient } from "./client.js";
|
|
7
|
+
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
|
8
8
|
import { getFeishuRuntime } from "./runtime.js";
|
|
9
9
|
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
|
|
10
10
|
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
|
|
@@ -22,7 +22,7 @@ export type DownloadMessageResourceResult = {
|
|
|
22
22
|
|
|
23
23
|
async function readFeishuResponseBuffer(params: {
|
|
24
24
|
response: unknown;
|
|
25
|
-
|
|
25
|
+
tmpDirPrefix: string;
|
|
26
26
|
errorPrefix: string;
|
|
27
27
|
}): Promise<Buffer> {
|
|
28
28
|
const { response } = params;
|
|
@@ -53,10 +53,10 @@ async function readFeishuResponseBuffer(params: {
|
|
|
53
53
|
return Buffer.concat(chunks);
|
|
54
54
|
}
|
|
55
55
|
if (typeof responseAny.writeFile === "function") {
|
|
56
|
-
await
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
|
|
57
|
+
await responseAny.writeFile(tmpPath);
|
|
58
|
+
return await fs.promises.readFile(tmpPath);
|
|
59
|
+
});
|
|
60
60
|
}
|
|
61
61
|
if (typeof responseAny[Symbol.asyncIterator] === "function") {
|
|
62
62
|
const chunks: Buffer[] = [];
|
|
@@ -88,6 +88,10 @@ export async function downloadImageFeishu(params: {
|
|
|
88
88
|
accountId?: string;
|
|
89
89
|
}): Promise<DownloadImageResult> {
|
|
90
90
|
const { cfg, imageKey, accountId } = params;
|
|
91
|
+
const normalizedImageKey = normalizeFeishuExternalKey(imageKey);
|
|
92
|
+
if (!normalizedImageKey) {
|
|
93
|
+
throw new Error("Feishu image download failed: invalid image_key");
|
|
94
|
+
}
|
|
91
95
|
const account = resolveFeishuAccount({ cfg, accountId });
|
|
92
96
|
if (!account.configured) {
|
|
93
97
|
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
@@ -96,13 +100,12 @@ export async function downloadImageFeishu(params: {
|
|
|
96
100
|
const client = createFeishuClient(account);
|
|
97
101
|
|
|
98
102
|
const response = await client.im.image.get({
|
|
99
|
-
path: { image_key:
|
|
103
|
+
path: { image_key: normalizedImageKey },
|
|
100
104
|
});
|
|
101
105
|
|
|
102
|
-
const tmpPath = path.join(os.tmpdir(), `feishu_img_${Date.now()}_${imageKey}`);
|
|
103
106
|
const buffer = await readFeishuResponseBuffer({
|
|
104
107
|
response,
|
|
105
|
-
|
|
108
|
+
tmpDirPrefix: "openclaw-feishu-img-",
|
|
106
109
|
errorPrefix: "Feishu image download failed",
|
|
107
110
|
});
|
|
108
111
|
return { buffer };
|
|
@@ -120,6 +123,10 @@ export async function downloadMessageResourceFeishu(params: {
|
|
|
120
123
|
accountId?: string;
|
|
121
124
|
}): Promise<DownloadMessageResourceResult> {
|
|
122
125
|
const { cfg, messageId, fileKey, type, accountId } = params;
|
|
126
|
+
const normalizedFileKey = normalizeFeishuExternalKey(fileKey);
|
|
127
|
+
if (!normalizedFileKey) {
|
|
128
|
+
throw new Error("Feishu message resource download failed: invalid file_key");
|
|
129
|
+
}
|
|
123
130
|
const account = resolveFeishuAccount({ cfg, accountId });
|
|
124
131
|
if (!account.configured) {
|
|
125
132
|
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
@@ -128,14 +135,13 @@ export async function downloadMessageResourceFeishu(params: {
|
|
|
128
135
|
const client = createFeishuClient(account);
|
|
129
136
|
|
|
130
137
|
const response = await client.im.messageResource.get({
|
|
131
|
-
path: { message_id: messageId, file_key:
|
|
138
|
+
path: { message_id: messageId, file_key: normalizedFileKey },
|
|
132
139
|
params: { type },
|
|
133
140
|
});
|
|
134
141
|
|
|
135
|
-
const tmpPath = path.join(os.tmpdir(), `feishu_${Date.now()}_${fileKey}`);
|
|
136
142
|
const buffer = await readFeishuResponseBuffer({
|
|
137
143
|
response,
|
|
138
|
-
|
|
144
|
+
tmpDirPrefix: "openclaw-feishu-resource-",
|
|
139
145
|
errorPrefix: "Feishu message resource download failed",
|
|
140
146
|
});
|
|
141
147
|
return { buffer };
|
package/src/mention.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { FeishuMessageEvent } from "./bot.js";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Escape regex metacharacters so user-controlled mention fields are treated literally.
|
|
5
|
+
*/
|
|
6
|
+
export function escapeRegExp(input: string): string {
|
|
7
|
+
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
/**
|
|
4
11
|
* Mention target user info
|
|
5
12
|
*/
|
|
@@ -67,7 +74,7 @@ export function extractMessageBody(text: string, allMentionKeys: string[]): stri
|
|
|
67
74
|
|
|
68
75
|
// Remove all @ placeholders
|
|
69
76
|
for (const key of allMentionKeys) {
|
|
70
|
-
result = result.replace(new RegExp(key
|
|
77
|
+
result = result.replace(new RegExp(escapeRegExp(key), "g"), "");
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
return result.replace(/\s+/g, " ").trim();
|
package/src/monitor.ts
CHANGED
|
@@ -25,6 +25,52 @@ const httpServers = new Map<string, http.Server>();
|
|
|
25
25
|
const botOpenIds = new Map<string, string>();
|
|
26
26
|
const FEISHU_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
|
|
27
27
|
const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
|
|
28
|
+
const FEISHU_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
29
|
+
const FEISHU_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120;
|
|
30
|
+
const FEISHU_WEBHOOK_COUNTER_LOG_EVERY = 25;
|
|
31
|
+
const feishuWebhookRateLimits = new Map<string, { count: number; windowStartMs: number }>();
|
|
32
|
+
const feishuWebhookStatusCounters = new Map<string, number>();
|
|
33
|
+
|
|
34
|
+
function isJsonContentType(value: string | string[] | undefined): boolean {
|
|
35
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
36
|
+
if (!first) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const mediaType = first.split(";", 1)[0]?.trim().toLowerCase();
|
|
40
|
+
return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isWebhookRateLimited(key: string, nowMs: number): boolean {
|
|
44
|
+
const state = feishuWebhookRateLimits.get(key);
|
|
45
|
+
if (!state || nowMs - state.windowStartMs >= FEISHU_WEBHOOK_RATE_LIMIT_WINDOW_MS) {
|
|
46
|
+
feishuWebhookRateLimits.set(key, { count: 1, windowStartMs: nowMs });
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
state.count += 1;
|
|
51
|
+
if (state.count > FEISHU_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function recordWebhookStatus(
|
|
58
|
+
runtime: RuntimeEnv | undefined,
|
|
59
|
+
accountId: string,
|
|
60
|
+
path: string,
|
|
61
|
+
statusCode: number,
|
|
62
|
+
): void {
|
|
63
|
+
if (![400, 401, 408, 413, 415, 429].includes(statusCode)) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const key = `${accountId}:${path}:${statusCode}`;
|
|
67
|
+
const next = (feishuWebhookStatusCounters.get(key) ?? 0) + 1;
|
|
68
|
+
feishuWebhookStatusCounters.set(key, next);
|
|
69
|
+
if (next === 1 || next % FEISHU_WEBHOOK_COUNTER_LOG_EVERY === 0) {
|
|
70
|
+
const log = runtime?.log ?? console.log;
|
|
71
|
+
log(`feishu[${accountId}]: webhook anomaly path=${path} status=${statusCode} count=${next}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
28
74
|
|
|
29
75
|
async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string | undefined> {
|
|
30
76
|
try {
|
|
@@ -120,6 +166,9 @@ async function monitorSingleAccount(params: MonitorAccountParams): Promise<void>
|
|
|
120
166
|
log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
|
|
121
167
|
|
|
122
168
|
const connectionMode = account.config.connectionMode ?? "websocket";
|
|
169
|
+
if (connectionMode === "webhook" && !account.verificationToken?.trim()) {
|
|
170
|
+
throw new Error(`Feishu account "${accountId}" webhook mode requires verificationToken`);
|
|
171
|
+
}
|
|
123
172
|
const eventDispatcher = createEventDispatcher(account);
|
|
124
173
|
const chatHistories = new Map<string, HistoryEntry[]>();
|
|
125
174
|
|
|
@@ -200,12 +249,30 @@ async function monitorWebhook({
|
|
|
200
249
|
|
|
201
250
|
const port = account.config.webhookPort ?? 3000;
|
|
202
251
|
const path = account.config.webhookPath ?? "/feishu/events";
|
|
252
|
+
const host = account.config.webhookHost ?? "127.0.0.1";
|
|
203
253
|
|
|
204
|
-
log(`feishu[${accountId}]: starting Webhook server on
|
|
254
|
+
log(`feishu[${accountId}]: starting Webhook server on ${host}:${port}, path ${path}...`);
|
|
205
255
|
|
|
206
256
|
const server = http.createServer();
|
|
207
257
|
const webhookHandler = Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true });
|
|
208
258
|
server.on("request", (req, res) => {
|
|
259
|
+
res.on("finish", () => {
|
|
260
|
+
recordWebhookStatus(runtime, accountId, path, res.statusCode);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const rateLimitKey = `${accountId}:${path}:${req.socket.remoteAddress ?? "unknown"}`;
|
|
264
|
+
if (isWebhookRateLimited(rateLimitKey, Date.now())) {
|
|
265
|
+
res.statusCode = 429;
|
|
266
|
+
res.end("Too Many Requests");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (req.method === "POST" && !isJsonContentType(req.headers["content-type"])) {
|
|
271
|
+
res.statusCode = 415;
|
|
272
|
+
res.end("Unsupported Media Type");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
209
276
|
const guard = installRequestBodyLimitGuard(req, res, {
|
|
210
277
|
maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES,
|
|
211
278
|
timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
|
|
@@ -247,8 +314,8 @@ async function monitorWebhook({
|
|
|
247
314
|
|
|
248
315
|
abortSignal?.addEventListener("abort", handleAbort, { once: true });
|
|
249
316
|
|
|
250
|
-
server.listen(port, () => {
|
|
251
|
-
log(`feishu[${accountId}]: Webhook server listening on
|
|
317
|
+
server.listen(port, host, () => {
|
|
318
|
+
log(`feishu[${accountId}]: Webhook server listening on ${host}:${port}`);
|
|
252
319
|
});
|
|
253
320
|
|
|
254
321
|
server.on("error", (err) => {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import type { AddressInfo } from "node:net";
|
|
3
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
|
7
|
+
|
|
8
|
+
vi.mock("@larksuiteoapi/node-sdk", () => ({
|
|
9
|
+
adaptDefault: vi.fn(
|
|
10
|
+
() => (_req: unknown, res: { statusCode?: number; end: (s: string) => void }) => {
|
|
11
|
+
res.statusCode = 200;
|
|
12
|
+
res.end("ok");
|
|
13
|
+
},
|
|
14
|
+
),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("./probe.js", () => ({
|
|
18
|
+
probeFeishu: probeFeishuMock,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("./client.js", () => ({
|
|
22
|
+
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
|
|
23
|
+
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
|
|
27
|
+
|
|
28
|
+
async function getFreePort(): Promise<number> {
|
|
29
|
+
const server = createServer();
|
|
30
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
31
|
+
const address = server.address() as AddressInfo | null;
|
|
32
|
+
if (!address) {
|
|
33
|
+
throw new Error("missing server address");
|
|
34
|
+
}
|
|
35
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
36
|
+
return address.port;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function waitUntilServerReady(url: string): Promise<void> {
|
|
40
|
+
for (let i = 0; i < 50; i += 1) {
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetch(url, { method: "GET" });
|
|
43
|
+
if (response.status >= 200 && response.status < 500) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// retry
|
|
48
|
+
}
|
|
49
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
50
|
+
}
|
|
51
|
+
throw new Error(`server did not start: ${url}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildConfig(params: {
|
|
55
|
+
accountId: string;
|
|
56
|
+
path: string;
|
|
57
|
+
port: number;
|
|
58
|
+
verificationToken?: string;
|
|
59
|
+
}): ClawdbotConfig {
|
|
60
|
+
return {
|
|
61
|
+
channels: {
|
|
62
|
+
feishu: {
|
|
63
|
+
enabled: true,
|
|
64
|
+
accounts: {
|
|
65
|
+
[params.accountId]: {
|
|
66
|
+
enabled: true,
|
|
67
|
+
appId: "cli_test",
|
|
68
|
+
appSecret: "secret_test",
|
|
69
|
+
connectionMode: "webhook",
|
|
70
|
+
webhookHost: "127.0.0.1",
|
|
71
|
+
webhookPort: params.port,
|
|
72
|
+
webhookPath: params.path,
|
|
73
|
+
verificationToken: params.verificationToken,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
} as ClawdbotConfig;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
stopFeishuMonitor();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("Feishu webhook security hardening", () => {
|
|
86
|
+
it("rejects webhook mode without verificationToken", async () => {
|
|
87
|
+
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
|
|
88
|
+
|
|
89
|
+
const cfg = buildConfig({
|
|
90
|
+
accountId: "missing-token",
|
|
91
|
+
path: "/hook-missing-token",
|
|
92
|
+
port: await getFreePort(),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await expect(monitorFeishuProvider({ config: cfg })).rejects.toThrow(
|
|
96
|
+
/requires verificationToken/i,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns 415 for POST requests without json content type", async () => {
|
|
101
|
+
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
|
|
102
|
+
const port = await getFreePort();
|
|
103
|
+
const path = "/hook-content-type";
|
|
104
|
+
const cfg = buildConfig({
|
|
105
|
+
accountId: "content-type",
|
|
106
|
+
path,
|
|
107
|
+
port,
|
|
108
|
+
verificationToken: "verify_token",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const abortController = new AbortController();
|
|
112
|
+
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
|
113
|
+
const monitorPromise = monitorFeishuProvider({
|
|
114
|
+
config: cfg,
|
|
115
|
+
runtime,
|
|
116
|
+
abortSignal: abortController.signal,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await waitUntilServerReady(`http://127.0.0.1:${port}${path}`);
|
|
120
|
+
|
|
121
|
+
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "content-type": "text/plain" },
|
|
124
|
+
body: "{}",
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(response.status).toBe(415);
|
|
128
|
+
expect(await response.text()).toBe("Unsupported Media Type");
|
|
129
|
+
|
|
130
|
+
abortController.abort();
|
|
131
|
+
await monitorPromise;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("rate limits webhook burst traffic with 429", async () => {
|
|
135
|
+
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
|
|
136
|
+
const port = await getFreePort();
|
|
137
|
+
const path = "/hook-rate-limit";
|
|
138
|
+
const cfg = buildConfig({
|
|
139
|
+
accountId: "rate-limit",
|
|
140
|
+
path,
|
|
141
|
+
port,
|
|
142
|
+
verificationToken: "verify_token",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const abortController = new AbortController();
|
|
146
|
+
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
|
147
|
+
const monitorPromise = monitorFeishuProvider({
|
|
148
|
+
config: cfg,
|
|
149
|
+
runtime,
|
|
150
|
+
abortSignal: abortController.signal,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await waitUntilServerReady(`http://127.0.0.1:${port}${path}`);
|
|
154
|
+
|
|
155
|
+
let saw429 = false;
|
|
156
|
+
for (let i = 0; i < 130; i += 1) {
|
|
157
|
+
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: { "content-type": "text/plain" },
|
|
160
|
+
body: "{}",
|
|
161
|
+
});
|
|
162
|
+
if (response.status === 429) {
|
|
163
|
+
saw429 = true;
|
|
164
|
+
expect(await response.text()).toBe("Too Many Requests");
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
expect(saw429).toBe(true);
|
|
170
|
+
|
|
171
|
+
abortController.abort();
|
|
172
|
+
await monitorPromise;
|
|
173
|
+
});
|
|
174
|
+
});
|