@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.
@@ -12,10 +12,10 @@ import {
12
12
  import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js";
13
13
  import { createEventDispatcher } from "./client.js";
14
14
  import {
15
- hasRecordedMessage,
16
- hasRecordedMessagePersistent,
17
- tryRecordMessage,
18
- tryRecordMessagePersistent,
15
+ hasProcessedFeishuMessage,
16
+ recordProcessedFeishuMessage,
17
+ releaseFeishuMessageProcessing,
18
+ tryBeginFeishuMessageProcessing,
19
19
  warmupDedupFromDisk,
20
20
  } from "./dedup.js";
21
21
  import { isMentionForwardRequest } from "./mention.js";
@@ -264,6 +264,7 @@ function registerEventHandlers(
264
264
  runtime,
265
265
  chatHistories,
266
266
  accountId,
267
+ processingClaimHeld: true,
267
268
  });
268
269
  await enqueue(chatId, task);
269
270
  };
@@ -291,10 +292,8 @@ function registerEventHandlers(
291
292
  return;
292
293
  }
293
294
  for (const messageId of suppressedIds) {
294
- // Keep in-memory dedupe in sync with handleFeishuMessage's keying.
295
- tryRecordMessage(`${accountId}:${messageId}`);
296
295
  try {
297
- await tryRecordMessagePersistent(messageId, accountId, log);
296
+ await recordProcessedFeishuMessage(messageId, accountId, log);
298
297
  } catch (err) {
299
298
  error(
300
299
  `feishu[${accountId}]: failed to record merged dedupe id ${messageId}: ${String(err)}`,
@@ -303,15 +302,7 @@ function registerEventHandlers(
303
302
  }
304
303
  };
305
304
  const isMessageAlreadyProcessed = async (entry: FeishuMessageEvent): Promise<boolean> => {
306
- const messageId = entry.message.message_id?.trim();
307
- if (!messageId) {
308
- return false;
309
- }
310
- const memoryKey = `${accountId}:${messageId}`;
311
- if (hasRecordedMessage(memoryKey)) {
312
- return true;
313
- }
314
- return hasRecordedMessagePersistent(messageId, accountId, log);
305
+ return await hasProcessedFeishuMessage(entry.message.message_id, accountId, log);
315
306
  };
316
307
  const inboundDebouncer = core.channel.debounce.createInboundDebouncer<FeishuMessageEvent>({
317
308
  debounceMs: inboundDebounceMs,
@@ -384,19 +375,28 @@ function registerEventHandlers(
384
375
  },
385
376
  });
386
377
  },
387
- onError: (err) => {
378
+ onError: (err, entries) => {
379
+ for (const entry of entries) {
380
+ releaseFeishuMessageProcessing(entry.message.message_id, accountId);
381
+ }
388
382
  error(`feishu[${accountId}]: inbound debounce flush failed: ${String(err)}`);
389
383
  },
390
384
  });
391
385
 
392
386
  eventDispatcher.register({
393
387
  "im.message.receive_v1": async (data) => {
388
+ const event = data as unknown as FeishuMessageEvent;
389
+ const messageId = event.message?.message_id?.trim();
390
+ if (!tryBeginFeishuMessageProcessing(messageId, accountId)) {
391
+ log(`feishu[${accountId}]: dropping duplicate event for message ${messageId}`);
392
+ return;
393
+ }
394
394
  const processMessage = async () => {
395
- const event = data as unknown as FeishuMessageEvent;
396
395
  await inboundDebouncer.enqueue(event);
397
396
  };
398
397
  if (fireAndForget) {
399
398
  void processMessage().catch((err) => {
399
+ releaseFeishuMessageProcessing(messageId, accountId);
400
400
  error(`feishu[${accountId}]: error handling message: ${String(err)}`);
401
401
  });
402
402
  return;
@@ -404,6 +404,7 @@ function registerEventHandlers(
404
404
  try {
405
405
  await processMessage();
406
406
  } catch (err) {
407
+ releaseFeishuMessageProcessing(messageId, accountId);
407
408
  error(`feishu[${accountId}]: error handling message: ${String(err)}`);
408
409
  }
409
410
  },
@@ -78,6 +78,25 @@ async function resolveReactionWithLookup(params: {
78
78
  });
79
79
  }
80
80
 
81
+ async function resolveNonBotReaction(params?: { cfg?: ClawdbotConfig; uuid?: () => string }) {
82
+ return await resolveReactionSyntheticEvent({
83
+ cfg: params?.cfg ?? cfg,
84
+ accountId: "default",
85
+ event: makeReactionEvent(),
86
+ botOpenId: "ou_bot",
87
+ fetchMessage: async () => ({
88
+ messageId: "om_msg1",
89
+ chatId: "oc_group",
90
+ chatType: "group",
91
+ senderOpenId: "ou_other",
92
+ senderType: "user",
93
+ content: "hello",
94
+ contentType: "text",
95
+ }),
96
+ ...(params?.uuid ? { uuid: params.uuid } : {}),
97
+ });
98
+ }
99
+
81
100
  type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number];
82
101
 
83
102
  function buildDebounceConfig(): ClawdbotConfig {
@@ -179,11 +198,23 @@ function getFirstDispatchedEvent(): FeishuMessageEvent {
179
198
  return firstParams.event;
180
199
  }
181
200
 
201
+ function expectSingleDispatchedEvent(): FeishuMessageEvent {
202
+ expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
203
+ return getFirstDispatchedEvent();
204
+ }
205
+
206
+ function expectParsedFirstDispatchedEvent(botOpenId = "ou_bot") {
207
+ const dispatched = expectSingleDispatchedEvent();
208
+ return {
209
+ dispatched,
210
+ parsed: parseFeishuMessageEvent(dispatched, botOpenId),
211
+ };
212
+ }
213
+
182
214
  function setDedupPassThroughMocks(): void {
183
- vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
184
- vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
185
- vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
186
- vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
215
+ vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
216
+ vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
217
+ vi.spyOn(dedup, "hasProcessedFeishuMessage").mockResolvedValue(false);
187
218
  }
188
219
 
189
220
  function createMention(params: { openId: string; name: string; key?: string }): FeishuMention {
@@ -203,6 +234,12 @@ async function enqueueDebouncedMessage(
203
234
  await Promise.resolve();
204
235
  }
205
236
 
237
+ function setStaleRetryMocks(messageId = "om_old") {
238
+ vi.spyOn(dedup, "hasProcessedFeishuMessage").mockImplementation(
239
+ async (currentMessageId) => currentMessageId === messageId,
240
+ );
241
+ }
242
+
206
243
  describe("resolveReactionSyntheticEvent", () => {
207
244
  it("filters app self-reactions", async () => {
208
245
  const event = makeReactionEvent({ operator_type: "app" });
@@ -262,28 +299,12 @@ describe("resolveReactionSyntheticEvent", () => {
262
299
  });
263
300
 
264
301
  it("filters reactions on non-bot messages", async () => {
265
- const event = makeReactionEvent();
266
- const result = await resolveReactionSyntheticEvent({
267
- cfg,
268
- accountId: "default",
269
- event,
270
- botOpenId: "ou_bot",
271
- fetchMessage: async () => ({
272
- messageId: "om_msg1",
273
- chatId: "oc_group",
274
- chatType: "group",
275
- senderOpenId: "ou_other",
276
- senderType: "user",
277
- content: "hello",
278
- contentType: "text",
279
- }),
280
- });
302
+ const result = await resolveNonBotReaction();
281
303
  expect(result).toBeNull();
282
304
  });
283
305
 
284
306
  it("allows non-bot reactions when reactionNotifications is all", async () => {
285
- const event = makeReactionEvent();
286
- const result = await resolveReactionSyntheticEvent({
307
+ const result = await resolveNonBotReaction({
287
308
  cfg: {
288
309
  channels: {
289
310
  feishu: {
@@ -291,18 +312,6 @@ describe("resolveReactionSyntheticEvent", () => {
291
312
  },
292
313
  },
293
314
  } as ClawdbotConfig,
294
- accountId: "default",
295
- event,
296
- botOpenId: "ou_bot",
297
- fetchMessage: async () => ({
298
- messageId: "om_msg1",
299
- chatId: "oc_group",
300
- chatType: "group",
301
- senderOpenId: "ou_other",
302
- senderType: "user",
303
- content: "hello",
304
- contentType: "text",
305
- }),
306
315
  uuid: () => "fixed-uuid",
307
316
  });
308
317
  expect(result?.message.message_id).toBe("om_msg1:reaction:THUMBSUP:fixed-uuid");
@@ -457,18 +466,16 @@ describe("Feishu inbound debounce regressions", () => {
457
466
  );
458
467
  await vi.advanceTimersByTimeAsync(25);
459
468
 
460
- expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
461
- const dispatched = getFirstDispatchedEvent();
469
+ const dispatched = expectSingleDispatchedEvent();
462
470
  const mergedMentions = dispatched.message.mentions ?? [];
463
471
  expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true);
464
472
  expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false);
465
473
  });
466
474
 
467
475
  it("passes prefetched botName through to handleFeishuMessage", async () => {
468
- vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
469
- vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
470
- vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
471
- vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
476
+ vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
477
+ vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
478
+ vi.spyOn(dedup, "hasProcessedFeishuMessage").mockResolvedValue(false);
472
479
  const onMessage = await setupDebounceMonitor({ botName: "OpenClaw Bot" });
473
480
 
474
481
  await onMessage(
@@ -517,9 +524,7 @@ describe("Feishu inbound debounce regressions", () => {
517
524
  );
518
525
  await vi.advanceTimersByTimeAsync(25);
519
526
 
520
- expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
521
- const dispatched = getFirstDispatchedEvent();
522
- const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
527
+ const { dispatched, parsed } = expectParsedFirstDispatchedEvent();
523
528
  expect(parsed.mentionedBot).toBe(true);
524
529
  expect(parsed.mentionTargets).toBeUndefined();
525
530
  const mergedMentions = dispatched.message.mentions ?? [];
@@ -547,19 +552,14 @@ describe("Feishu inbound debounce regressions", () => {
547
552
  );
548
553
  await vi.advanceTimersByTimeAsync(25);
549
554
 
550
- expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
551
- const dispatched = getFirstDispatchedEvent();
552
- const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
555
+ const { parsed } = expectParsedFirstDispatchedEvent();
553
556
  expect(parsed.mentionedBot).toBe(true);
554
557
  });
555
558
 
556
559
  it("excludes previously processed retries from combined debounce text", async () => {
557
- vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
558
- vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
559
- vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
560
- vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
561
- async (messageId) => messageId === "om_old",
562
- );
560
+ vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
561
+ vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
562
+ setStaleRetryMocks();
563
563
  const onMessage = await setupDebounceMonitor();
564
564
 
565
565
  await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
@@ -576,20 +576,16 @@ describe("Feishu inbound debounce regressions", () => {
576
576
  await Promise.resolve();
577
577
  await vi.advanceTimersByTimeAsync(25);
578
578
 
579
- expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
580
- const dispatched = getFirstDispatchedEvent();
579
+ const dispatched = expectSingleDispatchedEvent();
581
580
  expect(dispatched.message.message_id).toBe("om_new_2");
582
581
  const combined = JSON.parse(dispatched.message.content) as { text?: string };
583
582
  expect(combined.text).toBe("first\nsecond");
584
583
  });
585
584
 
586
585
  it("uses latest fresh message id when debounce batch ends with stale retry", async () => {
587
- const recordSpy = vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
588
- vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
589
- vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
590
- vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
591
- async (messageId) => messageId === "om_old",
592
- );
586
+ vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
587
+ const recordSpy = vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
588
+ setStaleRetryMocks();
593
589
  const onMessage = await setupDebounceMonitor();
594
590
 
595
591
  await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" }));
@@ -600,12 +596,58 @@ describe("Feishu inbound debounce regressions", () => {
600
596
  await Promise.resolve();
601
597
  await vi.advanceTimersByTimeAsync(25);
602
598
 
603
- expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
604
- const dispatched = getFirstDispatchedEvent();
599
+ const dispatched = expectSingleDispatchedEvent();
605
600
  expect(dispatched.message.message_id).toBe("om_new");
606
601
  const combined = JSON.parse(dispatched.message.content) as { text?: string };
607
602
  expect(combined.text).toBe("fresh");
608
- expect(recordSpy).toHaveBeenCalledWith("default:om_old");
609
- expect(recordSpy).not.toHaveBeenCalledWith("default:om_new");
603
+ expect(recordSpy).toHaveBeenCalledWith("om_old", "default", expect.any(Function));
604
+ expect(recordSpy).not.toHaveBeenCalledWith("om_new", "default", expect.any(Function));
605
+ });
606
+
607
+ it("releases early event dedupe when debounced dispatch fails", async () => {
608
+ setDedupPassThroughMocks();
609
+ const enqueueMock = vi.fn();
610
+ setFeishuRuntime(
611
+ createPluginRuntimeMock({
612
+ channel: {
613
+ debounce: {
614
+ createInboundDebouncer: <T>(params: {
615
+ onError?: (err: unknown, items: T[]) => void;
616
+ }) => ({
617
+ enqueue: async (item: T) => {
618
+ enqueueMock(item);
619
+ params.onError?.(new Error("dispatch failed"), [item]);
620
+ },
621
+ flushKey: async () => {},
622
+ }),
623
+ resolveInboundDebounceMs,
624
+ },
625
+ text: {
626
+ hasControlCommand,
627
+ },
628
+ },
629
+ }),
630
+ );
631
+ const onMessage = await setupDebounceMonitor();
632
+ const event = createTextEvent({ messageId: "om_retryable", text: "hello" });
633
+
634
+ await enqueueDebouncedMessage(onMessage, event);
635
+ expect(enqueueMock).toHaveBeenCalledTimes(1);
636
+
637
+ await enqueueDebouncedMessage(onMessage, event);
638
+ expect(enqueueMock).toHaveBeenCalledTimes(2);
639
+ expect(handleFeishuMessageMock).not.toHaveBeenCalled();
640
+ });
641
+
642
+ it("drops duplicate inbound events before they re-enter the debounce pipeline", async () => {
643
+ const onMessage = await setupDebounceMonitor();
644
+ const event = createTextEvent({ messageId: "om_duplicate", text: "hello" });
645
+
646
+ await enqueueDebouncedMessage(onMessage, event);
647
+ await vi.advanceTimersByTimeAsync(25);
648
+ await enqueueDebouncedMessage(onMessage, event);
649
+ await vi.advanceTimersByTimeAsync(25);
650
+
651
+ expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
610
652
  });
611
653
  });
@@ -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);