@openclaw/feishu 2026.3.2 → 2026.3.7

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 (70) hide show
  1. package/index.ts +2 -2
  2. package/package.json +1 -1
  3. package/src/accounts.test.ts +199 -13
  4. package/src/accounts.ts +45 -17
  5. package/src/bitable.ts +40 -28
  6. package/src/bot.checkBotMentioned.test.ts +8 -0
  7. package/src/bot.stripBotMention.test.ts +118 -22
  8. package/src/bot.test.ts +516 -9
  9. package/src/bot.ts +366 -109
  10. package/src/card-action.ts +1 -1
  11. package/src/channel.test.ts +1 -1
  12. package/src/channel.ts +52 -64
  13. package/src/chat.test.ts +2 -2
  14. package/src/chat.ts +1 -1
  15. package/src/client.test.ts +207 -4
  16. package/src/client.ts +70 -5
  17. package/src/config-schema.test.ts +14 -6
  18. package/src/config-schema.ts +5 -1
  19. package/src/dedup.ts +1 -1
  20. package/src/directory.test.ts +40 -0
  21. package/src/directory.ts +29 -50
  22. package/src/docx-batch-insert.test.ts +90 -0
  23. package/src/docx-batch-insert.ts +8 -11
  24. package/src/docx.account-selection.test.ts +3 -3
  25. package/src/docx.ts +1 -1
  26. package/src/drive.ts +13 -17
  27. package/src/dynamic-agent.ts +1 -1
  28. package/src/feishu-command-handler.ts +59 -0
  29. package/src/media.test.ts +60 -13
  30. package/src/media.ts +23 -9
  31. package/src/monitor.account.ts +19 -8
  32. package/src/monitor.reaction.test.ts +111 -105
  33. package/src/monitor.startup.test.ts +11 -10
  34. package/src/monitor.startup.ts +20 -7
  35. package/src/monitor.state.ts +4 -1
  36. package/src/monitor.test-mocks.ts +42 -9
  37. package/src/monitor.transport.ts +4 -1
  38. package/src/monitor.ts +4 -4
  39. package/src/monitor.webhook-security.test.ts +8 -23
  40. package/src/onboarding.status.test.ts +1 -1
  41. package/src/onboarding.test.ts +143 -0
  42. package/src/onboarding.ts +86 -71
  43. package/src/outbound.test.ts +178 -0
  44. package/src/outbound.ts +39 -6
  45. package/src/perm.ts +11 -15
  46. package/src/policy.test.ts +40 -0
  47. package/src/policy.ts +9 -10
  48. package/src/probe.test.ts +18 -18
  49. package/src/reactions.ts +1 -1
  50. package/src/reply-dispatcher.test.ts +175 -0
  51. package/src/reply-dispatcher.ts +69 -21
  52. package/src/runtime.ts +1 -1
  53. package/src/secret-input.ts +8 -14
  54. package/src/send-message.ts +71 -0
  55. package/src/send-target.test.ts +1 -1
  56. package/src/send-target.ts +1 -1
  57. package/src/send.reply-fallback.test.ts +74 -0
  58. package/src/send.test.ts +1 -1
  59. package/src/send.ts +88 -49
  60. package/src/streaming-card.test.ts +54 -0
  61. package/src/streaming-card.ts +96 -28
  62. package/src/targets.ts +5 -1
  63. package/src/tool-account-routing.test.ts +3 -3
  64. package/src/tool-account.ts +1 -1
  65. package/src/tool-factory-test-harness.ts +1 -1
  66. package/src/tool-result.test.ts +32 -0
  67. package/src/tool-result.ts +14 -0
  68. package/src/types.ts +2 -3
  69. package/src/typing.ts +1 -1
  70. package/src/wiki.ts +15 -19
package/src/channel.ts CHANGED
@@ -1,12 +1,16 @@
1
- import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
2
1
  import {
3
- buildBaseChannelStatusSummary,
2
+ collectAllowlistProviderRestrictSendersWarnings,
3
+ formatAllowFromLowercase,
4
+ mapAllowFromEntries,
5
+ } from "openclaw/plugin-sdk/compat";
6
+ import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
7
+ import {
8
+ buildProbeChannelStatusSummary,
9
+ buildRuntimeAccountStatusSnapshot,
4
10
  createDefaultChannelRuntimeState,
5
11
  DEFAULT_ACCOUNT_ID,
6
12
  PAIRING_APPROVED_MESSAGE,
7
- resolveAllowlistProviderRuntimeGroupPolicy,
8
- resolveDefaultGroupPolicy,
9
- } from "openclaw/plugin-sdk";
13
+ } from "openclaw/plugin-sdk/feishu";
10
14
  import {
11
15
  resolveFeishuAccount,
12
16
  resolveFeishuCredentials,
@@ -54,6 +58,30 @@ const secretInputJsonSchema = {
54
58
  ],
55
59
  } as const;
56
60
 
61
+ function setFeishuNamedAccountEnabled(
62
+ cfg: ClawdbotConfig,
63
+ accountId: string,
64
+ enabled: boolean,
65
+ ): ClawdbotConfig {
66
+ const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
67
+ return {
68
+ ...cfg,
69
+ channels: {
70
+ ...cfg.channels,
71
+ feishu: {
72
+ ...feishuCfg,
73
+ accounts: {
74
+ ...feishuCfg?.accounts,
75
+ [accountId]: {
76
+ ...feishuCfg?.accounts?.[accountId],
77
+ enabled,
78
+ },
79
+ },
80
+ },
81
+ },
82
+ };
83
+ }
84
+
57
85
  export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
58
86
  id: "feishu",
59
87
  meta: {
@@ -88,6 +116,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
88
116
  groups: {
89
117
  resolveToolPolicy: resolveFeishuGroupToolPolicy,
90
118
  },
119
+ mentions: {
120
+ stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'],
121
+ },
91
122
  reload: { configPrefixes: ["channels.feishu"] },
92
123
  configSchema: {
93
124
  schema: {
@@ -175,23 +206,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
175
206
  }
176
207
 
177
208
  // For named accounts, set enabled in accounts[accountId]
178
- const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
179
- return {
180
- ...cfg,
181
- channels: {
182
- ...cfg.channels,
183
- feishu: {
184
- ...feishuCfg,
185
- accounts: {
186
- ...feishuCfg?.accounts,
187
- [accountId]: {
188
- ...feishuCfg?.accounts?.[accountId],
189
- enabled,
190
- },
191
- },
192
- },
193
- },
194
- };
209
+ return setFeishuNamedAccountEnabled(cfg, accountId, enabled);
195
210
  },
196
211
  deleteAccount: ({ cfg, accountId }) => {
197
212
  const isDefault = accountId === DEFAULT_ACCOUNT_ID;
@@ -236,28 +251,23 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
236
251
  }),
237
252
  resolveAllowFrom: ({ cfg, accountId }) => {
238
253
  const account = resolveFeishuAccount({ cfg, accountId });
239
- return (account.config?.allowFrom ?? []).map((entry) => String(entry));
254
+ return mapAllowFromEntries(account.config?.allowFrom);
240
255
  },
241
- formatAllowFrom: ({ allowFrom }) =>
242
- allowFrom
243
- .map((entry) => String(entry).trim())
244
- .filter(Boolean)
245
- .map((entry) => entry.toLowerCase()),
256
+ formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }),
246
257
  },
247
258
  security: {
248
259
  collectWarnings: ({ cfg, accountId }) => {
249
260
  const account = resolveFeishuAccount({ cfg, accountId });
250
261
  const feishuCfg = account.config;
251
- const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
252
- const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
262
+ return collectAllowlistProviderRestrictSendersWarnings({
263
+ cfg,
253
264
  providerConfigPresent: cfg.channels?.feishu !== undefined,
254
- groupPolicy: feishuCfg?.groupPolicy,
255
- defaultGroupPolicy,
265
+ configuredGroupPolicy: feishuCfg?.groupPolicy,
266
+ surface: `Feishu[${account.accountId}] groups`,
267
+ openScope: "any member",
268
+ groupPolicyPath: "channels.feishu.groupPolicy",
269
+ groupAllowFromPath: "channels.feishu.groupAllowFrom",
256
270
  });
257
- if (groupPolicy !== "open") return [];
258
- return [
259
- `- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
260
- ];
261
271
  },
262
272
  },
263
273
  setup: {
@@ -278,23 +288,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
278
288
  };
279
289
  }
280
290
 
281
- const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
282
- return {
283
- ...cfg,
284
- channels: {
285
- ...cfg.channels,
286
- feishu: {
287
- ...feishuCfg,
288
- accounts: {
289
- ...feishuCfg?.accounts,
290
- [accountId]: {
291
- ...feishuCfg?.accounts?.[accountId],
292
- enabled: true,
293
- },
294
- },
295
- },
296
- },
297
- };
291
+ return setFeishuNamedAccountEnabled(cfg, accountId, true);
298
292
  },
299
293
  },
300
294
  onboarding: feishuOnboardingAdapter,
@@ -339,12 +333,10 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
339
333
  outbound: feishuOutbound,
340
334
  status: {
341
335
  defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),
342
- buildChannelSummary: ({ snapshot }) => ({
343
- ...buildBaseChannelStatusSummary(snapshot),
344
- port: snapshot.port ?? null,
345
- probe: snapshot.probe,
346
- lastProbeAt: snapshot.lastProbeAt ?? null,
347
- }),
336
+ buildChannelSummary: ({ snapshot }) =>
337
+ buildProbeChannelStatusSummary(snapshot, {
338
+ port: snapshot.port ?? null,
339
+ }),
348
340
  probeAccount: async ({ account }) => await probeFeishu(account),
349
341
  buildAccountSnapshot: ({ account, runtime, probe }) => ({
350
342
  accountId: account.accountId,
@@ -353,12 +345,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
353
345
  name: account.name,
354
346
  appId: account.appId,
355
347
  domain: account.domain,
356
- running: runtime?.running ?? false,
357
- lastStartAt: runtime?.lastStartAt ?? null,
358
- lastStopAt: runtime?.lastStopAt ?? null,
359
- lastError: runtime?.lastError ?? null,
348
+ ...buildRuntimeAccountStatusSnapshot({ runtime, probe }),
360
349
  port: runtime?.port ?? null,
361
- probe,
362
350
  }),
363
351
  },
364
352
  gateway: {
package/src/chat.test.ts CHANGED
@@ -29,7 +29,7 @@ describe("registerFeishuChatTools", () => {
29
29
  feishu: {
30
30
  enabled: true,
31
31
  appId: "app_id",
32
- appSecret: "app_secret",
32
+ appSecret: "app_secret", // pragma: allowlist secret
33
33
  tools: { chat: true },
34
34
  },
35
35
  },
@@ -76,7 +76,7 @@ describe("registerFeishuChatTools", () => {
76
76
  feishu: {
77
77
  enabled: true,
78
78
  appId: "app_id",
79
- appSecret: "app_secret",
79
+ appSecret: "app_secret", // pragma: allowlist secret
80
80
  tools: { chat: false },
81
81
  },
82
82
  },
package/src/chat.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type * as Lark from "@larksuiteoapi/node-sdk";
2
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
3
3
  import { listEnabledFeishuAccounts } from "./accounts.js";
4
4
  import { FeishuChatSchema, type FeishuChatParams } from "./chat-schema.js";
5
5
  import { createFeishuClient } from "./client.js";
@@ -12,6 +12,17 @@ const httpsProxyAgentCtorMock = vi.hoisted(() =>
12
12
  }),
13
13
  );
14
14
 
15
+ const mockBaseHttpInstance = vi.hoisted(() => ({
16
+ request: vi.fn().mockResolvedValue({}),
17
+ get: vi.fn().mockResolvedValue({}),
18
+ post: vi.fn().mockResolvedValue({}),
19
+ put: vi.fn().mockResolvedValue({}),
20
+ patch: vi.fn().mockResolvedValue({}),
21
+ delete: vi.fn().mockResolvedValue({}),
22
+ head: vi.fn().mockResolvedValue({}),
23
+ options: vi.fn().mockResolvedValue({}),
24
+ }));
25
+
15
26
  vi.mock("@larksuiteoapi/node-sdk", () => ({
16
27
  AppType: { SelfBuild: "self" },
17
28
  Domain: { Feishu: "https://open.feishu.cn", Lark: "https://open.larksuite.com" },
@@ -19,18 +30,28 @@ vi.mock("@larksuiteoapi/node-sdk", () => ({
19
30
  Client: vi.fn(),
20
31
  WSClient: wsClientCtorMock,
21
32
  EventDispatcher: vi.fn(),
33
+ defaultHttpInstance: mockBaseHttpInstance,
22
34
  }));
23
35
 
24
36
  vi.mock("https-proxy-agent", () => ({
25
37
  HttpsProxyAgent: httpsProxyAgentCtorMock,
26
38
  }));
27
39
 
28
- import { createFeishuWSClient } from "./client.js";
40
+ import { Client as LarkClient } from "@larksuiteoapi/node-sdk";
41
+ import {
42
+ createFeishuClient,
43
+ createFeishuWSClient,
44
+ clearClientCache,
45
+ FEISHU_HTTP_TIMEOUT_MS,
46
+ FEISHU_HTTP_TIMEOUT_MAX_MS,
47
+ FEISHU_HTTP_TIMEOUT_ENV_VAR,
48
+ } from "./client.js";
29
49
 
30
50
  const proxyEnvKeys = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"] as const;
31
51
  type ProxyEnvKey = (typeof proxyEnvKeys)[number];
32
52
 
33
53
  let priorProxyEnv: Partial<Record<ProxyEnvKey, string | undefined>> = {};
54
+ let priorFeishuTimeoutEnv: string | undefined;
34
55
 
35
56
  const baseAccount: ResolvedFeishuAccount = {
36
57
  accountId: "main",
@@ -38,7 +59,7 @@ const baseAccount: ResolvedFeishuAccount = {
38
59
  enabled: true,
39
60
  configured: true,
40
61
  appId: "app_123",
41
- appSecret: "secret_123",
62
+ appSecret: "secret_123", // pragma: allowlist secret
42
63
  domain: "feishu",
43
64
  config: {} as FeishuConfig,
44
65
  };
@@ -50,6 +71,8 @@ function firstWsClientOptions(): { agent?: unknown } {
50
71
 
51
72
  beforeEach(() => {
52
73
  priorProxyEnv = {};
74
+ priorFeishuTimeoutEnv = process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
75
+ delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
53
76
  for (const key of proxyEnvKeys) {
54
77
  priorProxyEnv[key] = process.env[key];
55
78
  delete process.env[key];
@@ -66,6 +89,171 @@ afterEach(() => {
66
89
  process.env[key] = value;
67
90
  }
68
91
  }
92
+ if (priorFeishuTimeoutEnv === undefined) {
93
+ delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
94
+ } else {
95
+ process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = priorFeishuTimeoutEnv;
96
+ }
97
+ });
98
+
99
+ describe("createFeishuClient HTTP timeout", () => {
100
+ beforeEach(() => {
101
+ clearClientCache();
102
+ });
103
+
104
+ const getLastClientHttpInstance = () => {
105
+ const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
106
+ const lastCall = calls[calls.length - 1]?.[0] as
107
+ | { httpInstance?: { get: (...args: unknown[]) => Promise<unknown> } }
108
+ | undefined;
109
+ return lastCall?.httpInstance;
110
+ };
111
+
112
+ const expectGetCallTimeout = async (timeout: number) => {
113
+ const httpInstance = getLastClientHttpInstance();
114
+ expect(httpInstance).toBeDefined();
115
+ await httpInstance?.get("https://example.com/api");
116
+ expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
117
+ "https://example.com/api",
118
+ expect.objectContaining({ timeout }),
119
+ );
120
+ };
121
+
122
+ it("passes a custom httpInstance with default timeout to Lark.Client", () => {
123
+ createFeishuClient({ appId: "app_1", appSecret: "secret_1", accountId: "timeout-test" }); // pragma: allowlist secret
124
+
125
+ const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
126
+ const lastCall = calls[calls.length - 1][0] as { httpInstance?: unknown };
127
+ expect(lastCall.httpInstance).toBeDefined();
128
+ });
129
+
130
+ it("injects default timeout into HTTP request options", async () => {
131
+ createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" }); // pragma: allowlist secret
132
+
133
+ const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
134
+ const lastCall = calls[calls.length - 1][0] as {
135
+ httpInstance: { post: (...args: unknown[]) => Promise<unknown> };
136
+ };
137
+ const httpInstance = lastCall.httpInstance;
138
+
139
+ await httpInstance.post(
140
+ "https://example.com/api",
141
+ { data: 1 },
142
+ { headers: { "X-Custom": "yes" } },
143
+ );
144
+
145
+ expect(mockBaseHttpInstance.post).toHaveBeenCalledWith(
146
+ "https://example.com/api",
147
+ { data: 1 },
148
+ expect.objectContaining({ timeout: FEISHU_HTTP_TIMEOUT_MS, headers: { "X-Custom": "yes" } }),
149
+ );
150
+ });
151
+
152
+ it("allows explicit timeout override per-request", async () => {
153
+ createFeishuClient({ appId: "app_3", appSecret: "secret_3", accountId: "timeout-override" }); // pragma: allowlist secret
154
+
155
+ const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
156
+ const lastCall = calls[calls.length - 1][0] as {
157
+ httpInstance: { get: (...args: unknown[]) => Promise<unknown> };
158
+ };
159
+ const httpInstance = lastCall.httpInstance;
160
+
161
+ await httpInstance.get("https://example.com/api", { timeout: 5_000 });
162
+
163
+ expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
164
+ "https://example.com/api",
165
+ expect.objectContaining({ timeout: 5_000 }),
166
+ );
167
+ });
168
+
169
+ it("uses config-configured default timeout when provided", async () => {
170
+ createFeishuClient({
171
+ appId: "app_4",
172
+ appSecret: "secret_4", // pragma: allowlist secret
173
+ accountId: "timeout-config",
174
+ config: { httpTimeoutMs: 45_000 },
175
+ });
176
+
177
+ await expectGetCallTimeout(45_000);
178
+ });
179
+
180
+ it("falls back to default timeout when configured timeout is invalid", async () => {
181
+ createFeishuClient({
182
+ appId: "app_5",
183
+ appSecret: "secret_5", // pragma: allowlist secret
184
+ accountId: "timeout-config-invalid",
185
+ config: { httpTimeoutMs: -1 },
186
+ });
187
+
188
+ await expectGetCallTimeout(FEISHU_HTTP_TIMEOUT_MS);
189
+ });
190
+
191
+ it("uses env timeout override when provided and no direct timeout is set", async () => {
192
+ process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = "60000";
193
+
194
+ createFeishuClient({
195
+ appId: "app_8",
196
+ appSecret: "secret_8", // pragma: allowlist secret
197
+ accountId: "timeout-env-override",
198
+ config: { httpTimeoutMs: 45_000 },
199
+ });
200
+
201
+ await expectGetCallTimeout(60_000);
202
+ });
203
+
204
+ it("prefers direct timeout over env override", async () => {
205
+ process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = "60000";
206
+
207
+ createFeishuClient({
208
+ appId: "app_10",
209
+ appSecret: "secret_10", // pragma: allowlist secret
210
+ accountId: "timeout-direct-override",
211
+ httpTimeoutMs: 120_000,
212
+ config: { httpTimeoutMs: 45_000 },
213
+ });
214
+
215
+ await expectGetCallTimeout(120_000);
216
+ });
217
+
218
+ it("clamps env timeout override to max bound", async () => {
219
+ process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = String(FEISHU_HTTP_TIMEOUT_MAX_MS + 123_456);
220
+
221
+ createFeishuClient({
222
+ appId: "app_9",
223
+ appSecret: "secret_9", // pragma: allowlist secret
224
+ accountId: "timeout-env-clamp",
225
+ });
226
+
227
+ await expectGetCallTimeout(FEISHU_HTTP_TIMEOUT_MAX_MS);
228
+ });
229
+
230
+ it("recreates cached client when configured timeout changes", async () => {
231
+ createFeishuClient({
232
+ appId: "app_6",
233
+ appSecret: "secret_6", // pragma: allowlist secret
234
+ accountId: "timeout-cache-change",
235
+ config: { httpTimeoutMs: 30_000 },
236
+ });
237
+ createFeishuClient({
238
+ appId: "app_6",
239
+ appSecret: "secret_6", // pragma: allowlist secret
240
+ accountId: "timeout-cache-change",
241
+ config: { httpTimeoutMs: 45_000 },
242
+ });
243
+
244
+ const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
245
+ expect(calls.length).toBe(2);
246
+
247
+ const lastCall = calls[calls.length - 1][0] as {
248
+ httpInstance: { get: (...args: unknown[]) => Promise<unknown> };
249
+ };
250
+ await lastCall.httpInstance.get("https://example.com/api");
251
+
252
+ expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
253
+ "https://example.com/api",
254
+ expect.objectContaining({ timeout: 45_000 }),
255
+ );
256
+ });
69
257
  });
70
258
 
71
259
  describe("createFeishuWSClient proxy handling", () => {
@@ -77,9 +265,12 @@ describe("createFeishuWSClient proxy handling", () => {
77
265
  expect(options?.agent).toBeUndefined();
78
266
  });
79
267
 
80
- it("prefers HTTPS proxy vars over HTTP proxy vars across runtimes", () => {
268
+ it("uses proxy env precedence: https_proxy first, then HTTPS_PROXY, then http_proxy/HTTP_PROXY", () => {
269
+ // NOTE: On Windows, environment variables are case-insensitive, so it's not
270
+ // possible to set both https_proxy and HTTPS_PROXY to different values.
271
+ // Keep this test cross-platform by asserting precedence via mutually-exclusive
272
+ // setups.
81
273
  process.env.https_proxy = "http://lower-https:8001";
82
- process.env.HTTPS_PROXY = "http://upper-https:8002";
83
274
  process.env.http_proxy = "http://lower-http:8003";
84
275
  process.env.HTTP_PROXY = "http://upper-http:8004";
85
276
 
@@ -108,6 +299,18 @@ describe("createFeishuWSClient proxy handling", () => {
108
299
  expect(options.agent).toEqual({ proxyUrl: expectedHttpsProxy });
109
300
  });
110
301
 
302
+ it("uses HTTPS_PROXY when https_proxy is unset", () => {
303
+ process.env.HTTPS_PROXY = "http://upper-https:8002";
304
+ process.env.http_proxy = "http://lower-http:8003";
305
+
306
+ createFeishuWSClient(baseAccount);
307
+
308
+ expect(httpsProxyAgentCtorMock).toHaveBeenCalledTimes(1);
309
+ expect(httpsProxyAgentCtorMock).toHaveBeenCalledWith("http://upper-https:8002");
310
+ const options = firstWsClientOptions();
311
+ expect(options.agent).toEqual({ proxyUrl: "http://upper-https:8002" });
312
+ });
313
+
111
314
  it("passes HTTP_PROXY to ws client when https vars are unset", () => {
112
315
  process.env.HTTP_PROXY = "http://upper-http:8999";
113
316
 
package/src/client.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import * as Lark from "@larksuiteoapi/node-sdk";
2
2
  import { HttpsProxyAgent } from "https-proxy-agent";
3
- import type { FeishuDomain, ResolvedFeishuAccount } from "./types.js";
3
+ import type { FeishuConfig, FeishuDomain, ResolvedFeishuAccount } from "./types.js";
4
+
5
+ /** Default HTTP timeout for Feishu API requests (30 seconds). */
6
+ export const FEISHU_HTTP_TIMEOUT_MS = 30_000;
7
+ export const FEISHU_HTTP_TIMEOUT_MAX_MS = 300_000;
8
+ export const FEISHU_HTTP_TIMEOUT_ENV_VAR = "OPENCLAW_FEISHU_HTTP_TIMEOUT_MS";
4
9
 
5
10
  function getWsProxyAgent(): HttpsProxyAgent<string> | undefined {
6
11
  const proxyUrl =
@@ -17,7 +22,7 @@ const clientCache = new Map<
17
22
  string,
18
23
  {
19
24
  client: Lark.Client;
20
- config: { appId: string; appSecret: string; domain?: FeishuDomain };
25
+ config: { appId: string; appSecret: string; domain?: FeishuDomain; httpTimeoutMs: number };
21
26
  }
22
27
  >();
23
28
 
@@ -31,6 +36,30 @@ function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
31
36
  return domain.replace(/\/+$/, ""); // Custom URL for private deployment
32
37
  }
33
38
 
39
+ /**
40
+ * Create an HTTP instance that delegates to the Lark SDK's default instance
41
+ * but injects a default request timeout to prevent indefinite hangs
42
+ * (e.g. when the Feishu API is slow, causing per-chat queue deadlocks).
43
+ */
44
+ function createTimeoutHttpInstance(defaultTimeoutMs: number): Lark.HttpInstance {
45
+ const base: Lark.HttpInstance = Lark.defaultHttpInstance as unknown as Lark.HttpInstance;
46
+
47
+ function injectTimeout<D>(opts?: Lark.HttpRequestOptions<D>): Lark.HttpRequestOptions<D> {
48
+ return { timeout: defaultTimeoutMs, ...opts } as Lark.HttpRequestOptions<D>;
49
+ }
50
+
51
+ return {
52
+ request: (opts) => base.request(injectTimeout(opts)),
53
+ get: (url, opts) => base.get(url, injectTimeout(opts)),
54
+ post: (url, data, opts) => base.post(url, data, injectTimeout(opts)),
55
+ put: (url, data, opts) => base.put(url, data, injectTimeout(opts)),
56
+ patch: (url, data, opts) => base.patch(url, data, injectTimeout(opts)),
57
+ delete: (url, opts) => base.delete(url, injectTimeout(opts)),
58
+ head: (url, opts) => base.head(url, injectTimeout(opts)),
59
+ options: (url, opts) => base.options(url, injectTimeout(opts)),
60
+ };
61
+ }
62
+
34
63
  /**
35
64
  * Credentials needed to create a Feishu client.
36
65
  * Both FeishuConfig and ResolvedFeishuAccount satisfy this interface.
@@ -40,14 +69,48 @@ export type FeishuClientCredentials = {
40
69
  appId?: string;
41
70
  appSecret?: string;
42
71
  domain?: FeishuDomain;
72
+ httpTimeoutMs?: number;
73
+ config?: Pick<FeishuConfig, "httpTimeoutMs">;
43
74
  };
44
75
 
76
+ function resolveConfiguredHttpTimeoutMs(creds: FeishuClientCredentials): number {
77
+ const clampTimeout = (value: number): number => {
78
+ const rounded = Math.floor(value);
79
+ return Math.min(Math.max(rounded, 1), FEISHU_HTTP_TIMEOUT_MAX_MS);
80
+ };
81
+
82
+ const fromDirectField = creds.httpTimeoutMs;
83
+ if (
84
+ typeof fromDirectField === "number" &&
85
+ Number.isFinite(fromDirectField) &&
86
+ fromDirectField > 0
87
+ ) {
88
+ return clampTimeout(fromDirectField);
89
+ }
90
+
91
+ const envRaw = process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
92
+ if (envRaw) {
93
+ const envValue = Number(envRaw);
94
+ if (Number.isFinite(envValue) && envValue > 0) {
95
+ return clampTimeout(envValue);
96
+ }
97
+ }
98
+
99
+ const fromConfig = creds.config?.httpTimeoutMs;
100
+ const timeout = fromConfig;
101
+ if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) {
102
+ return FEISHU_HTTP_TIMEOUT_MS;
103
+ }
104
+ return clampTimeout(timeout);
105
+ }
106
+
45
107
  /**
46
108
  * Create or get a cached Feishu client for an account.
47
109
  * Accepts any object with appId, appSecret, and optional domain/accountId.
48
110
  */
49
111
  export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client {
50
112
  const { accountId = "default", appId, appSecret, domain } = creds;
113
+ const defaultHttpTimeoutMs = resolveConfiguredHttpTimeoutMs(creds);
51
114
 
52
115
  if (!appId || !appSecret) {
53
116
  throw new Error(`Feishu credentials not configured for account "${accountId}"`);
@@ -59,23 +122,25 @@ export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client
59
122
  cached &&
60
123
  cached.config.appId === appId &&
61
124
  cached.config.appSecret === appSecret &&
62
- cached.config.domain === domain
125
+ cached.config.domain === domain &&
126
+ cached.config.httpTimeoutMs === defaultHttpTimeoutMs
63
127
  ) {
64
128
  return cached.client;
65
129
  }
66
130
 
67
- // Create new client
131
+ // Create new client with timeout-aware HTTP instance
68
132
  const client = new Lark.Client({
69
133
  appId,
70
134
  appSecret,
71
135
  appType: Lark.AppType.SelfBuild,
72
136
  domain: resolveDomain(domain),
137
+ httpInstance: createTimeoutHttpInstance(defaultHttpTimeoutMs),
73
138
  });
74
139
 
75
140
  // Cache it
76
141
  clientCache.set(accountId, {
77
142
  client,
78
- config: { appId, appSecret, domain },
143
+ config: { appId, appSecret, domain, httpTimeoutMs: defaultHttpTimeoutMs },
79
144
  });
80
145
 
81
146
  return client;
@@ -24,11 +24,19 @@ describe("FeishuConfigSchema webhook validation", () => {
24
24
  expect(result.accounts?.main?.requireMention).toBeUndefined();
25
25
  });
26
26
 
27
+ it("normalizes legacy groupPolicy allowall to open", () => {
28
+ const result = FeishuConfigSchema.parse({
29
+ groupPolicy: "allowall",
30
+ });
31
+
32
+ expect(result.groupPolicy).toBe("open");
33
+ });
34
+
27
35
  it("rejects top-level webhook mode without verificationToken", () => {
28
36
  const result = FeishuConfigSchema.safeParse({
29
37
  connectionMode: "webhook",
30
38
  appId: "cli_top",
31
- appSecret: "secret_top",
39
+ appSecret: "secret_top", // pragma: allowlist secret
32
40
  });
33
41
 
34
42
  expect(result.success).toBe(false);
@@ -44,7 +52,7 @@ describe("FeishuConfigSchema webhook validation", () => {
44
52
  connectionMode: "webhook",
45
53
  verificationToken: "token_top",
46
54
  appId: "cli_top",
47
- appSecret: "secret_top",
55
+ appSecret: "secret_top", // pragma: allowlist secret
48
56
  });
49
57
 
50
58
  expect(result.success).toBe(true);
@@ -56,7 +64,7 @@ describe("FeishuConfigSchema webhook validation", () => {
56
64
  main: {
57
65
  connectionMode: "webhook",
58
66
  appId: "cli_main",
59
- appSecret: "secret_main",
67
+ appSecret: "secret_main", // pragma: allowlist secret
60
68
  },
61
69
  },
62
70
  });
@@ -78,7 +86,7 @@ describe("FeishuConfigSchema webhook validation", () => {
78
86
  main: {
79
87
  connectionMode: "webhook",
80
88
  appId: "cli_main",
81
- appSecret: "secret_main",
89
+ appSecret: "secret_main", // pragma: allowlist secret
82
90
  },
83
91
  },
84
92
  });
@@ -163,7 +171,7 @@ describe("FeishuConfigSchema defaultAccount", () => {
163
171
  const result = FeishuConfigSchema.safeParse({
164
172
  defaultAccount: "router-d",
165
173
  accounts: {
166
- "router-d": { appId: "cli_router", appSecret: "secret_router" },
174
+ "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
167
175
  },
168
176
  });
169
177
 
@@ -174,7 +182,7 @@ describe("FeishuConfigSchema defaultAccount", () => {
174
182
  const result = FeishuConfigSchema.safeParse({
175
183
  defaultAccount: "router-d",
176
184
  accounts: {
177
- backup: { appId: "cli_backup", appSecret: "secret_backup" },
185
+ backup: { appId: "cli_backup", appSecret: "secret_backup" }, // pragma: allowlist secret
178
186
  },
179
187
  });
180
188
 
@@ -4,7 +4,10 @@ export { z };
4
4
  import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
5
5
 
6
6
  const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
7
- const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
7
+ const GroupPolicySchema = z.union([
8
+ z.enum(["open", "allowlist", "disabled"]),
9
+ z.literal("allowall").transform(() => "open" as const),
10
+ ]);
8
11
  const FeishuDomainSchema = z.union([
9
12
  z.enum(["feishu", "lark"]),
10
13
  z.string().url().startsWith("https://"),
@@ -162,6 +165,7 @@ const FeishuSharedConfigShape = {
162
165
  chunkMode: z.enum(["length", "newline"]).optional(),
163
166
  blockStreamingCoalesce: BlockStreamingCoalesceSchema,
164
167
  mediaMaxMb: z.number().positive().optional(),
168
+ httpTimeoutMs: z.number().int().positive().max(300_000).optional(),
165
169
  heartbeat: ChannelHeartbeatVisibilitySchema,
166
170
  renderMode: RenderModeSchema,
167
171
  streaming: StreamingModeSchema,