@openclaw/feishu 2026.3.12 → 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.
@@ -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,98 +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
- encryptKey?: string;
68
- }): ClawdbotConfig {
69
- return {
70
- channels: {
71
- feishu: {
72
- enabled: true,
73
- accounts: {
74
- [params.accountId]: {
75
- enabled: true,
76
- appId: "cli_test",
77
- appSecret: "secret_test", // pragma: allowlist secret
78
- connectionMode: "webhook",
79
- webhookHost: "127.0.0.1",
80
- webhookPort: params.port,
81
- webhookPath: params.path,
82
- encryptKey: params.encryptKey,
83
- verificationToken: params.verificationToken,
84
- },
85
- },
86
- },
87
- },
88
- } as ClawdbotConfig;
89
- }
90
-
91
- async function withRunningWebhookMonitor(
92
- params: {
93
- accountId: string;
94
- path: string;
95
- verificationToken: string;
96
- encryptKey: string;
97
- },
98
- run: (url: string) => Promise<void>,
99
- ) {
100
- const port = await getFreePort();
101
- const cfg = buildConfig({
102
- accountId: params.accountId,
103
- path: params.path,
104
- port,
105
- encryptKey: params.encryptKey,
106
- verificationToken: params.verificationToken,
107
- });
108
-
109
- const abortController = new AbortController();
110
- const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
111
- const monitorPromise = monitorFeishuProvider({
112
- config: cfg,
113
- runtime,
114
- abortSignal: abortController.signal,
115
- });
116
-
117
- const url = `http://127.0.0.1:${port}${params.path}`;
118
- await waitUntilServerReady(url);
119
-
120
- try {
121
- await run(url);
122
- } finally {
123
- abortController.abort();
124
- await monitorPromise;
125
- }
126
- }
127
-
128
38
  afterEach(() => {
129
39
  clearFeishuWebhookRateLimitStateForTest();
130
40
  stopFeishuMonitor();
@@ -134,7 +44,7 @@ describe("Feishu webhook security hardening", () => {
134
44
  it("rejects webhook mode without verificationToken", async () => {
135
45
  probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
136
46
 
137
- const cfg = buildConfig({
47
+ const cfg = buildWebhookConfig({
138
48
  accountId: "missing-token",
139
49
  path: "/hook-missing-token",
140
50
  port: await getFreePort(),
@@ -148,7 +58,7 @@ describe("Feishu webhook security hardening", () => {
148
58
  it("rejects webhook mode without encryptKey", async () => {
149
59
  probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
150
60
 
151
- const cfg = buildConfig({
61
+ const cfg = buildWebhookConfig({
152
62
  accountId: "missing-encrypt-key",
153
63
  path: "/hook-missing-encrypt",
154
64
  port: await getFreePort(),
@@ -167,6 +77,7 @@ describe("Feishu webhook security hardening", () => {
167
77
  verificationToken: "verify_token",
168
78
  encryptKey: "encrypt_key",
169
79
  },
80
+ monitorFeishuProvider,
170
81
  async (url) => {
171
82
  const response = await fetch(url, {
172
83
  method: "POST",
@@ -189,6 +100,7 @@ describe("Feishu webhook security hardening", () => {
189
100
  verificationToken: "verify_token",
190
101
  encryptKey: "encrypt_key",
191
102
  },
103
+ monitorFeishuProvider,
192
104
  async (url) => {
193
105
  let saw429 = false;
194
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
+ }
@@ -29,12 +29,16 @@ vi.mock("./runtime.js", () => ({
29
29
  import { feishuOutbound } from "./outbound.js";
30
30
  const sendText = feishuOutbound.sendText!;
31
31
 
32
+ function resetOutboundMocks() {
33
+ vi.clearAllMocks();
34
+ sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
35
+ sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
36
+ sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
37
+ }
38
+
32
39
  describe("feishuOutbound.sendText local-image auto-convert", () => {
33
40
  beforeEach(() => {
34
- vi.clearAllMocks();
35
- sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
36
- sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
37
- sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
41
+ resetOutboundMocks();
38
42
  });
39
43
 
40
44
  async function createTmpImage(ext = ".png"): Promise<{ dir: string; file: string }> {
@@ -181,10 +185,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
181
185
 
182
186
  describe("feishuOutbound.sendText replyToId forwarding", () => {
183
187
  beforeEach(() => {
184
- vi.clearAllMocks();
185
- sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
186
- sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
187
- sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
188
+ resetOutboundMocks();
188
189
  });
189
190
 
190
191
  it("forwards replyToId as replyToMessageId to sendMessageFeishu", async () => {
@@ -249,10 +250,7 @@ describe("feishuOutbound.sendText replyToId forwarding", () => {
249
250
 
250
251
  describe("feishuOutbound.sendMedia replyToId forwarding", () => {
251
252
  beforeEach(() => {
252
- vi.clearAllMocks();
253
- sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
254
- sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
255
- sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
253
+ resetOutboundMocks();
256
254
  });
257
255
 
258
256
  it("forwards replyToId to sendMediaFeishu", async () => {
@@ -292,10 +290,7 @@ describe("feishuOutbound.sendMedia replyToId forwarding", () => {
292
290
 
293
291
  describe("feishuOutbound.sendMedia renderMode", () => {
294
292
  beforeEach(() => {
295
- vi.clearAllMocks();
296
- sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
297
- sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
298
- sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
293
+ resetOutboundMocks();
299
294
  });
300
295
 
301
296
  it("uses markdown cards for captions when renderMode=card", async () => {