@openclaw/feishu 2026.3.13 → 2026.5.1-beta.1

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 (188) hide show
  1. package/api.ts +31 -0
  2. package/channel-entry.ts +20 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +16 -0
  5. package/index.ts +70 -53
  6. package/openclaw.plugin.json +1653 -4
  7. package/package.json +32 -7
  8. package/runtime-api.ts +55 -0
  9. package/secret-contract-api.ts +5 -0
  10. package/security-contract-api.ts +1 -0
  11. package/session-key-api.ts +1 -0
  12. package/setup-api.ts +3 -0
  13. package/setup-entry.test.ts +14 -0
  14. package/setup-entry.ts +13 -0
  15. package/src/accounts.test.ts +95 -7
  16. package/src/accounts.ts +199 -117
  17. package/src/app-registration.ts +331 -0
  18. package/src/approval-auth.test.ts +24 -0
  19. package/src/approval-auth.ts +25 -0
  20. package/src/async.test.ts +35 -0
  21. package/src/async.ts +43 -1
  22. package/src/audio-preflight.runtime.ts +9 -0
  23. package/src/bitable.test.ts +131 -0
  24. package/src/bitable.ts +59 -22
  25. package/src/bot-content.ts +474 -0
  26. package/src/bot-group-name.test.ts +108 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.broadcast.test.ts +463 -0
  30. package/src/bot.card-action.test.ts +519 -5
  31. package/src/bot.checkBotMentioned.test.ts +92 -20
  32. package/src/bot.helpers.test.ts +118 -0
  33. package/src/bot.stripBotMention.test.ts +13 -21
  34. package/src/bot.test.ts +1334 -401
  35. package/src/bot.ts +778 -775
  36. package/src/card-action.ts +408 -40
  37. package/src/card-interaction.test.ts +129 -0
  38. package/src/card-interaction.ts +159 -0
  39. package/src/card-test-helpers.ts +47 -0
  40. package/src/card-ux-approval.ts +65 -0
  41. package/src/card-ux-launcher.test.ts +99 -0
  42. package/src/card-ux-launcher.ts +121 -0
  43. package/src/card-ux-shared.ts +33 -0
  44. package/src/channel-runtime-api.ts +16 -0
  45. package/src/channel.runtime.ts +47 -0
  46. package/src/channel.test.ts +914 -3
  47. package/src/channel.ts +1252 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +84 -28
  50. package/src/chat.ts +68 -10
  51. package/src/client.test.ts +212 -103
  52. package/src/client.ts +115 -21
  53. package/src/comment-dispatcher-runtime-api.ts +6 -0
  54. package/src/comment-dispatcher.test.ts +169 -0
  55. package/src/comment-dispatcher.ts +107 -0
  56. package/src/comment-handler-runtime-api.ts +3 -0
  57. package/src/comment-handler.test.ts +486 -0
  58. package/src/comment-handler.ts +309 -0
  59. package/src/comment-reaction.test.ts +166 -0
  60. package/src/comment-reaction.ts +259 -0
  61. package/src/comment-shared.test.ts +182 -0
  62. package/src/comment-shared.ts +365 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +63 -1
  65. package/src/config-schema.ts +31 -4
  66. package/src/conversation-id.test.ts +18 -0
  67. package/src/conversation-id.ts +199 -0
  68. package/src/dedup-runtime-api.ts +1 -0
  69. package/src/dedup.ts +32 -94
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +119 -20
  72. package/src/directory.ts +61 -91
  73. package/src/doc-schema.ts +1 -1
  74. package/src/docx-batch-insert.test.ts +39 -38
  75. package/src/docx-batch-insert.ts +55 -19
  76. package/src/docx-color-text.ts +9 -4
  77. package/src/docx-table-ops.test.ts +53 -0
  78. package/src/docx-table-ops.ts +52 -34
  79. package/src/docx-types.ts +38 -0
  80. package/src/docx.account-selection.test.ts +12 -3
  81. package/src/docx.test.ts +314 -74
  82. package/src/docx.ts +278 -122
  83. package/src/drive-schema.ts +47 -1
  84. package/src/drive.test.ts +1219 -0
  85. package/src/drive.ts +614 -13
  86. package/src/dynamic-agent.ts +10 -4
  87. package/src/event-types.ts +45 -0
  88. package/src/external-keys.ts +1 -1
  89. package/src/lifecycle.test-support.ts +220 -0
  90. package/src/media.test.ts +375 -26
  91. package/src/media.ts +434 -88
  92. package/src/mention-target.types.ts +5 -0
  93. package/src/mention.ts +32 -51
  94. package/src/message-action-contract.ts +13 -0
  95. package/src/monitor-state-runtime-api.ts +7 -0
  96. package/src/monitor-transport-runtime-api.ts +7 -0
  97. package/src/monitor.account.ts +218 -312
  98. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  99. package/src/monitor.bot-identity.ts +86 -0
  100. package/src/monitor.bot-menu-handler.ts +165 -0
  101. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  102. package/src/monitor.bot-menu.test.ts +178 -0
  103. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  104. package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
  105. package/src/monitor.cleanup.test.ts +376 -0
  106. package/src/monitor.comment-notice-handler.ts +105 -0
  107. package/src/monitor.comment.test.ts +937 -0
  108. package/src/monitor.comment.ts +1386 -0
  109. package/src/monitor.lifecycle.test.ts +4 -0
  110. package/src/monitor.message-handler.ts +339 -0
  111. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  112. package/src/monitor.reaction.test.ts +108 -48
  113. package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
  114. package/src/monitor.startup.test.ts +11 -9
  115. package/src/monitor.startup.ts +26 -16
  116. package/src/monitor.state.ts +20 -5
  117. package/src/monitor.synthetic-error.ts +18 -0
  118. package/src/monitor.test-mocks.ts +2 -2
  119. package/src/monitor.transport.ts +220 -60
  120. package/src/monitor.ts +15 -10
  121. package/src/monitor.webhook-e2e.test.ts +65 -7
  122. package/src/monitor.webhook-security.test.ts +122 -0
  123. package/src/monitor.webhook.test-helpers.ts +44 -26
  124. package/src/outbound-runtime-api.ts +1 -0
  125. package/src/outbound.test.ts +616 -37
  126. package/src/outbound.ts +623 -81
  127. package/src/perm-schema.ts +1 -1
  128. package/src/perm.ts +1 -7
  129. package/src/pins.ts +108 -0
  130. package/src/policy.test.ts +297 -117
  131. package/src/policy.ts +142 -29
  132. package/src/post.ts +7 -6
  133. package/src/probe.test.ts +14 -9
  134. package/src/probe.ts +26 -16
  135. package/src/processing-claims.ts +59 -0
  136. package/src/qr-terminal.ts +1 -0
  137. package/src/reactions.ts +4 -34
  138. package/src/reasoning-preview.test.ts +59 -0
  139. package/src/reasoning-preview.ts +20 -0
  140. package/src/reply-dispatcher-runtime-api.ts +7 -0
  141. package/src/reply-dispatcher.test.ts +660 -29
  142. package/src/reply-dispatcher.ts +407 -154
  143. package/src/runtime.ts +6 -3
  144. package/src/secret-contract.ts +145 -0
  145. package/src/secret-input.ts +1 -13
  146. package/src/security-audit-shared.ts +69 -0
  147. package/src/security-audit.test.ts +61 -0
  148. package/src/security-audit.ts +1 -0
  149. package/src/send-result.ts +1 -1
  150. package/src/send-target.test.ts +9 -3
  151. package/src/send-target.ts +10 -4
  152. package/src/send.reply-fallback.test.ts +77 -2
  153. package/src/send.test.ts +386 -4
  154. package/src/send.ts +399 -86
  155. package/src/sequential-key.test.ts +72 -0
  156. package/src/sequential-key.ts +28 -0
  157. package/src/sequential-queue.test.ts +92 -0
  158. package/src/sequential-queue.ts +16 -0
  159. package/src/session-conversation.ts +42 -0
  160. package/src/session-route.ts +48 -0
  161. package/src/setup-core.ts +51 -0
  162. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  163. package/src/setup-surface.ts +581 -0
  164. package/src/streaming-card.test.ts +138 -2
  165. package/src/streaming-card.ts +134 -18
  166. package/src/subagent-hooks.test.ts +603 -0
  167. package/src/subagent-hooks.ts +397 -0
  168. package/src/targets.ts +3 -13
  169. package/src/test-support/lifecycle-test-support.ts +479 -0
  170. package/src/thread-bindings.test.ts +143 -0
  171. package/src/thread-bindings.ts +330 -0
  172. package/src/tool-account-routing.test.ts +66 -8
  173. package/src/tool-account.test.ts +44 -0
  174. package/src/tool-account.ts +40 -17
  175. package/src/tool-factory-test-harness.ts +11 -8
  176. package/src/tool-result.ts +3 -1
  177. package/src/tools-config.ts +1 -1
  178. package/src/types.ts +16 -15
  179. package/src/typing.ts +10 -6
  180. package/src/wiki-schema.ts +1 -1
  181. package/src/wiki.ts +1 -7
  182. package/subagent-hooks-api.ts +31 -0
  183. package/tsconfig.json +16 -0
  184. package/src/feishu-command-handler.ts +0 -59
  185. package/src/onboarding.status.test.ts +0 -25
  186. package/src/onboarding.ts +0 -489
  187. package/src/send-message.ts +0 -71
  188. package/src/targets.test.ts +0 -70
@@ -1,13 +1,15 @@
1
- import * as http from "http";
2
1
  import crypto from "node:crypto";
2
+ import * as http from "node:http";
3
3
  import * as Lark from "@larksuiteoapi/node-sdk";
4
+ import { waitForAbortableDelay } from "./async.js";
5
+ import { createFeishuWSClient } from "./client.js";
4
6
  import {
5
7
  applyBasicWebhookRequestGuards,
6
- readJsonBodyWithLimit,
7
8
  type RuntimeEnv,
8
9
  installRequestBodyLimitGuard,
9
- } from "openclaw/plugin-sdk/feishu";
10
- import { createFeishuWSClient } from "./client.js";
10
+ readWebhookBodyOrReject,
11
+ safeEqualSecret,
12
+ } from "./monitor-transport-runtime-api.js";
11
13
  import {
12
14
  botNames,
13
15
  botOpenIds,
@@ -20,7 +22,7 @@ import {
20
22
  } from "./monitor.state.js";
21
23
  import type { ResolvedFeishuAccount } from "./types.js";
22
24
 
23
- export type MonitorTransportParams = {
25
+ type MonitorTransportParams = {
24
26
  account: ResolvedFeishuAccount;
25
27
  accountId: string;
26
28
  runtime?: RuntimeEnv;
@@ -28,6 +30,13 @@ export type MonitorTransportParams = {
28
30
  eventDispatcher: Lark.EventDispatcher;
29
31
  };
30
32
 
33
+ const FEISHU_WS_RECONNECT_INITIAL_DELAY_MS = 1_000;
34
+ const FEISHU_WS_RECONNECT_MAX_DELAY_MS = 30_000;
35
+ const FEISHU_WS_LOG_ERROR_MAX_LENGTH = 500;
36
+ const FEISHU_WS_RECONNECT_EXHAUSTED_RE = /^WebSocket reconnect exhausted after \d+ attempts?/;
37
+ const FEISHU_WS_AUTORECONNECT_DISABLED_ERROR =
38
+ "WebSocket connect failed and autoReconnect is disabled";
39
+
31
40
  function isFeishuWebhookPayload(value: unknown): value is Record<string, unknown> {
32
41
  return !!value && typeof value === "object" && !Array.isArray(value);
33
42
  }
@@ -39,14 +48,23 @@ function buildFeishuWebhookEnvelope(
39
48
  return Object.assign(Object.create({ headers: req.headers }), payload) as Record<string, unknown>;
40
49
  }
41
50
 
51
+ function parseFeishuWebhookPayload(rawBody: string): Record<string, unknown> | null {
52
+ try {
53
+ const parsed = JSON.parse(rawBody) as unknown;
54
+ return isFeishuWebhookPayload(parsed) ? parsed : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
42
60
  function isFeishuWebhookSignatureValid(params: {
43
61
  headers: http.IncomingHttpHeaders;
44
- payload: Record<string, unknown>;
62
+ rawBody: string;
45
63
  encryptKey?: string;
46
64
  }): boolean {
47
65
  const encryptKey = params.encryptKey?.trim();
48
66
  if (!encryptKey) {
49
- return true;
67
+ return false;
50
68
  }
51
69
 
52
70
  const timestampHeader = params.headers["x-lark-request-timestamp"];
@@ -61,9 +79,9 @@ function isFeishuWebhookSignatureValid(params: {
61
79
 
62
80
  const computedSignature = crypto
63
81
  .createHash("sha256")
64
- .update(timestamp + nonce + encryptKey + JSON.stringify(params.payload))
82
+ .update(timestamp + nonce + encryptKey + params.rawBody)
65
83
  .digest("hex");
66
- return computedSignature === signature;
84
+ return safeEqualSecret(computedSignature, signature);
67
85
  }
68
86
 
69
87
  function respondText(res: http.ServerResponse, statusCode: number, body: string): void {
@@ -72,6 +90,104 @@ function respondText(res: http.ServerResponse, statusCode: number, body: string)
72
90
  res.end(body);
73
91
  }
74
92
 
93
+ function getFeishuWsReconnectDelayMs(attempt: number): number {
94
+ return Math.min(
95
+ FEISHU_WS_RECONNECT_INITIAL_DELAY_MS * 2 ** Math.max(0, attempt - 1),
96
+ FEISHU_WS_RECONNECT_MAX_DELAY_MS,
97
+ );
98
+ }
99
+
100
+ function formatFeishuWsErrorForLog(err: unknown): string {
101
+ const raw = err instanceof Error ? err.message || err.name : String(err);
102
+ const singleLine = Array.from(raw, (char) => {
103
+ const code = char.charCodeAt(0);
104
+ return code <= 31 || code === 127 ? " " : char;
105
+ }).join("");
106
+ const redacted = singleLine
107
+ .replace(/:\/\/[^:@/\s]+:[^@/\s]+@/g, "://[redacted]@")
108
+ .replace(/\b(authorization\s*[:=]\s*Bearer\s+)[^\s,;]+/gi, "$1[redacted]")
109
+ .replace(/\b(Bearer\s+)[A-Za-z0-9._~+/-]+=*/g, "$1[redacted]")
110
+ .replace(
111
+ /\b((?:app[_-]?secret|tenant[_-]?access[_-]?token|access[_-]?token|refresh[_-]?token|token|secret|password)\s*[:=]\s*)[^\s&;,]+/gi,
112
+ "$1[redacted]",
113
+ )
114
+ .replace(/\s+/g, " ")
115
+ .trim();
116
+
117
+ if (!redacted) {
118
+ return "unknown error";
119
+ }
120
+ if (redacted.length <= FEISHU_WS_LOG_ERROR_MAX_LENGTH) {
121
+ return redacted;
122
+ }
123
+ return `${redacted.slice(0, FEISHU_WS_LOG_ERROR_MAX_LENGTH)}...`;
124
+ }
125
+
126
+ function isFeishuWsTerminalError(err: Error): boolean {
127
+ const message = err.message.trim();
128
+ return (
129
+ FEISHU_WS_RECONNECT_EXHAUSTED_RE.test(message) ||
130
+ message.startsWith(FEISHU_WS_AUTORECONNECT_DISABLED_ERROR)
131
+ );
132
+ }
133
+
134
+ function cleanupFeishuWsClient(params: {
135
+ accountId: string;
136
+ wsClient?: Lark.WSClient;
137
+ error: (message: string) => void;
138
+ clearIdentity: boolean;
139
+ }): void {
140
+ const { accountId, wsClient, error, clearIdentity } = params;
141
+ if (wsClient) {
142
+ try {
143
+ wsClient.close();
144
+ } catch (err) {
145
+ error(
146
+ `feishu[${accountId}]: error closing WebSocket client: ${formatFeishuWsErrorForLog(err)}`,
147
+ );
148
+ }
149
+ }
150
+ wsClients.delete(accountId);
151
+ if (clearIdentity) {
152
+ botOpenIds.delete(accountId);
153
+ botNames.delete(accountId);
154
+ }
155
+ }
156
+
157
+ function waitForFeishuWsCycleEnd(params: {
158
+ abortSignal?: AbortSignal;
159
+ terminalError: Promise<Error>;
160
+ }): Promise<"abort" | Error> {
161
+ if (params.abortSignal?.aborted) {
162
+ return Promise.resolve("abort");
163
+ }
164
+
165
+ return new Promise((resolve) => {
166
+ let settled = false;
167
+ let handleAbort: (() => void) | undefined;
168
+
169
+ const finish = (result: "abort" | Error) => {
170
+ if (settled) {
171
+ return;
172
+ }
173
+ settled = true;
174
+ if (handleAbort) {
175
+ params.abortSignal?.removeEventListener("abort", handleAbort);
176
+ }
177
+ resolve(result);
178
+ };
179
+
180
+ handleAbort = () => finish("abort");
181
+ params.abortSignal?.addEventListener("abort", handleAbort, { once: true });
182
+ if (params.abortSignal?.aborted) {
183
+ finish("abort");
184
+ return;
185
+ }
186
+
187
+ void params.terminalError.then(finish);
188
+ });
189
+ }
190
+
75
191
  export async function monitorWebSocket({
76
192
  account,
77
193
  accountId,
@@ -80,41 +196,81 @@ export async function monitorWebSocket({
80
196
  eventDispatcher,
81
197
  }: MonitorTransportParams): Promise<void> {
82
198
  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
- };
199
+ const error = runtime?.error ?? console.error;
100
200
 
201
+ let attempt = 0;
202
+ while (true) {
101
203
  if (abortSignal?.aborted) {
102
- cleanup();
103
- resolve();
104
- return;
204
+ break;
105
205
  }
106
206
 
107
- abortSignal?.addEventListener("abort", handleAbort, { once: true });
108
-
207
+ let wsClient: Lark.WSClient | undefined;
109
208
  try {
110
- wsClient.start({ eventDispatcher });
209
+ let reportTerminalError: (err: Error) => void = () => {};
210
+ const terminalError = new Promise<Error>((resolve) => {
211
+ reportTerminalError = resolve;
212
+ });
213
+ const handleWsError = (err: Error) => {
214
+ if (isFeishuWsTerminalError(err)) {
215
+ reportTerminalError(err);
216
+ return;
217
+ }
218
+
219
+ error(
220
+ `feishu[${accountId}]: WebSocket SDK reported recoverable error: ${formatFeishuWsErrorForLog(err)}`,
221
+ );
222
+ };
223
+ log(`feishu[${accountId}]: starting WebSocket connection...`);
224
+ wsClient = await createFeishuWSClient(account, {
225
+ onError: handleWsError,
226
+ });
227
+ if (abortSignal?.aborted) {
228
+ cleanupFeishuWsClient({ accountId, wsClient, error, clearIdentity: true });
229
+ break;
230
+ }
231
+ wsClients.set(accountId, wsClient);
232
+ await wsClient.start({ eventDispatcher });
233
+ attempt = 0;
111
234
  log(`feishu[${accountId}]: WebSocket client started`);
235
+ const cycleEnd = await waitForFeishuWsCycleEnd({ abortSignal, terminalError });
236
+ if (cycleEnd === "abort") {
237
+ log(`feishu[${accountId}]: abort signal received, stopping`);
238
+ cleanupFeishuWsClient({ accountId, wsClient, error, clearIdentity: true });
239
+ return;
240
+ }
241
+
242
+ cleanupFeishuWsClient({ accountId, wsClient, error, clearIdentity: false });
243
+ if (abortSignal?.aborted) {
244
+ break;
245
+ }
246
+
247
+ attempt += 1;
248
+ const delayMs = getFeishuWsReconnectDelayMs(attempt);
249
+ error(
250
+ `feishu[${accountId}]: WebSocket connection ended, recreating client in ${delayMs}ms: ${formatFeishuWsErrorForLog(cycleEnd)}`,
251
+ );
252
+ const shouldRetry = await waitForAbortableDelay(delayMs, abortSignal);
253
+ if (!shouldRetry) {
254
+ break;
255
+ }
112
256
  } catch (err) {
113
- cleanup();
114
- abortSignal?.removeEventListener("abort", handleAbort);
115
- reject(err);
257
+ cleanupFeishuWsClient({ accountId, wsClient, error, clearIdentity: false });
258
+ if (abortSignal?.aborted) {
259
+ break;
260
+ }
261
+
262
+ attempt += 1;
263
+ const delayMs = getFeishuWsReconnectDelayMs(attempt);
264
+ error(
265
+ `feishu[${accountId}]: WebSocket start failed, retrying in ${delayMs}ms: ${formatFeishuWsErrorForLog(err)}`,
266
+ );
267
+ const shouldRetry = await waitForAbortableDelay(delayMs, abortSignal);
268
+ if (!shouldRetry) {
269
+ break;
270
+ }
116
271
  }
117
- });
272
+ }
273
+ cleanupFeishuWsClient({ accountId, wsClient: undefined, error, clearIdentity: true });
118
274
  }
119
275
 
120
276
  export async function monitorWebhook({
@@ -126,6 +282,10 @@ export async function monitorWebhook({
126
282
  }: MonitorTransportParams): Promise<void> {
127
283
  const log = runtime?.log ?? console.log;
128
284
  const error = runtime?.error ?? console.error;
285
+ const encryptKey = account.encryptKey?.trim();
286
+ if (!encryptKey) {
287
+ throw new Error(`Feishu account "${accountId}" webhook mode requires encryptKey`);
288
+ }
129
289
 
130
290
  const port = account.config.webhookPort ?? 3000;
131
291
  const path = account.config.webhookPath ?? "/feishu/events";
@@ -165,38 +325,41 @@ export async function monitorWebhook({
165
325
 
166
326
  void (async () => {
167
327
  try {
168
- const bodyResult = await readJsonBodyWithLimit(req, {
328
+ const body = await readWebhookBodyOrReject({
329
+ req,
330
+ res,
169
331
  maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES,
170
332
  timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
333
+ profile: "pre-auth",
171
334
  });
172
- if (guard.isTripped() || res.writableEnded) {
335
+ if (!body.ok || res.writableEnded) {
173
336
  return;
174
337
  }
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");
338
+ if (guard.isTripped()) {
183
339
  return;
184
340
  }
341
+ const rawBody = body.value;
185
342
 
186
- // Lark's default adapter drops invalid signatures as an empty 200. Reject here instead.
343
+ // Reject invalid signatures before any JSON parsing to keep the auth boundary strict.
187
344
  if (
188
345
  !isFeishuWebhookSignatureValid({
189
346
  headers: req.headers,
190
- payload: bodyResult.value,
191
- encryptKey: account.encryptKey,
347
+ rawBody,
348
+ encryptKey,
192
349
  })
193
350
  ) {
194
351
  respondText(res, 401, "Invalid signature");
195
352
  return;
196
353
  }
197
354
 
198
- const { isChallenge, challenge } = Lark.generateChallenge(bodyResult.value, {
199
- encryptKey: account.encryptKey ?? "",
355
+ const payload = parseFeishuWebhookPayload(rawBody);
356
+ if (!payload) {
357
+ respondText(res, 400, "Invalid JSON");
358
+ return;
359
+ }
360
+
361
+ const { isChallenge, challenge } = Lark.generateChallenge(payload, {
362
+ encryptKey,
200
363
  });
201
364
  if (isChallenge) {
202
365
  res.statusCode = 200;
@@ -205,21 +368,18 @@ export async function monitorWebhook({
205
368
  return;
206
369
  }
207
370
 
208
- const value = await eventDispatcher.invoke(
209
- buildFeishuWebhookEnvelope(req, bodyResult.value),
210
- { needCheck: false },
211
- );
371
+ const value = await eventDispatcher.invoke(buildFeishuWebhookEnvelope(req, payload), {
372
+ needCheck: false,
373
+ });
212
374
  if (!res.headersSent) {
213
375
  res.statusCode = 200;
214
376
  res.setHeader("Content-Type", "application/json; charset=utf-8");
215
377
  res.end(JSON.stringify(value));
216
378
  }
217
379
  } 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
- }
380
+ error(`feishu[${accountId}]: webhook handler error: ${String(err)}`);
381
+ if (!res.headersSent) {
382
+ respondText(res, 500, "Internal Server Error");
223
383
  }
224
384
  } finally {
225
385
  guard.dispose();
package/src/monitor.ts CHANGED
@@ -1,10 +1,5 @@
1
- import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
2
- import { listEnabledFeishuAccounts, resolveFeishuAccount } from "./accounts.js";
3
- import {
4
- monitorSingleAccount,
5
- resolveReactionSyntheticEvent,
6
- type FeishuReactionCreatedEvent,
7
- } from "./monitor.account.js";
1
+ import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
2
+ import { listEnabledFeishuAccounts, resolveFeishuRuntimeAccount } from "./accounts.js";
8
3
  import { fetchBotIdentityForMonitor } from "./monitor.startup.js";
9
4
  import {
10
5
  clearFeishuWebhookRateLimitStateForTest,
@@ -20,13 +15,18 @@ export type MonitorFeishuOpts = {
20
15
  accountId?: string;
21
16
  };
22
17
 
18
+ let monitorAccountRuntimePromise: Promise<typeof import("./monitor.account.js")> | undefined;
19
+
20
+ async function loadMonitorAccountRuntime() {
21
+ monitorAccountRuntimePromise ??= import("./monitor.account.js");
22
+ return await monitorAccountRuntimePromise;
23
+ }
24
+
23
25
  export {
24
26
  clearFeishuWebhookRateLimitStateForTest,
25
27
  getFeishuWebhookRateLimitStateSizeForTest,
26
28
  isWebhookRateLimitedForTest,
27
- resolveReactionSyntheticEvent,
28
29
  };
29
- export type { FeishuReactionCreatedEvent };
30
30
 
31
31
  export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promise<void> {
32
32
  const cfg = opts.config;
@@ -37,10 +37,14 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
37
37
  const log = opts.runtime?.log ?? console.log;
38
38
 
39
39
  if (opts.accountId) {
40
- const account = resolveFeishuAccount({ cfg, accountId: opts.accountId });
40
+ const account = resolveFeishuRuntimeAccount(
41
+ { cfg, accountId: opts.accountId },
42
+ { requireEventSecrets: true },
43
+ );
41
44
  if (!account.enabled || !account.configured) {
42
45
  throw new Error(`Feishu account "${opts.accountId}" not configured or disabled`);
43
46
  }
47
+ const { monitorSingleAccount } = await loadMonitorAccountRuntime();
44
48
  return monitorSingleAccount({
45
49
  cfg,
46
50
  account,
@@ -58,6 +62,7 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
58
62
  `feishu: starting ${accounts.length} account(s): ${accounts.map((a) => a.accountId).join(", ")}`,
59
63
  );
60
64
 
65
+ const { monitorSingleAccount } = await loadMonitorAccountRuntime();
61
66
  const monitorPromises: Promise<void>[] = [];
62
67
  for (const account of accounts) {
63
68
  if (opts.abortSignal?.aborted) {
@@ -23,7 +23,7 @@ import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
23
23
 
24
24
  function signFeishuPayload(params: {
25
25
  encryptKey: string;
26
- payload: Record<string, unknown>;
26
+ rawBody: string;
27
27
  timestamp?: string;
28
28
  nonce?: string;
29
29
  }): Record<string, string> {
@@ -31,7 +31,7 @@ function signFeishuPayload(params: {
31
31
  const nonce = params.nonce ?? "nonce-test";
32
32
  const signature = crypto
33
33
  .createHash("sha256")
34
- .update(timestamp + nonce + params.encryptKey + JSON.stringify(params.payload))
34
+ .update(timestamp + nonce + params.encryptKey + params.rawBody)
35
35
  .digest("hex");
36
36
  return {
37
37
  "content-type": "application/json",
@@ -51,10 +51,11 @@ function encryptFeishuPayload(encryptKey: string, payload: Record<string, unknow
51
51
  }
52
52
 
53
53
  async function postSignedPayload(url: string, payload: Record<string, unknown>) {
54
+ const rawBody = JSON.stringify(payload);
54
55
  return await fetch(url, {
55
56
  method: "POST",
56
- headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }),
57
- body: JSON.stringify(payload),
57
+ headers: signFeishuPayload({ encryptKey: "encrypt_key", rawBody }),
58
+ body: rawBody,
58
59
  });
59
60
  }
60
61
 
@@ -76,12 +77,13 @@ describe("Feishu webhook signed-request e2e", () => {
76
77
  monitorFeishuProvider,
77
78
  async (url) => {
78
79
  const payload = { type: "url_verification", challenge: "challenge-token" };
80
+ const rawBody = JSON.stringify(payload);
79
81
  const response = await fetch(url, {
80
82
  method: "POST",
81
83
  headers: {
82
- ...signFeishuPayload({ encryptKey: "wrong_key", payload }),
84
+ ...signFeishuPayload({ encryptKey: "wrong_key", rawBody }),
83
85
  },
84
- body: JSON.stringify(payload),
86
+ body: rawBody,
85
87
  });
86
88
 
87
89
  expect(response.status).toBe(401);
@@ -114,7 +116,38 @@ describe("Feishu webhook signed-request e2e", () => {
114
116
  );
115
117
  });
116
118
 
117
- it("returns 400 for invalid json before invoking the sdk", async () => {
119
+ it("rejects malformed short signatures with 401", async () => {
120
+ probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
121
+
122
+ await withRunningWebhookMonitor(
123
+ {
124
+ accountId: "short-signature",
125
+ path: "/hook-e2e-short-signature",
126
+ verificationToken: "verify_token",
127
+ encryptKey: "encrypt_key",
128
+ },
129
+ monitorFeishuProvider,
130
+ async (url) => {
131
+ const payload = { type: "url_verification", challenge: "challenge-token" };
132
+ const headers = signFeishuPayload({
133
+ encryptKey: "encrypt_key",
134
+ rawBody: JSON.stringify(payload),
135
+ });
136
+ headers["x-lark-signature"] = headers["x-lark-signature"].slice(0, 12);
137
+
138
+ const response = await fetch(url, {
139
+ method: "POST",
140
+ headers,
141
+ body: JSON.stringify(payload),
142
+ });
143
+
144
+ expect(response.status).toBe(401);
145
+ expect(await response.text()).toBe("Invalid signature");
146
+ },
147
+ );
148
+ });
149
+
150
+ it("returns 401 for unsigned invalid json before parsing", async () => {
118
151
  probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
119
152
 
120
153
  await withRunningWebhookMonitor(
@@ -132,6 +165,31 @@ describe("Feishu webhook signed-request e2e", () => {
132
165
  body: "{not-json",
133
166
  });
134
167
 
168
+ expect(response.status).toBe(401);
169
+ expect(await response.text()).toBe("Invalid signature");
170
+ },
171
+ );
172
+ });
173
+
174
+ it("returns 400 for signed invalid json after signature validation", async () => {
175
+ probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
176
+
177
+ await withRunningWebhookMonitor(
178
+ {
179
+ accountId: "signed-invalid-json",
180
+ path: "/hook-e2e-signed-invalid-json",
181
+ verificationToken: "verify_token",
182
+ encryptKey: "encrypt_key",
183
+ },
184
+ monitorFeishuProvider,
185
+ async (url) => {
186
+ const rawBody = "{not-json";
187
+ const response = await fetch(url, {
188
+ method: "POST",
189
+ headers: signFeishuPayload({ encryptKey: "encrypt_key", rawBody }),
190
+ body: rawBody,
191
+ });
192
+
135
193
  expect(response.status).toBe(400);
136
194
  expect(await response.text()).toBe("Invalid JSON");
137
195
  },