@openclaw/feishu 2026.3.11 → 2026.3.13
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/accounts.test.ts +40 -16
- package/src/accounts.ts +5 -1
- package/src/bot.ts +20 -11
- package/src/channel.ts +2 -2
- package/src/config-schema.test.ts +67 -16
- package/src/config-schema.ts +30 -9
- package/src/dedup.ts +103 -0
- package/src/media.test.ts +38 -61
- package/src/media.ts +64 -76
- package/src/monitor.account.ts +39 -22
- package/src/monitor.reaction.test.ts +134 -65
- package/src/monitor.startup.test.ts +16 -30
- package/src/monitor.transport.ts +104 -6
- package/src/monitor.webhook-e2e.test.ts +214 -0
- package/src/monitor.webhook-security.test.ts +23 -92
- package/src/monitor.webhook.test-helpers.ts +98 -0
- package/src/onboarding.ts +31 -0
- package/src/outbound.test.ts +11 -16
- package/src/probe.test.ts +112 -113
- package/src/reactions.ts +20 -27
- package/src/reply-dispatcher.test.ts +65 -143
- package/src/reply-dispatcher.ts +37 -40
- package/src/send.reply-fallback.test.ts +50 -40
- package/src/send.ts +95 -91
- package/src/types.ts +14 -0
|
@@ -3,33 +3,19 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
3
3
|
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
|
|
4
4
|
|
|
5
5
|
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
|
6
|
-
const feishuClientMockModule = vi.hoisted(() => ({
|
|
7
|
-
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
|
|
8
|
-
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
|
|
9
|
-
}));
|
|
10
|
-
const feishuRuntimeMockModule = vi.hoisted(() => ({
|
|
11
|
-
getFeishuRuntime: () => ({
|
|
12
|
-
channel: {
|
|
13
|
-
debounce: {
|
|
14
|
-
resolveInboundDebounceMs: () => 0,
|
|
15
|
-
createInboundDebouncer: () => ({
|
|
16
|
-
enqueue: async () => {},
|
|
17
|
-
flushKey: async () => {},
|
|
18
|
-
}),
|
|
19
|
-
},
|
|
20
|
-
text: {
|
|
21
|
-
hasControlCommand: () => false,
|
|
22
|
-
},
|
|
23
|
-
},
|
|
24
|
-
}),
|
|
25
|
-
}));
|
|
26
6
|
|
|
27
7
|
vi.mock("./probe.js", () => ({
|
|
28
8
|
probeFeishu: probeFeishuMock,
|
|
29
9
|
}));
|
|
30
10
|
|
|
31
|
-
vi.mock("./client.js", () =>
|
|
32
|
-
|
|
11
|
+
vi.mock("./client.js", async () => {
|
|
12
|
+
const { createFeishuClientMockModule } = await import("./monitor.test-mocks.js");
|
|
13
|
+
return createFeishuClientMockModule();
|
|
14
|
+
});
|
|
15
|
+
vi.mock("./runtime.js", async () => {
|
|
16
|
+
const { createFeishuRuntimeMockModule } = await import("./monitor.test-mocks.js");
|
|
17
|
+
return createFeishuRuntimeMockModule();
|
|
18
|
+
});
|
|
33
19
|
|
|
34
20
|
function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig {
|
|
35
21
|
return {
|
|
@@ -52,6 +38,12 @@ function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig
|
|
|
52
38
|
} as ClawdbotConfig;
|
|
53
39
|
}
|
|
54
40
|
|
|
41
|
+
async function waitForStartedAccount(started: string[], accountId: string) {
|
|
42
|
+
for (let i = 0; i < 10 && !started.includes(accountId); i += 1) {
|
|
43
|
+
await Promise.resolve();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
55
47
|
afterEach(() => {
|
|
56
48
|
stopFeishuMonitor();
|
|
57
49
|
});
|
|
@@ -116,10 +108,7 @@ describe("Feishu monitor startup preflight", () => {
|
|
|
116
108
|
});
|
|
117
109
|
|
|
118
110
|
try {
|
|
119
|
-
|
|
120
|
-
await Promise.resolve();
|
|
121
|
-
}
|
|
122
|
-
|
|
111
|
+
await waitForStartedAccount(started, "beta");
|
|
123
112
|
expect(started).toEqual(["alpha", "beta"]);
|
|
124
113
|
expect(started.filter((accountId) => accountId === "alpha")).toHaveLength(1);
|
|
125
114
|
} finally {
|
|
@@ -153,10 +142,7 @@ describe("Feishu monitor startup preflight", () => {
|
|
|
153
142
|
});
|
|
154
143
|
|
|
155
144
|
try {
|
|
156
|
-
|
|
157
|
-
await Promise.resolve();
|
|
158
|
-
}
|
|
159
|
-
|
|
145
|
+
await waitForStartedAccount(started, "beta");
|
|
160
146
|
expect(started).toEqual(["alpha", "beta"]);
|
|
161
147
|
expect(runtime.error).toHaveBeenCalledWith(
|
|
162
148
|
expect.stringContaining("bot info probe timed out"),
|
package/src/monitor.transport.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as http from "http";
|
|
2
|
+
import crypto from "node:crypto";
|
|
2
3
|
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
3
4
|
import {
|
|
4
5
|
applyBasicWebhookRequestGuards,
|
|
6
|
+
readJsonBodyWithLimit,
|
|
5
7
|
type RuntimeEnv,
|
|
6
8
|
installRequestBodyLimitGuard,
|
|
7
9
|
} from "openclaw/plugin-sdk/feishu";
|
|
@@ -26,6 +28,50 @@ export type MonitorTransportParams = {
|
|
|
26
28
|
eventDispatcher: Lark.EventDispatcher;
|
|
27
29
|
};
|
|
28
30
|
|
|
31
|
+
function isFeishuWebhookPayload(value: unknown): value is Record<string, unknown> {
|
|
32
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildFeishuWebhookEnvelope(
|
|
36
|
+
req: http.IncomingMessage,
|
|
37
|
+
payload: Record<string, unknown>,
|
|
38
|
+
): Record<string, unknown> {
|
|
39
|
+
return Object.assign(Object.create({ headers: req.headers }), payload) as Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isFeishuWebhookSignatureValid(params: {
|
|
43
|
+
headers: http.IncomingHttpHeaders;
|
|
44
|
+
payload: Record<string, unknown>;
|
|
45
|
+
encryptKey?: string;
|
|
46
|
+
}): boolean {
|
|
47
|
+
const encryptKey = params.encryptKey?.trim();
|
|
48
|
+
if (!encryptKey) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const timestampHeader = params.headers["x-lark-request-timestamp"];
|
|
53
|
+
const nonceHeader = params.headers["x-lark-request-nonce"];
|
|
54
|
+
const signatureHeader = params.headers["x-lark-signature"];
|
|
55
|
+
const timestamp = Array.isArray(timestampHeader) ? timestampHeader[0] : timestampHeader;
|
|
56
|
+
const nonce = Array.isArray(nonceHeader) ? nonceHeader[0] : nonceHeader;
|
|
57
|
+
const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader;
|
|
58
|
+
if (!timestamp || !nonce || !signature) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const computedSignature = crypto
|
|
63
|
+
.createHash("sha256")
|
|
64
|
+
.update(timestamp + nonce + encryptKey + JSON.stringify(params.payload))
|
|
65
|
+
.digest("hex");
|
|
66
|
+
return computedSignature === signature;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function respondText(res: http.ServerResponse, statusCode: number, body: string): void {
|
|
70
|
+
res.statusCode = statusCode;
|
|
71
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
72
|
+
res.end(body);
|
|
73
|
+
}
|
|
74
|
+
|
|
29
75
|
export async function monitorWebSocket({
|
|
30
76
|
account,
|
|
31
77
|
accountId,
|
|
@@ -88,7 +134,6 @@ export async function monitorWebhook({
|
|
|
88
134
|
log(`feishu[${accountId}]: starting Webhook server on ${host}:${port}, path ${path}...`);
|
|
89
135
|
|
|
90
136
|
const server = http.createServer();
|
|
91
|
-
const webhookHandler = Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true });
|
|
92
137
|
|
|
93
138
|
server.on("request", (req, res) => {
|
|
94
139
|
res.on("finish", () => {
|
|
@@ -118,15 +163,68 @@ export async function monitorWebhook({
|
|
|
118
163
|
return;
|
|
119
164
|
}
|
|
120
165
|
|
|
121
|
-
void
|
|
122
|
-
|
|
166
|
+
void (async () => {
|
|
167
|
+
try {
|
|
168
|
+
const bodyResult = await readJsonBodyWithLimit(req, {
|
|
169
|
+
maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES,
|
|
170
|
+
timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
|
|
171
|
+
});
|
|
172
|
+
if (guard.isTripped() || res.writableEnded) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (!bodyResult.ok) {
|
|
176
|
+
if (bodyResult.code === "INVALID_JSON") {
|
|
177
|
+
respondText(res, 400, "Invalid JSON");
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (!isFeishuWebhookPayload(bodyResult.value)) {
|
|
182
|
+
respondText(res, 400, "Invalid JSON");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Lark's default adapter drops invalid signatures as an empty 200. Reject here instead.
|
|
187
|
+
if (
|
|
188
|
+
!isFeishuWebhookSignatureValid({
|
|
189
|
+
headers: req.headers,
|
|
190
|
+
payload: bodyResult.value,
|
|
191
|
+
encryptKey: account.encryptKey,
|
|
192
|
+
})
|
|
193
|
+
) {
|
|
194
|
+
respondText(res, 401, "Invalid signature");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const { isChallenge, challenge } = Lark.generateChallenge(bodyResult.value, {
|
|
199
|
+
encryptKey: account.encryptKey ?? "",
|
|
200
|
+
});
|
|
201
|
+
if (isChallenge) {
|
|
202
|
+
res.statusCode = 200;
|
|
203
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
204
|
+
res.end(JSON.stringify(challenge));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const value = await eventDispatcher.invoke(
|
|
209
|
+
buildFeishuWebhookEnvelope(req, bodyResult.value),
|
|
210
|
+
{ needCheck: false },
|
|
211
|
+
);
|
|
212
|
+
if (!res.headersSent) {
|
|
213
|
+
res.statusCode = 200;
|
|
214
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
215
|
+
res.end(JSON.stringify(value));
|
|
216
|
+
}
|
|
217
|
+
} catch (err) {
|
|
123
218
|
if (!guard.isTripped()) {
|
|
124
219
|
error(`feishu[${accountId}]: webhook handler error: ${String(err)}`);
|
|
220
|
+
if (!res.headersSent) {
|
|
221
|
+
respondText(res, 500, "Internal Server Error");
|
|
222
|
+
}
|
|
125
223
|
}
|
|
126
|
-
}
|
|
127
|
-
.finally(() => {
|
|
224
|
+
} finally {
|
|
128
225
|
guard.dispose();
|
|
129
|
-
}
|
|
226
|
+
}
|
|
227
|
+
})();
|
|
130
228
|
});
|
|
131
229
|
|
|
132
230
|
httpServers.set(accountId, server);
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createFeishuRuntimeMockModule } from "./monitor.test-mocks.js";
|
|
4
|
+
import { withRunningWebhookMonitor } from "./monitor.webhook.test-helpers.js";
|
|
5
|
+
|
|
6
|
+
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
|
7
|
+
|
|
8
|
+
vi.mock("./probe.js", () => ({
|
|
9
|
+
probeFeishu: probeFeishuMock,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("./client.js", async () => {
|
|
13
|
+
const actual = await vi.importActual<typeof import("./client.js")>("./client.js");
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
vi.mock("./runtime.js", () => createFeishuRuntimeMockModule());
|
|
21
|
+
|
|
22
|
+
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
|
|
23
|
+
|
|
24
|
+
function signFeishuPayload(params: {
|
|
25
|
+
encryptKey: string;
|
|
26
|
+
payload: Record<string, unknown>;
|
|
27
|
+
timestamp?: string;
|
|
28
|
+
nonce?: string;
|
|
29
|
+
}): Record<string, string> {
|
|
30
|
+
const timestamp = params.timestamp ?? "1711111111";
|
|
31
|
+
const nonce = params.nonce ?? "nonce-test";
|
|
32
|
+
const signature = crypto
|
|
33
|
+
.createHash("sha256")
|
|
34
|
+
.update(timestamp + nonce + params.encryptKey + JSON.stringify(params.payload))
|
|
35
|
+
.digest("hex");
|
|
36
|
+
return {
|
|
37
|
+
"content-type": "application/json",
|
|
38
|
+
"x-lark-request-timestamp": timestamp,
|
|
39
|
+
"x-lark-request-nonce": nonce,
|
|
40
|
+
"x-lark-signature": signature,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function encryptFeishuPayload(encryptKey: string, payload: Record<string, unknown>): string {
|
|
45
|
+
const iv = crypto.randomBytes(16);
|
|
46
|
+
const key = crypto.createHash("sha256").update(encryptKey).digest();
|
|
47
|
+
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
|
48
|
+
const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
|
|
49
|
+
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
50
|
+
return Buffer.concat([iv, encrypted]).toString("base64");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function postSignedPayload(url: string, payload: Record<string, unknown>) {
|
|
54
|
+
return await fetch(url, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }),
|
|
57
|
+
body: JSON.stringify(payload),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
stopFeishuMonitor();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("Feishu webhook signed-request e2e", () => {
|
|
66
|
+
it("rejects invalid signatures with 401 instead of empty 200", async () => {
|
|
67
|
+
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
|
|
68
|
+
|
|
69
|
+
await withRunningWebhookMonitor(
|
|
70
|
+
{
|
|
71
|
+
accountId: "invalid-signature",
|
|
72
|
+
path: "/hook-e2e-invalid-signature",
|
|
73
|
+
verificationToken: "verify_token",
|
|
74
|
+
encryptKey: "encrypt_key",
|
|
75
|
+
},
|
|
76
|
+
monitorFeishuProvider,
|
|
77
|
+
async (url) => {
|
|
78
|
+
const payload = { type: "url_verification", challenge: "challenge-token" };
|
|
79
|
+
const response = await fetch(url, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: {
|
|
82
|
+
...signFeishuPayload({ encryptKey: "wrong_key", payload }),
|
|
83
|
+
},
|
|
84
|
+
body: JSON.stringify(payload),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(response.status).toBe(401);
|
|
88
|
+
expect(await response.text()).toBe("Invalid signature");
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("rejects missing signature headers with 401", async () => {
|
|
94
|
+
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
|
|
95
|
+
|
|
96
|
+
await withRunningWebhookMonitor(
|
|
97
|
+
{
|
|
98
|
+
accountId: "missing-signature",
|
|
99
|
+
path: "/hook-e2e-missing-signature",
|
|
100
|
+
verificationToken: "verify_token",
|
|
101
|
+
encryptKey: "encrypt_key",
|
|
102
|
+
},
|
|
103
|
+
monitorFeishuProvider,
|
|
104
|
+
async (url) => {
|
|
105
|
+
const response = await fetch(url, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: { "content-type": "application/json" },
|
|
108
|
+
body: JSON.stringify({ type: "url_verification", challenge: "challenge-token" }),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(response.status).toBe(401);
|
|
112
|
+
expect(await response.text()).toBe("Invalid signature");
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns 400 for invalid json before invoking the sdk", async () => {
|
|
118
|
+
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
|
|
119
|
+
|
|
120
|
+
await withRunningWebhookMonitor(
|
|
121
|
+
{
|
|
122
|
+
accountId: "invalid-json",
|
|
123
|
+
path: "/hook-e2e-invalid-json",
|
|
124
|
+
verificationToken: "verify_token",
|
|
125
|
+
encryptKey: "encrypt_key",
|
|
126
|
+
},
|
|
127
|
+
monitorFeishuProvider,
|
|
128
|
+
async (url) => {
|
|
129
|
+
const response = await fetch(url, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: { "content-type": "application/json" },
|
|
132
|
+
body: "{not-json",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(response.status).toBe(400);
|
|
136
|
+
expect(await response.text()).toBe("Invalid JSON");
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("accepts signed plaintext url_verification challenges end-to-end", async () => {
|
|
142
|
+
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
|
|
143
|
+
|
|
144
|
+
await withRunningWebhookMonitor(
|
|
145
|
+
{
|
|
146
|
+
accountId: "signed-challenge",
|
|
147
|
+
path: "/hook-e2e-signed-challenge",
|
|
148
|
+
verificationToken: "verify_token",
|
|
149
|
+
encryptKey: "encrypt_key",
|
|
150
|
+
},
|
|
151
|
+
monitorFeishuProvider,
|
|
152
|
+
async (url) => {
|
|
153
|
+
const payload = { type: "url_verification", challenge: "challenge-token" };
|
|
154
|
+
const response = await postSignedPayload(url, payload);
|
|
155
|
+
|
|
156
|
+
expect(response.status).toBe(200);
|
|
157
|
+
await expect(response.json()).resolves.toEqual({ challenge: "challenge-token" });
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("accepts signed non-challenge events and reaches the dispatcher", async () => {
|
|
163
|
+
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
|
|
164
|
+
|
|
165
|
+
await withRunningWebhookMonitor(
|
|
166
|
+
{
|
|
167
|
+
accountId: "signed-dispatch",
|
|
168
|
+
path: "/hook-e2e-signed-dispatch",
|
|
169
|
+
verificationToken: "verify_token",
|
|
170
|
+
encryptKey: "encrypt_key",
|
|
171
|
+
},
|
|
172
|
+
monitorFeishuProvider,
|
|
173
|
+
async (url) => {
|
|
174
|
+
const payload = {
|
|
175
|
+
schema: "2.0",
|
|
176
|
+
header: { event_type: "unknown.event" },
|
|
177
|
+
event: {},
|
|
178
|
+
};
|
|
179
|
+
const response = await postSignedPayload(url, payload);
|
|
180
|
+
|
|
181
|
+
expect(response.status).toBe(200);
|
|
182
|
+
expect(await response.text()).toContain("no unknown.event event handle");
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("accepts signed encrypted url_verification challenges end-to-end", async () => {
|
|
188
|
+
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
|
|
189
|
+
|
|
190
|
+
await withRunningWebhookMonitor(
|
|
191
|
+
{
|
|
192
|
+
accountId: "encrypted-challenge",
|
|
193
|
+
path: "/hook-e2e-encrypted-challenge",
|
|
194
|
+
verificationToken: "verify_token",
|
|
195
|
+
encryptKey: "encrypt_key",
|
|
196
|
+
},
|
|
197
|
+
monitorFeishuProvider,
|
|
198
|
+
async (url) => {
|
|
199
|
+
const payload = {
|
|
200
|
+
encrypt: encryptFeishuPayload("encrypt_key", {
|
|
201
|
+
type: "url_verification",
|
|
202
|
+
challenge: "encrypted-challenge-token",
|
|
203
|
+
}),
|
|
204
|
+
};
|
|
205
|
+
const response = await postSignedPayload(url, payload);
|
|
206
|
+
|
|
207
|
+
expect(response.status).toBe(200);
|
|
208
|
+
await expect(response.json()).resolves.toEqual({
|
|
209
|
+
challenge: "encrypted-challenge-token",
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { createServer } from "node:http";
|
|
2
|
-
import type { AddressInfo } from "node:net";
|
|
3
|
-
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
|
4
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
2
|
import {
|
|
6
3
|
createFeishuClientMockModule,
|
|
7
4
|
createFeishuRuntimeMockModule,
|
|
8
5
|
} from "./monitor.test-mocks.js";
|
|
6
|
+
import {
|
|
7
|
+
buildWebhookConfig,
|
|
8
|
+
getFreePort,
|
|
9
|
+
withRunningWebhookMonitor,
|
|
10
|
+
} from "./monitor.webhook.test-helpers.js";
|
|
9
11
|
|
|
10
12
|
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
|
11
13
|
|
|
@@ -33,94 +35,6 @@ import {
|
|
|
33
35
|
stopFeishuMonitor,
|
|
34
36
|
} from "./monitor.js";
|
|
35
37
|
|
|
36
|
-
async function getFreePort(): Promise<number> {
|
|
37
|
-
const server = createServer();
|
|
38
|
-
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
39
|
-
const address = server.address() as AddressInfo | null;
|
|
40
|
-
if (!address) {
|
|
41
|
-
throw new Error("missing server address");
|
|
42
|
-
}
|
|
43
|
-
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
44
|
-
return address.port;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function waitUntilServerReady(url: string): Promise<void> {
|
|
48
|
-
for (let i = 0; i < 50; i += 1) {
|
|
49
|
-
try {
|
|
50
|
-
const response = await fetch(url, { method: "GET" });
|
|
51
|
-
if (response.status >= 200 && response.status < 500) {
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
} catch {
|
|
55
|
-
// retry
|
|
56
|
-
}
|
|
57
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
58
|
-
}
|
|
59
|
-
throw new Error(`server did not start: ${url}`);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function buildConfig(params: {
|
|
63
|
-
accountId: string;
|
|
64
|
-
path: string;
|
|
65
|
-
port: number;
|
|
66
|
-
verificationToken?: string;
|
|
67
|
-
}): ClawdbotConfig {
|
|
68
|
-
return {
|
|
69
|
-
channels: {
|
|
70
|
-
feishu: {
|
|
71
|
-
enabled: true,
|
|
72
|
-
accounts: {
|
|
73
|
-
[params.accountId]: {
|
|
74
|
-
enabled: true,
|
|
75
|
-
appId: "cli_test",
|
|
76
|
-
appSecret: "secret_test", // pragma: allowlist secret
|
|
77
|
-
connectionMode: "webhook",
|
|
78
|
-
webhookHost: "127.0.0.1",
|
|
79
|
-
webhookPort: params.port,
|
|
80
|
-
webhookPath: params.path,
|
|
81
|
-
verificationToken: params.verificationToken,
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
} as ClawdbotConfig;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async function withRunningWebhookMonitor(
|
|
90
|
-
params: {
|
|
91
|
-
accountId: string;
|
|
92
|
-
path: string;
|
|
93
|
-
verificationToken: string;
|
|
94
|
-
},
|
|
95
|
-
run: (url: string) => Promise<void>,
|
|
96
|
-
) {
|
|
97
|
-
const port = await getFreePort();
|
|
98
|
-
const cfg = buildConfig({
|
|
99
|
-
accountId: params.accountId,
|
|
100
|
-
path: params.path,
|
|
101
|
-
port,
|
|
102
|
-
verificationToken: params.verificationToken,
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
const abortController = new AbortController();
|
|
106
|
-
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
|
107
|
-
const monitorPromise = monitorFeishuProvider({
|
|
108
|
-
config: cfg,
|
|
109
|
-
runtime,
|
|
110
|
-
abortSignal: abortController.signal,
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const url = `http://127.0.0.1:${port}${params.path}`;
|
|
114
|
-
await waitUntilServerReady(url);
|
|
115
|
-
|
|
116
|
-
try {
|
|
117
|
-
await run(url);
|
|
118
|
-
} finally {
|
|
119
|
-
abortController.abort();
|
|
120
|
-
await monitorPromise;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
38
|
afterEach(() => {
|
|
125
39
|
clearFeishuWebhookRateLimitStateForTest();
|
|
126
40
|
stopFeishuMonitor();
|
|
@@ -130,7 +44,7 @@ describe("Feishu webhook security hardening", () => {
|
|
|
130
44
|
it("rejects webhook mode without verificationToken", async () => {
|
|
131
45
|
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
|
|
132
46
|
|
|
133
|
-
const cfg =
|
|
47
|
+
const cfg = buildWebhookConfig({
|
|
134
48
|
accountId: "missing-token",
|
|
135
49
|
path: "/hook-missing-token",
|
|
136
50
|
port: await getFreePort(),
|
|
@@ -141,6 +55,19 @@ describe("Feishu webhook security hardening", () => {
|
|
|
141
55
|
);
|
|
142
56
|
});
|
|
143
57
|
|
|
58
|
+
it("rejects webhook mode without encryptKey", async () => {
|
|
59
|
+
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
|
|
60
|
+
|
|
61
|
+
const cfg = buildWebhookConfig({
|
|
62
|
+
accountId: "missing-encrypt-key",
|
|
63
|
+
path: "/hook-missing-encrypt",
|
|
64
|
+
port: await getFreePort(),
|
|
65
|
+
verificationToken: "verify_token",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await expect(monitorFeishuProvider({ config: cfg })).rejects.toThrow(/requires encryptKey/i);
|
|
69
|
+
});
|
|
70
|
+
|
|
144
71
|
it("returns 415 for POST requests without json content type", async () => {
|
|
145
72
|
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
|
|
146
73
|
await withRunningWebhookMonitor(
|
|
@@ -148,7 +75,9 @@ describe("Feishu webhook security hardening", () => {
|
|
|
148
75
|
accountId: "content-type",
|
|
149
76
|
path: "/hook-content-type",
|
|
150
77
|
verificationToken: "verify_token",
|
|
78
|
+
encryptKey: "encrypt_key",
|
|
151
79
|
},
|
|
80
|
+
monitorFeishuProvider,
|
|
152
81
|
async (url) => {
|
|
153
82
|
const response = await fetch(url, {
|
|
154
83
|
method: "POST",
|
|
@@ -169,7 +98,9 @@ describe("Feishu webhook security hardening", () => {
|
|
|
169
98
|
accountId: "rate-limit",
|
|
170
99
|
path: "/hook-rate-limit",
|
|
171
100
|
verificationToken: "verify_token",
|
|
101
|
+
encryptKey: "encrypt_key",
|
|
172
102
|
},
|
|
103
|
+
monitorFeishuProvider,
|
|
173
104
|
async (url) => {
|
|
174
105
|
let saw429 = false;
|
|
175
106
|
for (let i = 0; i < 130; i += 1) {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import type { AddressInfo } from "node:net";
|
|
3
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
|
4
|
+
import { vi } from "vitest";
|
|
5
|
+
import type { monitorFeishuProvider } from "./monitor.js";
|
|
6
|
+
|
|
7
|
+
export async function getFreePort(): Promise<number> {
|
|
8
|
+
const server = createServer();
|
|
9
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
10
|
+
const address = server.address() as AddressInfo | null;
|
|
11
|
+
if (!address) {
|
|
12
|
+
throw new Error("missing server address");
|
|
13
|
+
}
|
|
14
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
15
|
+
return address.port;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function waitUntilServerReady(url: string): Promise<void> {
|
|
19
|
+
for (let i = 0; i < 50; i += 1) {
|
|
20
|
+
try {
|
|
21
|
+
const response = await fetch(url, { method: "GET" });
|
|
22
|
+
if (response.status >= 200 && response.status < 500) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// retry
|
|
27
|
+
}
|
|
28
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`server did not start: ${url}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildWebhookConfig(params: {
|
|
34
|
+
accountId: string;
|
|
35
|
+
path: string;
|
|
36
|
+
port: number;
|
|
37
|
+
verificationToken?: string;
|
|
38
|
+
encryptKey?: string;
|
|
39
|
+
}): ClawdbotConfig {
|
|
40
|
+
return {
|
|
41
|
+
channels: {
|
|
42
|
+
feishu: {
|
|
43
|
+
enabled: true,
|
|
44
|
+
accounts: {
|
|
45
|
+
[params.accountId]: {
|
|
46
|
+
enabled: true,
|
|
47
|
+
appId: "cli_test",
|
|
48
|
+
appSecret: "secret_test", // pragma: allowlist secret
|
|
49
|
+
connectionMode: "webhook",
|
|
50
|
+
webhookHost: "127.0.0.1",
|
|
51
|
+
webhookPort: params.port,
|
|
52
|
+
webhookPath: params.path,
|
|
53
|
+
encryptKey: params.encryptKey,
|
|
54
|
+
verificationToken: params.verificationToken,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
} as ClawdbotConfig;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function withRunningWebhookMonitor(
|
|
63
|
+
params: {
|
|
64
|
+
accountId: string;
|
|
65
|
+
path: string;
|
|
66
|
+
verificationToken: string;
|
|
67
|
+
encryptKey: string;
|
|
68
|
+
},
|
|
69
|
+
monitor: typeof monitorFeishuProvider,
|
|
70
|
+
run: (url: string) => Promise<void>,
|
|
71
|
+
) {
|
|
72
|
+
const port = await getFreePort();
|
|
73
|
+
const cfg = buildWebhookConfig({
|
|
74
|
+
accountId: params.accountId,
|
|
75
|
+
path: params.path,
|
|
76
|
+
port,
|
|
77
|
+
encryptKey: params.encryptKey,
|
|
78
|
+
verificationToken: params.verificationToken,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const abortController = new AbortController();
|
|
82
|
+
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
|
83
|
+
const monitorPromise = monitor({
|
|
84
|
+
config: cfg,
|
|
85
|
+
runtime,
|
|
86
|
+
abortSignal: abortController.signal,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const url = `http://127.0.0.1:${port}${params.path}`;
|
|
90
|
+
await waitUntilServerReady(url);
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await run(url);
|
|
94
|
+
} finally {
|
|
95
|
+
abortController.abort();
|
|
96
|
+
await monitorPromise;
|
|
97
|
+
}
|
|
98
|
+
}
|