@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.
@@ -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", () => feishuClientMockModule);
32
- vi.mock("./runtime.js", () => feishuRuntimeMockModule);
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
- for (let i = 0; i < 10 && !started.includes("beta"); i += 1) {
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
- for (let i = 0; i < 10 && !started.includes("beta"); i += 1) {
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"),
@@ -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 Promise.resolve(webhookHandler(req, res))
122
- .catch((err) => {
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 = buildConfig({
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
+ }