@nextclaw/channel-plugin-feishu 0.2.12 → 0.2.14

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 (102) hide show
  1. package/README.md +3 -1
  2. package/index.ts +65 -0
  3. package/openclaw.plugin.json +3 -7
  4. package/package.json +33 -9
  5. package/skills/feishu-doc/SKILL.md +211 -0
  6. package/skills/feishu-doc/references/block-types.md +103 -0
  7. package/skills/feishu-drive/SKILL.md +97 -0
  8. package/skills/feishu-perm/SKILL.md +119 -0
  9. package/skills/feishu-wiki/SKILL.md +111 -0
  10. package/src/accounts.test.ts +371 -0
  11. package/src/accounts.ts +244 -0
  12. package/src/async.ts +62 -0
  13. package/src/bitable.ts +725 -0
  14. package/src/bot.card-action.test.ts +63 -0
  15. package/src/bot.checkBotMentioned.test.ts +193 -0
  16. package/src/bot.stripBotMention.test.ts +134 -0
  17. package/src/bot.test.ts +2107 -0
  18. package/src/bot.ts +1556 -0
  19. package/src/card-action.ts +79 -0
  20. package/src/channel.test.ts +48 -0
  21. package/src/channel.ts +369 -0
  22. package/src/chat-schema.ts +24 -0
  23. package/src/chat.test.ts +89 -0
  24. package/src/chat.ts +130 -0
  25. package/src/client.test.ts +324 -0
  26. package/src/client.ts +196 -0
  27. package/src/config-schema.test.ts +247 -0
  28. package/src/config-schema.ts +306 -0
  29. package/src/dedup.ts +203 -0
  30. package/src/directory.test.ts +40 -0
  31. package/src/directory.ts +156 -0
  32. package/src/doc-schema.ts +182 -0
  33. package/src/docx-batch-insert.test.ts +90 -0
  34. package/src/docx-batch-insert.ts +187 -0
  35. package/src/docx-color-text.ts +149 -0
  36. package/src/docx-table-ops.ts +298 -0
  37. package/src/docx.account-selection.test.ts +70 -0
  38. package/src/docx.test.ts +445 -0
  39. package/src/docx.ts +1460 -0
  40. package/src/drive-schema.ts +46 -0
  41. package/src/drive.ts +228 -0
  42. package/src/dynamic-agent.ts +131 -0
  43. package/src/external-keys.test.ts +20 -0
  44. package/src/external-keys.ts +19 -0
  45. package/src/feishu-command-handler.ts +59 -0
  46. package/src/media.test.ts +523 -0
  47. package/src/media.ts +484 -0
  48. package/src/mention.ts +133 -0
  49. package/src/monitor.account.ts +562 -0
  50. package/src/monitor.reaction.test.ts +653 -0
  51. package/src/monitor.startup.test.ts +190 -0
  52. package/src/monitor.startup.ts +64 -0
  53. package/src/monitor.state.defaults.test.ts +46 -0
  54. package/src/monitor.state.ts +155 -0
  55. package/src/monitor.test-mocks.ts +45 -0
  56. package/src/monitor.transport.ts +264 -0
  57. package/src/monitor.ts +95 -0
  58. package/src/monitor.webhook-e2e.test.ts +214 -0
  59. package/src/monitor.webhook-security.test.ts +142 -0
  60. package/src/monitor.webhook.test-helpers.ts +98 -0
  61. package/src/onboarding.status.test.ts +25 -0
  62. package/src/onboarding.test.ts +143 -0
  63. package/src/onboarding.ts +489 -0
  64. package/src/outbound.test.ts +356 -0
  65. package/src/outbound.ts +176 -0
  66. package/src/perm-schema.ts +52 -0
  67. package/src/perm.ts +176 -0
  68. package/src/policy.test.ts +154 -0
  69. package/src/policy.ts +123 -0
  70. package/src/post.test.ts +105 -0
  71. package/src/post.ts +274 -0
  72. package/src/probe.test.ts +270 -0
  73. package/src/probe.ts +156 -0
  74. package/src/reactions.ts +153 -0
  75. package/src/reply-dispatcher.test.ts +513 -0
  76. package/src/reply-dispatcher.ts +397 -0
  77. package/src/runtime.ts +6 -0
  78. package/src/secret-input.ts +13 -0
  79. package/src/send-message.ts +71 -0
  80. package/src/send-result.ts +29 -0
  81. package/src/send-target.test.ts +74 -0
  82. package/src/send-target.ts +29 -0
  83. package/src/send.reply-fallback.test.ts +189 -0
  84. package/src/send.test.ts +168 -0
  85. package/src/send.ts +481 -0
  86. package/src/streaming-card.test.ts +54 -0
  87. package/src/streaming-card.ts +374 -0
  88. package/src/targets.test.ts +70 -0
  89. package/src/targets.ts +107 -0
  90. package/src/tool-account-routing.test.ts +129 -0
  91. package/src/tool-account.ts +70 -0
  92. package/src/tool-factory-test-harness.ts +76 -0
  93. package/src/tool-result.test.ts +32 -0
  94. package/src/tool-result.ts +14 -0
  95. package/src/tools-config.test.ts +21 -0
  96. package/src/tools-config.ts +22 -0
  97. package/src/types.ts +103 -0
  98. package/src/typing.test.ts +144 -0
  99. package/src/typing.ts +210 -0
  100. package/src/wiki-schema.ts +55 -0
  101. package/src/wiki.ts +233 -0
  102. package/index.js +0 -27
package/src/chat.ts ADDED
@@ -0,0 +1,130 @@
1
+ import type * as Lark from "@larksuiteoapi/node-sdk";
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
3
+ import { listEnabledFeishuAccounts } from "./accounts.js";
4
+ import { FeishuChatSchema, type FeishuChatParams } from "./chat-schema.js";
5
+ import { createFeishuClient } from "./client.js";
6
+ import { resolveToolsConfig } from "./tools-config.js";
7
+
8
+ function json(data: unknown) {
9
+ return {
10
+ content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
11
+ details: data,
12
+ };
13
+ }
14
+
15
+ async function getChatInfo(client: Lark.Client, chatId: string) {
16
+ const res = await client.im.chat.get({ path: { chat_id: chatId } });
17
+ if (res.code !== 0) {
18
+ throw new Error(res.msg);
19
+ }
20
+
21
+ const chat = res.data;
22
+ return {
23
+ chat_id: chatId,
24
+ name: chat?.name,
25
+ description: chat?.description,
26
+ owner_id: chat?.owner_id,
27
+ tenant_key: chat?.tenant_key,
28
+ user_count: chat?.user_count,
29
+ chat_mode: chat?.chat_mode,
30
+ chat_type: chat?.chat_type,
31
+ join_message_visibility: chat?.join_message_visibility,
32
+ leave_message_visibility: chat?.leave_message_visibility,
33
+ membership_approval: chat?.membership_approval,
34
+ moderation_permission: chat?.moderation_permission,
35
+ avatar: chat?.avatar,
36
+ };
37
+ }
38
+
39
+ async function getChatMembers(
40
+ client: Lark.Client,
41
+ chatId: string,
42
+ pageSize?: number,
43
+ pageToken?: string,
44
+ memberIdType?: "open_id" | "user_id" | "union_id",
45
+ ) {
46
+ const page_size = pageSize ? Math.max(1, Math.min(100, pageSize)) : 50;
47
+ const res = await client.im.chatMembers.get({
48
+ path: { chat_id: chatId },
49
+ params: {
50
+ page_size,
51
+ page_token: pageToken,
52
+ member_id_type: memberIdType ?? "open_id",
53
+ },
54
+ });
55
+
56
+ if (res.code !== 0) {
57
+ throw new Error(res.msg);
58
+ }
59
+
60
+ return {
61
+ chat_id: chatId,
62
+ has_more: res.data?.has_more,
63
+ page_token: res.data?.page_token,
64
+ members:
65
+ res.data?.items?.map((item) => ({
66
+ member_id: item.member_id,
67
+ name: item.name,
68
+ tenant_key: item.tenant_key,
69
+ member_id_type: item.member_id_type,
70
+ })) ?? [],
71
+ };
72
+ }
73
+
74
+ export function registerFeishuChatTools(api: OpenClawPluginApi) {
75
+ if (!api.config) {
76
+ api.logger.debug?.("feishu_chat: No config available, skipping chat tools");
77
+ return;
78
+ }
79
+
80
+ const accounts = listEnabledFeishuAccounts(api.config);
81
+ if (accounts.length === 0) {
82
+ api.logger.debug?.("feishu_chat: No Feishu accounts configured, skipping chat tools");
83
+ return;
84
+ }
85
+
86
+ const firstAccount = accounts[0];
87
+ const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
88
+ if (!toolsCfg.chat) {
89
+ api.logger.debug?.("feishu_chat: chat tool disabled in config");
90
+ return;
91
+ }
92
+
93
+ const getClient = () => createFeishuClient(firstAccount);
94
+
95
+ api.registerTool(
96
+ {
97
+ name: "feishu_chat",
98
+ label: "Feishu Chat",
99
+ description: "Feishu chat operations. Actions: members, info",
100
+ parameters: FeishuChatSchema,
101
+ async execute(_toolCallId, params) {
102
+ const p = params as FeishuChatParams;
103
+ try {
104
+ const client = getClient();
105
+ switch (p.action) {
106
+ case "members":
107
+ return json(
108
+ await getChatMembers(
109
+ client,
110
+ p.chat_id,
111
+ p.page_size,
112
+ p.page_token,
113
+ p.member_id_type,
114
+ ),
115
+ );
116
+ case "info":
117
+ return json(await getChatInfo(client, p.chat_id));
118
+ default:
119
+ return json({ error: `Unknown action: ${String(p.action)}` });
120
+ }
121
+ } catch (err) {
122
+ return json({ error: err instanceof Error ? err.message : String(err) });
123
+ }
124
+ },
125
+ },
126
+ { name: "feishu_chat" },
127
+ );
128
+
129
+ api.logger.info?.("feishu_chat: Registered feishu_chat tool");
130
+ }
@@ -0,0 +1,324 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { FeishuConfig, ResolvedFeishuAccount } from "./types.js";
3
+
4
+ const wsClientCtorMock = vi.hoisted(() =>
5
+ vi.fn(function wsClientCtor() {
6
+ return { connected: true };
7
+ }),
8
+ );
9
+ const httpsProxyAgentCtorMock = vi.hoisted(() =>
10
+ vi.fn(function httpsProxyAgentCtor(proxyUrl: string) {
11
+ return { proxyUrl };
12
+ }),
13
+ );
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
+
26
+ vi.mock("@larksuiteoapi/node-sdk", () => ({
27
+ AppType: { SelfBuild: "self" },
28
+ Domain: { Feishu: "https://open.feishu.cn", Lark: "https://open.larksuite.com" },
29
+ LoggerLevel: { info: "info" },
30
+ Client: vi.fn(),
31
+ WSClient: wsClientCtorMock,
32
+ EventDispatcher: vi.fn(),
33
+ defaultHttpInstance: mockBaseHttpInstance,
34
+ }));
35
+
36
+ vi.mock("https-proxy-agent", () => ({
37
+ HttpsProxyAgent: httpsProxyAgentCtorMock,
38
+ }));
39
+
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";
49
+
50
+ const proxyEnvKeys = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"] as const;
51
+ type ProxyEnvKey = (typeof proxyEnvKeys)[number];
52
+
53
+ let priorProxyEnv: Partial<Record<ProxyEnvKey, string | undefined>> = {};
54
+ let priorFeishuTimeoutEnv: string | undefined;
55
+
56
+ const baseAccount: ResolvedFeishuAccount = {
57
+ accountId: "main",
58
+ selectionSource: "explicit",
59
+ enabled: true,
60
+ configured: true,
61
+ appId: "app_123",
62
+ appSecret: "secret_123", // pragma: allowlist secret
63
+ domain: "feishu",
64
+ config: {} as FeishuConfig,
65
+ };
66
+
67
+ function firstWsClientOptions(): { agent?: unknown } {
68
+ const calls = wsClientCtorMock.mock.calls as unknown as Array<[options: { agent?: unknown }]>;
69
+ return calls[0]?.[0] ?? {};
70
+ }
71
+
72
+ beforeEach(() => {
73
+ priorProxyEnv = {};
74
+ priorFeishuTimeoutEnv = process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
75
+ delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
76
+ for (const key of proxyEnvKeys) {
77
+ priorProxyEnv[key] = process.env[key];
78
+ delete process.env[key];
79
+ }
80
+ vi.clearAllMocks();
81
+ });
82
+
83
+ afterEach(() => {
84
+ for (const key of proxyEnvKeys) {
85
+ const value = priorProxyEnv[key];
86
+ if (value === undefined) {
87
+ delete process.env[key];
88
+ } else {
89
+ process.env[key] = value;
90
+ }
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
+ });
257
+ });
258
+
259
+ describe("createFeishuWSClient proxy handling", () => {
260
+ it("does not set a ws proxy agent when proxy env is absent", () => {
261
+ createFeishuWSClient(baseAccount);
262
+
263
+ expect(httpsProxyAgentCtorMock).not.toHaveBeenCalled();
264
+ const options = firstWsClientOptions();
265
+ expect(options?.agent).toBeUndefined();
266
+ });
267
+
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.
273
+ process.env.https_proxy = "http://lower-https:8001";
274
+ process.env.http_proxy = "http://lower-http:8003";
275
+ process.env.HTTP_PROXY = "http://upper-http:8004";
276
+
277
+ createFeishuWSClient(baseAccount);
278
+
279
+ // On Windows env keys are case-insensitive, so setting HTTPS_PROXY may
280
+ // overwrite https_proxy. We assert https proxies still win over http.
281
+ const expectedProxy = process.env.https_proxy || process.env.HTTPS_PROXY;
282
+ expect(expectedProxy).toBeTruthy();
283
+ expect(httpsProxyAgentCtorMock).toHaveBeenCalledTimes(1);
284
+ expect(httpsProxyAgentCtorMock).toHaveBeenCalledWith(expectedProxy);
285
+ const options = firstWsClientOptions();
286
+ expect(options.agent).toEqual({ proxyUrl: expectedProxy });
287
+ });
288
+
289
+ it("accepts lowercase https_proxy when it is the configured HTTPS proxy var", () => {
290
+ process.env.https_proxy = "http://lower-https:8001";
291
+
292
+ createFeishuWSClient(baseAccount);
293
+
294
+ const expectedHttpsProxy = process.env.https_proxy || process.env.HTTPS_PROXY;
295
+ expect(httpsProxyAgentCtorMock).toHaveBeenCalledTimes(1);
296
+ expect(expectedHttpsProxy).toBeTruthy();
297
+ expect(httpsProxyAgentCtorMock).toHaveBeenCalledWith(expectedHttpsProxy);
298
+ const options = firstWsClientOptions();
299
+ expect(options.agent).toEqual({ proxyUrl: expectedHttpsProxy });
300
+ });
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
+
314
+ it("passes HTTP_PROXY to ws client when https vars are unset", () => {
315
+ process.env.HTTP_PROXY = "http://upper-http:8999";
316
+
317
+ createFeishuWSClient(baseAccount);
318
+
319
+ expect(httpsProxyAgentCtorMock).toHaveBeenCalledTimes(1);
320
+ expect(httpsProxyAgentCtorMock).toHaveBeenCalledWith("http://upper-http:8999");
321
+ const options = firstWsClientOptions();
322
+ expect(options.agent).toEqual({ proxyUrl: "http://upper-http:8999" });
323
+ });
324
+ });
package/src/client.ts ADDED
@@ -0,0 +1,196 @@
1
+ import * as Lark from "@larksuiteoapi/node-sdk";
2
+ import { HttpsProxyAgent } from "https-proxy-agent";
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";
9
+
10
+ function getWsProxyAgent(): HttpsProxyAgent<string> | undefined {
11
+ const proxyUrl =
12
+ process.env.https_proxy ||
13
+ process.env.HTTPS_PROXY ||
14
+ process.env.http_proxy ||
15
+ process.env.HTTP_PROXY;
16
+ if (!proxyUrl) return undefined;
17
+ return new HttpsProxyAgent(proxyUrl);
18
+ }
19
+
20
+ // Multi-account client cache
21
+ const clientCache = new Map<
22
+ string,
23
+ {
24
+ client: Lark.Client;
25
+ config: { appId: string; appSecret: string; domain?: FeishuDomain; httpTimeoutMs: number };
26
+ }
27
+ >();
28
+
29
+ function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
30
+ if (domain === "lark") {
31
+ return Lark.Domain.Lark;
32
+ }
33
+ if (domain === "feishu" || !domain) {
34
+ return Lark.Domain.Feishu;
35
+ }
36
+ return domain.replace(/\/+$/, ""); // Custom URL for private deployment
37
+ }
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
+
63
+ /**
64
+ * Credentials needed to create a Feishu client.
65
+ * Both FeishuConfig and ResolvedFeishuAccount satisfy this interface.
66
+ */
67
+ export type FeishuClientCredentials = {
68
+ accountId?: string;
69
+ appId?: string;
70
+ appSecret?: string;
71
+ domain?: FeishuDomain;
72
+ httpTimeoutMs?: number;
73
+ config?: Pick<FeishuConfig, "httpTimeoutMs">;
74
+ };
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
+
107
+ /**
108
+ * Create or get a cached Feishu client for an account.
109
+ * Accepts any object with appId, appSecret, and optional domain/accountId.
110
+ */
111
+ export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client {
112
+ const { accountId = "default", appId, appSecret, domain } = creds;
113
+ const defaultHttpTimeoutMs = resolveConfiguredHttpTimeoutMs(creds);
114
+
115
+ if (!appId || !appSecret) {
116
+ throw new Error(`Feishu credentials not configured for account "${accountId}"`);
117
+ }
118
+
119
+ // Check cache
120
+ const cached = clientCache.get(accountId);
121
+ if (
122
+ cached &&
123
+ cached.config.appId === appId &&
124
+ cached.config.appSecret === appSecret &&
125
+ cached.config.domain === domain &&
126
+ cached.config.httpTimeoutMs === defaultHttpTimeoutMs
127
+ ) {
128
+ return cached.client;
129
+ }
130
+
131
+ // Create new client with timeout-aware HTTP instance
132
+ const client = new Lark.Client({
133
+ appId,
134
+ appSecret,
135
+ appType: Lark.AppType.SelfBuild,
136
+ domain: resolveDomain(domain),
137
+ httpInstance: createTimeoutHttpInstance(defaultHttpTimeoutMs),
138
+ });
139
+
140
+ // Cache it
141
+ clientCache.set(accountId, {
142
+ client,
143
+ config: { appId, appSecret, domain, httpTimeoutMs: defaultHttpTimeoutMs },
144
+ });
145
+
146
+ return client;
147
+ }
148
+
149
+ /**
150
+ * Create a Feishu WebSocket client for an account.
151
+ * Note: WSClient is not cached since each call creates a new connection.
152
+ */
153
+ export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSClient {
154
+ const { accountId, appId, appSecret, domain } = account;
155
+
156
+ if (!appId || !appSecret) {
157
+ throw new Error(`Feishu credentials not configured for account "${accountId}"`);
158
+ }
159
+
160
+ const agent = getWsProxyAgent();
161
+ return new Lark.WSClient({
162
+ appId,
163
+ appSecret,
164
+ domain: resolveDomain(domain),
165
+ loggerLevel: Lark.LoggerLevel.info,
166
+ ...(agent ? { agent } : {}),
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Create an event dispatcher for an account.
172
+ */
173
+ export function createEventDispatcher(account: ResolvedFeishuAccount): Lark.EventDispatcher {
174
+ return new Lark.EventDispatcher({
175
+ encryptKey: account.encryptKey,
176
+ verificationToken: account.verificationToken,
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Get a cached client for an account (if exists).
182
+ */
183
+ export function getFeishuClient(accountId: string): Lark.Client | null {
184
+ return clientCache.get(accountId)?.client ?? null;
185
+ }
186
+
187
+ /**
188
+ * Clear client cache for a specific account or all accounts.
189
+ */
190
+ export function clearClientCache(accountId?: string): void {
191
+ if (accountId) {
192
+ clientCache.delete(accountId);
193
+ } else {
194
+ clientCache.clear();
195
+ }
196
+ }