@nextclaw/channel-plugin-feishu 0.2.13 → 0.2.15

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.
Files changed (120) hide show
  1. package/README.md +3 -1
  2. package/index.ts +65 -0
  3. package/openclaw.plugin.json +3 -7
  4. package/package.json +32 -9
  5. package/skills/feishu-doc/SKILL.md +211 -0
  6. package/skills/feishu-doc/references/block-types.md +103 -0
  7. package/skills/feishu-drive/SKILL.md +97 -0
  8. package/skills/feishu-perm/SKILL.md +119 -0
  9. package/skills/feishu-wiki/SKILL.md +111 -0
  10. package/src/accounts.test.ts +371 -0
  11. package/src/accounts.ts +244 -0
  12. package/src/async.ts +62 -0
  13. package/src/bitable.ts +725 -0
  14. package/src/bot.card-action.test.ts +63 -0
  15. package/src/bot.checkBotMentioned.test.ts +193 -0
  16. package/src/bot.stripBotMention.test.ts +134 -0
  17. package/src/bot.test.ts +2107 -0
  18. package/src/bot.ts +1556 -0
  19. package/src/card-action.ts +79 -0
  20. package/src/channel.test.ts +48 -0
  21. package/src/channel.ts +369 -0
  22. package/src/chat-schema.ts +24 -0
  23. package/src/chat.test.ts +89 -0
  24. package/src/chat.ts +130 -0
  25. package/src/client.test.ts +324 -0
  26. package/src/client.ts +196 -0
  27. package/src/config-schema.test.ts +247 -0
  28. package/src/config-schema.ts +306 -0
  29. package/src/dedup.ts +203 -0
  30. package/src/directory.test.ts +40 -0
  31. package/src/directory.ts +156 -0
  32. package/src/doc-schema.ts +182 -0
  33. package/src/docx-batch-insert.test.ts +90 -0
  34. package/src/docx-batch-insert.ts +187 -0
  35. package/src/docx-color-text.ts +149 -0
  36. package/src/docx-table-ops.ts +298 -0
  37. package/src/docx.account-selection.test.ts +70 -0
  38. package/src/docx.test.ts +445 -0
  39. package/src/docx.ts +1460 -0
  40. package/src/drive-schema.ts +46 -0
  41. package/src/drive.ts +228 -0
  42. package/src/dynamic-agent.ts +131 -0
  43. package/src/external-keys.test.ts +20 -0
  44. package/src/external-keys.ts +19 -0
  45. package/src/feishu-command-handler.ts +59 -0
  46. package/src/media.test.ts +523 -0
  47. package/src/media.ts +484 -0
  48. package/src/mention.ts +133 -0
  49. package/src/monitor.account.ts +562 -0
  50. package/src/monitor.reaction.test.ts +653 -0
  51. package/src/monitor.startup.test.ts +190 -0
  52. package/src/monitor.startup.ts +64 -0
  53. package/src/monitor.state.defaults.test.ts +46 -0
  54. package/src/monitor.state.ts +155 -0
  55. package/src/monitor.test-mocks.ts +45 -0
  56. package/src/monitor.transport.ts +264 -0
  57. package/src/monitor.ts +95 -0
  58. package/src/monitor.webhook-e2e.test.ts +214 -0
  59. package/src/monitor.webhook-security.test.ts +142 -0
  60. package/src/monitor.webhook.test-helpers.ts +98 -0
  61. package/src/nextclaw-sdk/account-id.ts +31 -0
  62. package/src/nextclaw-sdk/compat.ts +8 -0
  63. package/src/nextclaw-sdk/core-channel.ts +296 -0
  64. package/src/nextclaw-sdk/core-pairing.ts +224 -0
  65. package/src/nextclaw-sdk/core.ts +26 -0
  66. package/src/nextclaw-sdk/dedupe.ts +246 -0
  67. package/src/nextclaw-sdk/feishu.ts +77 -0
  68. package/src/nextclaw-sdk/history.ts +127 -0
  69. package/src/nextclaw-sdk/network-body.ts +245 -0
  70. package/src/nextclaw-sdk/network-fetch.ts +129 -0
  71. package/src/nextclaw-sdk/network-webhook.ts +182 -0
  72. package/src/nextclaw-sdk/network.ts +13 -0
  73. package/src/nextclaw-sdk/runtime-store.ts +26 -0
  74. package/src/nextclaw-sdk/secrets-config.ts +109 -0
  75. package/src/nextclaw-sdk/secrets-core.ts +170 -0
  76. package/src/nextclaw-sdk/secrets-prompt.ts +305 -0
  77. package/src/nextclaw-sdk/secrets.ts +18 -0
  78. package/src/nextclaw-sdk/types.ts +300 -0
  79. package/src/onboarding.status.test.ts +25 -0
  80. package/src/onboarding.test.ts +143 -0
  81. package/src/onboarding.ts +489 -0
  82. package/src/outbound.test.ts +356 -0
  83. package/src/outbound.ts +176 -0
  84. package/src/perm-schema.ts +52 -0
  85. package/src/perm.ts +176 -0
  86. package/src/policy.test.ts +154 -0
  87. package/src/policy.ts +123 -0
  88. package/src/post.test.ts +105 -0
  89. package/src/post.ts +274 -0
  90. package/src/probe.test.ts +270 -0
  91. package/src/probe.ts +156 -0
  92. package/src/reactions.ts +153 -0
  93. package/src/reply-dispatcher.test.ts +513 -0
  94. package/src/reply-dispatcher.ts +397 -0
  95. package/src/runtime.ts +6 -0
  96. package/src/secret-input.ts +13 -0
  97. package/src/send-message.ts +71 -0
  98. package/src/send-result.ts +29 -0
  99. package/src/send-target.test.ts +74 -0
  100. package/src/send-target.ts +29 -0
  101. package/src/send.reply-fallback.test.ts +189 -0
  102. package/src/send.test.ts +168 -0
  103. package/src/send.ts +481 -0
  104. package/src/streaming-card.test.ts +54 -0
  105. package/src/streaming-card.ts +374 -0
  106. package/src/targets.test.ts +70 -0
  107. package/src/targets.ts +107 -0
  108. package/src/tool-account-routing.test.ts +129 -0
  109. package/src/tool-account.ts +70 -0
  110. package/src/tool-factory-test-harness.ts +76 -0
  111. package/src/tool-result.test.ts +32 -0
  112. package/src/tool-result.ts +14 -0
  113. package/src/tools-config.test.ts +21 -0
  114. package/src/tools-config.ts +22 -0
  115. package/src/types.ts +103 -0
  116. package/src/typing.test.ts +144 -0
  117. package/src/typing.ts +210 -0
  118. package/src/wiki-schema.ts +55 -0
  119. package/src/wiki.ts +233 -0
  120. package/index.js +0 -27
@@ -0,0 +1,264 @@
1
+ import * as http from "http";
2
+ import crypto from "node:crypto";
3
+ import * as Lark from "@larksuiteoapi/node-sdk";
4
+ import {
5
+ applyBasicWebhookRequestGuards,
6
+ readJsonBodyWithLimit,
7
+ type RuntimeEnv,
8
+ installRequestBodyLimitGuard,
9
+ } from "./nextclaw-sdk/feishu.js";
10
+ import { createFeishuWSClient } from "./client.js";
11
+ import {
12
+ botNames,
13
+ botOpenIds,
14
+ FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
15
+ FEISHU_WEBHOOK_MAX_BODY_BYTES,
16
+ feishuWebhookRateLimiter,
17
+ httpServers,
18
+ recordWebhookStatus,
19
+ wsClients,
20
+ } from "./monitor.state.js";
21
+ import type { ResolvedFeishuAccount } from "./types.js";
22
+
23
+ export type MonitorTransportParams = {
24
+ account: ResolvedFeishuAccount;
25
+ accountId: string;
26
+ runtime?: RuntimeEnv;
27
+ abortSignal?: AbortSignal;
28
+ eventDispatcher: Lark.EventDispatcher;
29
+ };
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
+
75
+ export async function monitorWebSocket({
76
+ account,
77
+ accountId,
78
+ runtime,
79
+ abortSignal,
80
+ eventDispatcher,
81
+ }: MonitorTransportParams): Promise<void> {
82
+ const log = runtime?.log ?? console.log;
83
+ log(`feishu[${accountId}]: starting WebSocket connection...`);
84
+
85
+ const wsClient = createFeishuWSClient(account);
86
+ wsClients.set(accountId, wsClient);
87
+
88
+ return new Promise((resolve, reject) => {
89
+ const cleanup = () => {
90
+ wsClients.delete(accountId);
91
+ botOpenIds.delete(accountId);
92
+ botNames.delete(accountId);
93
+ };
94
+
95
+ const handleAbort = () => {
96
+ log(`feishu[${accountId}]: abort signal received, stopping`);
97
+ cleanup();
98
+ resolve();
99
+ };
100
+
101
+ if (abortSignal?.aborted) {
102
+ cleanup();
103
+ resolve();
104
+ return;
105
+ }
106
+
107
+ abortSignal?.addEventListener("abort", handleAbort, { once: true });
108
+
109
+ try {
110
+ wsClient.start({ eventDispatcher });
111
+ log(`feishu[${accountId}]: WebSocket client started`);
112
+ } catch (err) {
113
+ cleanup();
114
+ abortSignal?.removeEventListener("abort", handleAbort);
115
+ reject(err);
116
+ }
117
+ });
118
+ }
119
+
120
+ export async function monitorWebhook({
121
+ account,
122
+ accountId,
123
+ runtime,
124
+ abortSignal,
125
+ eventDispatcher,
126
+ }: MonitorTransportParams): Promise<void> {
127
+ const log = runtime?.log ?? console.log;
128
+ const error = runtime?.error ?? console.error;
129
+
130
+ const port = account.config.webhookPort ?? 3000;
131
+ const path = account.config.webhookPath ?? "/feishu/events";
132
+ const host = account.config.webhookHost ?? "127.0.0.1";
133
+
134
+ log(`feishu[${accountId}]: starting Webhook server on ${host}:${port}, path ${path}...`);
135
+
136
+ const server = http.createServer();
137
+
138
+ server.on("request", (req, res) => {
139
+ res.on("finish", () => {
140
+ recordWebhookStatus(runtime, accountId, path, res.statusCode);
141
+ });
142
+
143
+ const rateLimitKey = `${accountId}:${path}:${req.socket.remoteAddress ?? "unknown"}`;
144
+ if (
145
+ !applyBasicWebhookRequestGuards({
146
+ req,
147
+ res,
148
+ rateLimiter: feishuWebhookRateLimiter,
149
+ rateLimitKey,
150
+ nowMs: Date.now(),
151
+ requireJsonContentType: true,
152
+ })
153
+ ) {
154
+ return;
155
+ }
156
+
157
+ const guard = installRequestBodyLimitGuard(req, res, {
158
+ maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES,
159
+ timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
160
+ responseFormat: "text",
161
+ });
162
+ if (guard.isTripped()) {
163
+ return;
164
+ }
165
+
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) {
218
+ if (!guard.isTripped()) {
219
+ error(`feishu[${accountId}]: webhook handler error: ${String(err)}`);
220
+ if (!res.headersSent) {
221
+ respondText(res, 500, "Internal Server Error");
222
+ }
223
+ }
224
+ } finally {
225
+ guard.dispose();
226
+ }
227
+ })();
228
+ });
229
+
230
+ httpServers.set(accountId, server);
231
+
232
+ return new Promise((resolve, reject) => {
233
+ const cleanup = () => {
234
+ server.close();
235
+ httpServers.delete(accountId);
236
+ botOpenIds.delete(accountId);
237
+ botNames.delete(accountId);
238
+ };
239
+
240
+ const handleAbort = () => {
241
+ log(`feishu[${accountId}]: abort signal received, stopping Webhook server`);
242
+ cleanup();
243
+ resolve();
244
+ };
245
+
246
+ if (abortSignal?.aborted) {
247
+ cleanup();
248
+ resolve();
249
+ return;
250
+ }
251
+
252
+ abortSignal?.addEventListener("abort", handleAbort, { once: true });
253
+
254
+ server.listen(port, host, () => {
255
+ log(`feishu[${accountId}]: Webhook server listening on ${host}:${port}`);
256
+ });
257
+
258
+ server.on("error", (err) => {
259
+ error(`feishu[${accountId}]: Webhook server error: ${err}`);
260
+ abortSignal?.removeEventListener("abort", handleAbort);
261
+ reject(err);
262
+ });
263
+ });
264
+ }
package/src/monitor.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { ClawdbotConfig, RuntimeEnv } from "./nextclaw-sdk/feishu.js";
2
+ import { listEnabledFeishuAccounts, resolveFeishuAccount } from "./accounts.js";
3
+ import {
4
+ monitorSingleAccount,
5
+ resolveReactionSyntheticEvent,
6
+ type FeishuReactionCreatedEvent,
7
+ } from "./monitor.account.js";
8
+ import { fetchBotIdentityForMonitor } from "./monitor.startup.js";
9
+ import {
10
+ clearFeishuWebhookRateLimitStateForTest,
11
+ getFeishuWebhookRateLimitStateSizeForTest,
12
+ isWebhookRateLimitedForTest,
13
+ stopFeishuMonitorState,
14
+ } from "./monitor.state.js";
15
+
16
+ export type MonitorFeishuOpts = {
17
+ config?: ClawdbotConfig;
18
+ runtime?: RuntimeEnv;
19
+ abortSignal?: AbortSignal;
20
+ accountId?: string;
21
+ };
22
+
23
+ export {
24
+ clearFeishuWebhookRateLimitStateForTest,
25
+ getFeishuWebhookRateLimitStateSizeForTest,
26
+ isWebhookRateLimitedForTest,
27
+ resolveReactionSyntheticEvent,
28
+ };
29
+ export type { FeishuReactionCreatedEvent };
30
+
31
+ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promise<void> {
32
+ const cfg = opts.config;
33
+ if (!cfg) {
34
+ throw new Error("Config is required for Feishu monitor");
35
+ }
36
+
37
+ const log = opts.runtime?.log ?? console.log;
38
+
39
+ if (opts.accountId) {
40
+ const account = resolveFeishuAccount({ cfg, accountId: opts.accountId });
41
+ if (!account.enabled || !account.configured) {
42
+ throw new Error(`Feishu account "${opts.accountId}" not configured or disabled`);
43
+ }
44
+ return monitorSingleAccount({
45
+ cfg,
46
+ account,
47
+ runtime: opts.runtime,
48
+ abortSignal: opts.abortSignal,
49
+ });
50
+ }
51
+
52
+ const accounts = listEnabledFeishuAccounts(cfg);
53
+ if (accounts.length === 0) {
54
+ throw new Error("No enabled Feishu accounts configured");
55
+ }
56
+
57
+ log(
58
+ `feishu: starting ${accounts.length} account(s): ${accounts.map((a) => a.accountId).join(", ")}`,
59
+ );
60
+
61
+ const monitorPromises: Promise<void>[] = [];
62
+ for (const account of accounts) {
63
+ if (opts.abortSignal?.aborted) {
64
+ log("feishu: abort signal received during startup preflight; stopping startup");
65
+ break;
66
+ }
67
+
68
+ // Probe sequentially so large multi-account startups do not burst Feishu's bot-info endpoint.
69
+ const { botOpenId, botName } = await fetchBotIdentityForMonitor(account, {
70
+ runtime: opts.runtime,
71
+ abortSignal: opts.abortSignal,
72
+ });
73
+
74
+ if (opts.abortSignal?.aborted) {
75
+ log("feishu: abort signal received during startup preflight; stopping startup");
76
+ break;
77
+ }
78
+
79
+ monitorPromises.push(
80
+ monitorSingleAccount({
81
+ cfg,
82
+ account,
83
+ runtime: opts.runtime,
84
+ abortSignal: opts.abortSignal,
85
+ botOpenIdSource: { kind: "prefetched", botOpenId, botName },
86
+ }),
87
+ );
88
+ }
89
+
90
+ await Promise.all(monitorPromises);
91
+ }
92
+
93
+ export function stopFeishuMonitor(accountId?: string): void {
94
+ stopFeishuMonitorState(accountId);
95
+ }
@@ -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
+ });