@openclaw/feishu 2026.3.12 → 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 +115 -22
  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 +798 -786
  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 +77 -25
  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 +76 -35
  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 +413 -87
  91. package/src/media.ts +488 -154
  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 +220 -313
  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 +194 -92
  113. package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
  114. package/src/monitor.startup.test.ts +24 -36
  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 +297 -39
  120. package/src/monitor.ts +15 -10
  121. package/src/monitor.webhook-e2e.test.ts +272 -0
  122. package/src/monitor.webhook-security.test.ts +125 -91
  123. package/src/monitor.webhook.test-helpers.ts +116 -0
  124. package/src/outbound-runtime-api.ts +1 -0
  125. package/src/outbound.test.ts +627 -53
  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 +122 -118
  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 +23 -60
  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 +721 -168
  142. package/src/reply-dispatcher.ts +422 -172
  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 +127 -42
  153. package/src/send.test.ts +386 -4
  154. package/src/send.ts +486 -164
  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,35 +1,22 @@
1
- import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
1
+ import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import type { ClawdbotConfig } from "../runtime-api.js";
3
4
  import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
4
5
 
5
6
  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
7
 
27
8
  vi.mock("./probe.js", () => ({
28
9
  probeFeishu: probeFeishuMock,
29
10
  }));
30
11
 
31
- vi.mock("./client.js", () => feishuClientMockModule);
32
- vi.mock("./runtime.js", () => feishuRuntimeMockModule);
12
+ vi.mock("./client.js", async () => {
13
+ const { createFeishuClientMockModule } = await import("./monitor.test-mocks.js");
14
+ return createFeishuClientMockModule();
15
+ });
16
+ vi.mock("./runtime.js", async () => {
17
+ const { createFeishuRuntimeMockModule } = await import("./monitor.test-mocks.js");
18
+ return createFeishuRuntimeMockModule();
19
+ });
33
20
 
34
21
  function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig {
35
22
  return {
@@ -52,6 +39,15 @@ function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig
52
39
  } as ClawdbotConfig;
53
40
  }
54
41
 
42
+ async function waitForStartedAccount(started: string[], accountId: string) {
43
+ await vi.waitFor(
44
+ () => {
45
+ expect(started).toContain(accountId);
46
+ },
47
+ { timeout: 10_000 },
48
+ );
49
+ }
50
+
55
51
  afterEach(() => {
56
52
  stopFeishuMonitor();
57
53
  });
@@ -81,9 +77,7 @@ describe("Feishu monitor startup preflight", () => {
81
77
  });
82
78
 
83
79
  try {
84
- await Promise.resolve();
85
- await Promise.resolve();
86
-
80
+ await waitForStartedAccount(started, "alpha");
87
81
  expect(started).toEqual(["alpha"]);
88
82
  expect(maxInFlight).toBe(1);
89
83
  } finally {
@@ -116,10 +110,7 @@ describe("Feishu monitor startup preflight", () => {
116
110
  });
117
111
 
118
112
  try {
119
- for (let i = 0; i < 10 && !started.includes("beta"); i += 1) {
120
- await Promise.resolve();
121
- }
122
-
113
+ await waitForStartedAccount(started, "beta");
123
114
  expect(started).toEqual(["alpha", "beta"]);
124
115
  expect(started.filter((accountId) => accountId === "alpha")).toHaveLength(1);
125
116
  } finally {
@@ -145,7 +136,7 @@ describe("Feishu monitor startup preflight", () => {
145
136
  });
146
137
 
147
138
  const abortController = new AbortController();
148
- const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
139
+ const runtime = createNonExitingRuntimeEnv();
149
140
  const monitorPromise = monitorFeishuProvider({
150
141
  config: buildMultiAccountWebsocketConfig(["alpha", "beta"]),
151
142
  runtime,
@@ -153,10 +144,7 @@ describe("Feishu monitor startup preflight", () => {
153
144
  });
154
145
 
155
146
  try {
156
- for (let i = 0; i < 10 && !started.includes("beta"); i += 1) {
157
- await Promise.resolve();
158
- }
159
-
147
+ await waitForStartedAccount(started, "beta");
160
148
  expect(started).toEqual(["alpha", "beta"]);
161
149
  expect(runtime.error).toHaveBeenCalledWith(
162
150
  expect.stringContaining("bot info probe timed out"),
@@ -190,7 +178,7 @@ describe("Feishu monitor startup preflight", () => {
190
178
  });
191
179
 
192
180
  try {
193
- await Promise.resolve();
181
+ await waitForStartedAccount(started, "alpha");
194
182
  expect(started).toEqual(["alpha"]);
195
183
 
196
184
  abortController.abort();
@@ -1,8 +1,26 @@
1
- import type { RuntimeEnv } from "openclaw/plugin-sdk/feishu";
1
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
2
+ import type { RuntimeEnv } from "../runtime-api.js";
2
3
  import { probeFeishu } from "./probe.js";
3
4
  import type { ResolvedFeishuAccount } from "./types.js";
4
5
 
5
- export const FEISHU_STARTUP_BOT_INFO_TIMEOUT_MS = 10_000;
6
+ const FEISHU_STARTUP_BOT_INFO_TIMEOUT_DEFAULT_MS = 30_000;
7
+ const FEISHU_STARTUP_BOT_INFO_TIMEOUT_ENV = "OPENCLAW_FEISHU_STARTUP_PROBE_TIMEOUT_MS";
8
+
9
+ function resolveStartupProbeTimeoutMs(): number {
10
+ const raw = process.env[FEISHU_STARTUP_BOT_INFO_TIMEOUT_ENV];
11
+ if (raw) {
12
+ const parsed = Number(raw);
13
+ if (Number.isFinite(parsed) && parsed > 0) {
14
+ return Math.floor(parsed);
15
+ }
16
+ console.warn(
17
+ `[feishu] ${FEISHU_STARTUP_BOT_INFO_TIMEOUT_ENV}="${raw}" is invalid; using default ${FEISHU_STARTUP_BOT_INFO_TIMEOUT_DEFAULT_MS}ms`,
18
+ );
19
+ }
20
+ return FEISHU_STARTUP_BOT_INFO_TIMEOUT_DEFAULT_MS;
21
+ }
22
+
23
+ const FEISHU_STARTUP_BOT_INFO_TIMEOUT_MS = resolveStartupProbeTimeoutMs();
6
24
 
7
25
  type FetchBotOpenIdOptions = {
8
26
  runtime?: RuntimeEnv;
@@ -16,13 +34,12 @@ export type FeishuMonitorBotIdentity = {
16
34
  };
17
35
 
18
36
  function isTimeoutErrorMessage(message: string | undefined): boolean {
19
- return message?.toLowerCase().includes("timeout") || message?.toLowerCase().includes("timed out")
20
- ? true
21
- : false;
37
+ const lower = normalizeLowercaseStringOrEmpty(message);
38
+ return lower.includes("timeout") || lower.includes("timed out");
22
39
  }
23
40
 
24
41
  function isAbortErrorMessage(message: string | undefined): boolean {
25
- return message?.toLowerCase().includes("aborted") ?? false;
42
+ return normalizeLowercaseStringOrEmpty(message).includes("aborted");
26
43
  }
27
44
 
28
45
  export async function fetchBotIdentityForMonitor(
@@ -42,11 +59,12 @@ export async function fetchBotIdentityForMonitor(
42
59
  return { botOpenId: result.botOpenId, botName: result.botName };
43
60
  }
44
61
 
45
- if (options.abortSignal?.aborted || isAbortErrorMessage(result.error)) {
62
+ const probeError = result.error ?? undefined;
63
+ if (options.abortSignal?.aborted || isAbortErrorMessage(probeError)) {
46
64
  return {};
47
65
  }
48
66
 
49
- if (isTimeoutErrorMessage(result.error)) {
67
+ if (isTimeoutErrorMessage(probeError)) {
50
68
  const error = options.runtime?.error ?? console.error;
51
69
  error(
52
70
  `feishu[${account.accountId}]: bot info probe timed out after ${timeoutMs}ms; continuing startup`,
@@ -54,11 +72,3 @@ export async function fetchBotIdentityForMonitor(
54
72
  }
55
73
  return {};
56
74
  }
57
-
58
- export async function fetchBotOpenIdForMonitor(
59
- account: ResolvedFeishuAccount,
60
- options: FetchBotOpenIdOptions = {},
61
- ): Promise<string | undefined> {
62
- const identity = await fetchBotIdentityForMonitor(account, options);
63
- return identity.botOpenId;
64
- }
@@ -1,20 +1,20 @@
1
- import * as http from "http";
2
- import * as Lark from "@larksuiteoapi/node-sdk";
1
+ import * as http from "node:http";
2
+ import type * as Lark from "@larksuiteoapi/node-sdk";
3
3
  import {
4
4
  createFixedWindowRateLimiter,
5
5
  createWebhookAnomalyTracker,
6
6
  type RuntimeEnv,
7
7
  WEBHOOK_ANOMALY_COUNTER_DEFAULTS as WEBHOOK_ANOMALY_COUNTER_DEFAULTS_FROM_SDK,
8
8
  WEBHOOK_RATE_LIMIT_DEFAULTS as WEBHOOK_RATE_LIMIT_DEFAULTS_FROM_SDK,
9
- } from "openclaw/plugin-sdk/feishu";
9
+ } from "./monitor-state-runtime-api.js";
10
10
 
11
11
  export const wsClients = new Map<string, Lark.WSClient>();
12
12
  export const httpServers = new Map<string, http.Server>();
13
13
  export const botOpenIds = new Map<string, string>();
14
14
  export const botNames = new Map<string, string>();
15
15
 
16
- export const FEISHU_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
17
- export const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
16
+ export const FEISHU_WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
17
+ export const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 5_000;
18
18
 
19
19
  type WebhookRateLimitDefaults = {
20
20
  windowMs: number;
@@ -104,6 +104,17 @@ const feishuWebhookAnomalyTracker = createWebhookAnomalyTracker({
104
104
  logEvery: feishuWebhookAnomalyDefaults.logEvery,
105
105
  });
106
106
 
107
+ function closeWsClient(client: Lark.WSClient | undefined): void {
108
+ if (!client) {
109
+ return;
110
+ }
111
+ try {
112
+ client.close();
113
+ } catch {
114
+ /* Best-effort cleanup */
115
+ }
116
+ }
117
+
107
118
  export function clearFeishuWebhookRateLimitStateForTest(): void {
108
119
  feishuWebhookRateLimiter.clear();
109
120
  feishuWebhookAnomalyTracker.clear();
@@ -134,6 +145,7 @@ export function recordWebhookStatus(
134
145
 
135
146
  export function stopFeishuMonitorState(accountId?: string): void {
136
147
  if (accountId) {
148
+ closeWsClient(wsClients.get(accountId));
137
149
  wsClients.delete(accountId);
138
150
  const server = httpServers.get(accountId);
139
151
  if (server) {
@@ -145,6 +157,9 @@ export function stopFeishuMonitorState(accountId?: string): void {
145
157
  return;
146
158
  }
147
159
 
160
+ for (const client of wsClients.values()) {
161
+ closeWsClient(client);
162
+ }
148
163
  wsClients.clear();
149
164
  for (const server of httpServers.values()) {
150
165
  server.close();
@@ -0,0 +1,18 @@
1
+ export class FeishuRetryableSyntheticEventError extends Error {
2
+ constructor(message: string, options?: ErrorOptions) {
3
+ super(message, options);
4
+ this.name = "FeishuRetryableSyntheticEventError";
5
+ }
6
+ }
7
+
8
+ export function isFeishuRetryableSyntheticEventError(
9
+ error: unknown,
10
+ ): error is FeishuRetryableSyntheticEventError {
11
+ return (
12
+ error instanceof FeishuRetryableSyntheticEventError ||
13
+ (typeof error === "object" &&
14
+ error !== null &&
15
+ "name" in error &&
16
+ error.name === "FeishuRetryableSyntheticEventError")
17
+ );
18
+ }
@@ -1,11 +1,11 @@
1
1
  import { vi } from "vitest";
2
2
 
3
3
  export function createFeishuClientMockModule(): {
4
- createFeishuWSClient: () => { start: () => void };
4
+ createFeishuWSClient: () => { start: () => void; close: () => void };
5
5
  createEventDispatcher: () => { register: () => void };
6
6
  } {
7
7
  return {
8
- createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
8
+ createFeishuWSClient: vi.fn(() => ({ start: vi.fn(), close: vi.fn() })),
9
9
  createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
10
10
  };
11
11
  }
@@ -1,11 +1,15 @@
1
- import * as http from "http";
1
+ import crypto from "node:crypto";
2
+ import * as http from "node:http";
2
3
  import * as Lark from "@larksuiteoapi/node-sdk";
4
+ import { waitForAbortableDelay } from "./async.js";
5
+ import { createFeishuWSClient } from "./client.js";
3
6
  import {
4
7
  applyBasicWebhookRequestGuards,
5
8
  type RuntimeEnv,
6
9
  installRequestBodyLimitGuard,
7
- } from "openclaw/plugin-sdk/feishu";
8
- import { createFeishuWSClient } from "./client.js";
10
+ readWebhookBodyOrReject,
11
+ safeEqualSecret,
12
+ } from "./monitor-transport-runtime-api.js";
9
13
  import {
10
14
  botNames,
11
15
  botOpenIds,
@@ -18,7 +22,7 @@ import {
18
22
  } from "./monitor.state.js";
19
23
  import type { ResolvedFeishuAccount } from "./types.js";
20
24
 
21
- export type MonitorTransportParams = {
25
+ type MonitorTransportParams = {
22
26
  account: ResolvedFeishuAccount;
23
27
  accountId: string;
24
28
  runtime?: RuntimeEnv;
@@ -26,6 +30,164 @@ export type MonitorTransportParams = {
26
30
  eventDispatcher: Lark.EventDispatcher;
27
31
  };
28
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
+
40
+ function isFeishuWebhookPayload(value: unknown): value is Record<string, unknown> {
41
+ return !!value && typeof value === "object" && !Array.isArray(value);
42
+ }
43
+
44
+ function buildFeishuWebhookEnvelope(
45
+ req: http.IncomingMessage,
46
+ payload: Record<string, unknown>,
47
+ ): Record<string, unknown> {
48
+ return Object.assign(Object.create({ headers: req.headers }), payload) as Record<string, unknown>;
49
+ }
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
+
60
+ function isFeishuWebhookSignatureValid(params: {
61
+ headers: http.IncomingHttpHeaders;
62
+ rawBody: string;
63
+ encryptKey?: string;
64
+ }): boolean {
65
+ const encryptKey = params.encryptKey?.trim();
66
+ if (!encryptKey) {
67
+ return false;
68
+ }
69
+
70
+ const timestampHeader = params.headers["x-lark-request-timestamp"];
71
+ const nonceHeader = params.headers["x-lark-request-nonce"];
72
+ const signatureHeader = params.headers["x-lark-signature"];
73
+ const timestamp = Array.isArray(timestampHeader) ? timestampHeader[0] : timestampHeader;
74
+ const nonce = Array.isArray(nonceHeader) ? nonceHeader[0] : nonceHeader;
75
+ const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader;
76
+ if (!timestamp || !nonce || !signature) {
77
+ return false;
78
+ }
79
+
80
+ const computedSignature = crypto
81
+ .createHash("sha256")
82
+ .update(timestamp + nonce + encryptKey + params.rawBody)
83
+ .digest("hex");
84
+ return safeEqualSecret(computedSignature, signature);
85
+ }
86
+
87
+ function respondText(res: http.ServerResponse, statusCode: number, body: string): void {
88
+ res.statusCode = statusCode;
89
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
90
+ res.end(body);
91
+ }
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
+
29
191
  export async function monitorWebSocket({
30
192
  account,
31
193
  accountId,
@@ -34,41 +196,81 @@ export async function monitorWebSocket({
34
196
  eventDispatcher,
35
197
  }: MonitorTransportParams): Promise<void> {
36
198
  const log = runtime?.log ?? console.log;
37
- log(`feishu[${accountId}]: starting WebSocket connection...`);
38
-
39
- const wsClient = createFeishuWSClient(account);
40
- wsClients.set(accountId, wsClient);
41
-
42
- return new Promise((resolve, reject) => {
43
- const cleanup = () => {
44
- wsClients.delete(accountId);
45
- botOpenIds.delete(accountId);
46
- botNames.delete(accountId);
47
- };
48
-
49
- const handleAbort = () => {
50
- log(`feishu[${accountId}]: abort signal received, stopping`);
51
- cleanup();
52
- resolve();
53
- };
199
+ const error = runtime?.error ?? console.error;
54
200
 
201
+ let attempt = 0;
202
+ while (true) {
55
203
  if (abortSignal?.aborted) {
56
- cleanup();
57
- resolve();
58
- return;
204
+ break;
59
205
  }
60
206
 
61
- abortSignal?.addEventListener("abort", handleAbort, { once: true });
62
-
207
+ let wsClient: Lark.WSClient | undefined;
63
208
  try {
64
- 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;
65
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
+ }
66
256
  } catch (err) {
67
- cleanup();
68
- abortSignal?.removeEventListener("abort", handleAbort);
69
- 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
+ }
70
271
  }
71
- });
272
+ }
273
+ cleanupFeishuWsClient({ accountId, wsClient: undefined, error, clearIdentity: true });
72
274
  }
73
275
 
74
276
  export async function monitorWebhook({
@@ -80,6 +282,10 @@ export async function monitorWebhook({
80
282
  }: MonitorTransportParams): Promise<void> {
81
283
  const log = runtime?.log ?? console.log;
82
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
+ }
83
289
 
84
290
  const port = account.config.webhookPort ?? 3000;
85
291
  const path = account.config.webhookPath ?? "/feishu/events";
@@ -88,7 +294,6 @@ export async function monitorWebhook({
88
294
  log(`feishu[${accountId}]: starting Webhook server on ${host}:${port}, path ${path}...`);
89
295
 
90
296
  const server = http.createServer();
91
- const webhookHandler = Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true });
92
297
 
93
298
  server.on("request", (req, res) => {
94
299
  res.on("finish", () => {
@@ -118,15 +323,68 @@ export async function monitorWebhook({
118
323
  return;
119
324
  }
120
325
 
121
- void Promise.resolve(webhookHandler(req, res))
122
- .catch((err) => {
123
- if (!guard.isTripped()) {
124
- error(`feishu[${accountId}]: webhook handler error: ${String(err)}`);
326
+ void (async () => {
327
+ try {
328
+ const body = await readWebhookBodyOrReject({
329
+ req,
330
+ res,
331
+ maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES,
332
+ timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
333
+ profile: "pre-auth",
334
+ });
335
+ if (!body.ok || res.writableEnded) {
336
+ return;
125
337
  }
126
- })
127
- .finally(() => {
338
+ if (guard.isTripped()) {
339
+ return;
340
+ }
341
+ const rawBody = body.value;
342
+
343
+ // Reject invalid signatures before any JSON parsing to keep the auth boundary strict.
344
+ if (
345
+ !isFeishuWebhookSignatureValid({
346
+ headers: req.headers,
347
+ rawBody,
348
+ encryptKey,
349
+ })
350
+ ) {
351
+ respondText(res, 401, "Invalid signature");
352
+ return;
353
+ }
354
+
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,
363
+ });
364
+ if (isChallenge) {
365
+ res.statusCode = 200;
366
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
367
+ res.end(JSON.stringify(challenge));
368
+ return;
369
+ }
370
+
371
+ const value = await eventDispatcher.invoke(buildFeishuWebhookEnvelope(req, payload), {
372
+ needCheck: false,
373
+ });
374
+ if (!res.headersSent) {
375
+ res.statusCode = 200;
376
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
377
+ res.end(JSON.stringify(value));
378
+ }
379
+ } catch (err) {
380
+ error(`feishu[${accountId}]: webhook handler error: ${String(err)}`);
381
+ if (!res.headersSent) {
382
+ respondText(res, 500, "Internal Server Error");
383
+ }
384
+ } finally {
128
385
  guard.dispose();
129
- });
386
+ }
387
+ })();
130
388
  });
131
389
 
132
390
  httpServers.set(accountId, server);