@openclaw/feishu 2026.3.13 → 2026.5.2-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 (187) 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 +1827 -4
  7. package/package.json +32 -7
  8. package/runtime-api.ts +55 -0
  9. package/secret-contract-api.ts +5 -0
  10. package/security-contract-api.ts +1 -0
  11. package/session-key-api.ts +1 -0
  12. package/setup-api.ts +3 -0
  13. package/setup-entry.test.ts +14 -0
  14. package/setup-entry.ts +13 -0
  15. package/src/accounts.test.ts +95 -7
  16. package/src/accounts.ts +199 -117
  17. package/src/app-registration.ts +331 -0
  18. package/src/approval-auth.test.ts +24 -0
  19. package/src/approval-auth.ts +25 -0
  20. package/src/async.test.ts +35 -0
  21. package/src/async.ts +43 -1
  22. package/src/audio-preflight.runtime.ts +9 -0
  23. package/src/bitable.test.ts +131 -0
  24. package/src/bitable.ts +59 -22
  25. package/src/bot-content.ts +474 -0
  26. package/src/bot-group-name.test.ts +108 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.broadcast.test.ts +463 -0
  30. package/src/bot.card-action.test.ts +519 -5
  31. package/src/bot.checkBotMentioned.test.ts +92 -20
  32. package/src/bot.helpers.test.ts +118 -0
  33. package/src/bot.stripBotMention.test.ts +13 -21
  34. package/src/bot.test.ts +1334 -401
  35. package/src/bot.ts +778 -775
  36. package/src/card-action.ts +408 -40
  37. package/src/card-interaction.test.ts +129 -0
  38. package/src/card-interaction.ts +159 -0
  39. package/src/card-test-helpers.ts +47 -0
  40. package/src/card-ux-approval.ts +65 -0
  41. package/src/card-ux-launcher.test.ts +99 -0
  42. package/src/card-ux-launcher.ts +121 -0
  43. package/src/card-ux-shared.ts +33 -0
  44. package/src/channel-runtime-api.ts +16 -0
  45. package/src/channel.runtime.ts +47 -0
  46. package/src/channel.test.ts +914 -3
  47. package/src/channel.ts +1253 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +135 -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 +406 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +63 -1
  65. package/src/config-schema.ts +31 -4
  66. package/src/conversation-id.test.ts +18 -0
  67. package/src/conversation-id.ts +199 -0
  68. package/src/dedup-runtime-api.ts +1 -0
  69. package/src/dedup.ts +33 -95
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +116 -20
  72. package/src/directory.ts +60 -92
  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 +403 -26
  91. package/src/media.ts +509 -132
  92. package/src/mention-target.types.ts +5 -0
  93. package/src/mention.ts +32 -51
  94. package/src/message-action-contract.ts +13 -0
  95. package/src/monitor-state-runtime-api.ts +7 -0
  96. package/src/monitor-transport-runtime-api.ts +7 -0
  97. package/src/monitor.account.ts +218 -312
  98. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  99. package/src/monitor.bot-identity.ts +86 -0
  100. package/src/monitor.bot-menu-handler.ts +165 -0
  101. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  102. package/src/monitor.bot-menu.test.ts +178 -0
  103. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  104. package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
  105. package/src/monitor.cleanup.test.ts +376 -0
  106. package/src/monitor.comment-notice-handler.ts +105 -0
  107. package/src/monitor.comment.test.ts +937 -0
  108. package/src/monitor.comment.ts +1386 -0
  109. package/src/monitor.lifecycle.test.ts +4 -0
  110. package/src/monitor.message-handler.ts +339 -0
  111. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  112. package/src/monitor.reaction.test.ts +108 -48
  113. package/src/monitor.startup.test.ts +11 -9
  114. package/src/monitor.startup.ts +26 -16
  115. package/src/monitor.state.ts +20 -5
  116. package/src/monitor.synthetic-error.ts +18 -0
  117. package/src/monitor.test-mocks.ts +2 -2
  118. package/src/monitor.transport.ts +220 -60
  119. package/src/monitor.ts +15 -10
  120. package/src/monitor.webhook-e2e.test.ts +65 -7
  121. package/src/monitor.webhook-security.test.ts +122 -0
  122. package/src/monitor.webhook.test-helpers.ts +44 -26
  123. package/src/outbound-runtime-api.ts +1 -0
  124. package/src/outbound.test.ts +616 -37
  125. package/src/outbound.ts +623 -81
  126. package/src/perm-schema.ts +1 -1
  127. package/src/perm.ts +1 -7
  128. package/src/pins.ts +108 -0
  129. package/src/policy.test.ts +297 -117
  130. package/src/policy.ts +142 -29
  131. package/src/post.ts +7 -6
  132. package/src/probe.test.ts +14 -9
  133. package/src/probe.ts +26 -16
  134. package/src/processing-claims.ts +59 -0
  135. package/src/qr-terminal.ts +1 -0
  136. package/src/reactions.ts +4 -34
  137. package/src/reasoning-preview.test.ts +59 -0
  138. package/src/reasoning-preview.ts +20 -0
  139. package/src/reply-dispatcher-runtime-api.ts +7 -0
  140. package/src/reply-dispatcher.test.ts +660 -29
  141. package/src/reply-dispatcher.ts +407 -154
  142. package/src/runtime.ts +6 -3
  143. package/src/secret-contract.ts +145 -0
  144. package/src/secret-input.ts +1 -13
  145. package/src/security-audit-shared.ts +69 -0
  146. package/src/security-audit.test.ts +61 -0
  147. package/src/security-audit.ts +1 -0
  148. package/src/send-result.ts +1 -1
  149. package/src/send-target.test.ts +9 -3
  150. package/src/send-target.ts +10 -4
  151. package/src/send.reply-fallback.test.ts +105 -2
  152. package/src/send.test.ts +386 -4
  153. package/src/send.ts +414 -95
  154. package/src/sequential-key.test.ts +72 -0
  155. package/src/sequential-key.ts +28 -0
  156. package/src/sequential-queue.test.ts +92 -0
  157. package/src/sequential-queue.ts +16 -0
  158. package/src/session-conversation.ts +42 -0
  159. package/src/session-route.ts +48 -0
  160. package/src/setup-core.ts +51 -0
  161. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  162. package/src/setup-surface.ts +581 -0
  163. package/src/streaming-card.test.ts +138 -2
  164. package/src/streaming-card.ts +134 -18
  165. package/src/subagent-hooks.test.ts +603 -0
  166. package/src/subagent-hooks.ts +397 -0
  167. package/src/targets.ts +3 -13
  168. package/src/test-support/lifecycle-test-support.ts +453 -0
  169. package/src/thread-bindings.test.ts +143 -0
  170. package/src/thread-bindings.ts +330 -0
  171. package/src/tool-account-routing.test.ts +66 -8
  172. package/src/tool-account.test.ts +44 -0
  173. package/src/tool-account.ts +40 -17
  174. package/src/tool-factory-test-harness.ts +11 -8
  175. package/src/tool-result.ts +3 -1
  176. package/src/tools-config.ts +1 -1
  177. package/src/types.ts +16 -15
  178. package/src/typing.ts +10 -6
  179. package/src/wiki-schema.ts +1 -1
  180. package/src/wiki.ts +1 -7
  181. package/subagent-hooks-api.ts +31 -0
  182. package/tsconfig.json +16 -0
  183. package/src/feishu-command-handler.ts +0 -59
  184. package/src/onboarding.status.test.ts +0 -25
  185. package/src/onboarding.ts +0 -489
  186. package/src/send-message.ts +0 -71
  187. package/src/targets.test.ts +0 -70
@@ -1,17 +1,27 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import type { FeishuConfig, ResolvedFeishuAccount } from "./types.js";
1
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { FeishuConfigSchema } from "./config-schema.js";
3
+ import type { ResolvedFeishuAccount } from "./types.js";
3
4
 
5
+ type CreateFeishuClient = typeof import("./client.js").createFeishuClient;
6
+ type CreateFeishuWSClient = typeof import("./client.js").createFeishuWSClient;
7
+ type ClearClientCache = typeof import("./client.js").clearClientCache;
8
+ type SetFeishuClientRuntimeForTest = typeof import("./client.js").setFeishuClientRuntimeForTest;
9
+
10
+ const clientCtorMock = vi.hoisted(() =>
11
+ vi.fn(function clientCtor() {
12
+ return { connected: true };
13
+ }),
14
+ );
4
15
  const wsClientCtorMock = vi.hoisted(() =>
5
16
  vi.fn(function wsClientCtor() {
6
17
  return { connected: true };
7
18
  }),
8
19
  );
9
- const httpsProxyAgentCtorMock = vi.hoisted(() =>
10
- vi.fn(function httpsProxyAgentCtor(proxyUrl: string) {
11
- return { proxyUrl };
20
+ const proxyAgentCtorMock = vi.hoisted(() =>
21
+ vi.fn(function proxyAgentCtor() {
22
+ return { proxied: true };
12
23
  }),
13
24
  );
14
-
15
25
  const mockBaseHttpInstance = vi.hoisted(() => ({
16
26
  request: vi.fn().mockResolvedValue({}),
17
27
  get: vi.fn().mockResolvedValue({}),
@@ -22,36 +32,64 @@ const mockBaseHttpInstance = vi.hoisted(() => ({
22
32
  head: vi.fn().mockResolvedValue({}),
23
33
  options: vi.fn().mockResolvedValue({}),
24
34
  }));
35
+ const proxyEnvKeys = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"] as const;
36
+ type ProxyEnvKey = (typeof proxyEnvKeys)[number];
37
+ const registerFeishuDocToolsMock = vi.hoisted(() => vi.fn());
38
+ const registerFeishuChatToolsMock = vi.hoisted(() => vi.fn());
39
+ const registerFeishuWikiToolsMock = vi.hoisted(() => vi.fn());
40
+ const registerFeishuDriveToolsMock = vi.hoisted(() => vi.fn());
41
+ const registerFeishuPermToolsMock = vi.hoisted(() => vi.fn());
42
+ const registerFeishuBitableToolsMock = vi.hoisted(() => vi.fn());
43
+ const feishuPluginMock = vi.hoisted(() => ({ id: "feishu-test-plugin" }));
44
+ const setFeishuRuntimeMock = vi.hoisted(() => vi.fn());
45
+ const registerFeishuSubagentHooksMock = vi.hoisted(() => vi.fn());
46
+
47
+ let createFeishuClient: CreateFeishuClient;
48
+ let createFeishuWSClient: CreateFeishuWSClient;
49
+ let clearClientCache: ClearClientCache;
50
+ let setFeishuClientRuntimeForTest: SetFeishuClientRuntimeForTest;
51
+ let FEISHU_HTTP_TIMEOUT_MS: number;
52
+ let FEISHU_HTTP_TIMEOUT_MAX_MS: number;
53
+ let FEISHU_HTTP_TIMEOUT_ENV_VAR: string;
25
54
 
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,
55
+ let priorProxyEnv: Partial<Record<ProxyEnvKey, string | undefined>> = {};
56
+ let priorFeishuTimeoutEnv: string | undefined;
57
+
58
+ vi.mock("./channel.js", () => ({
59
+ feishuPlugin: feishuPluginMock,
34
60
  }));
35
61
 
36
- vi.mock("https-proxy-agent", () => ({
37
- HttpsProxyAgent: httpsProxyAgentCtorMock,
62
+ vi.mock("./docx.js", () => ({
63
+ registerFeishuDocTools: registerFeishuDocToolsMock,
38
64
  }));
39
65
 
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";
66
+ vi.mock("./chat.js", () => ({
67
+ registerFeishuChatTools: registerFeishuChatToolsMock,
68
+ }));
49
69
 
50
- const proxyEnvKeys = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"] as const;
51
- type ProxyEnvKey = (typeof proxyEnvKeys)[number];
70
+ vi.mock("./wiki.js", () => ({
71
+ registerFeishuWikiTools: registerFeishuWikiToolsMock,
72
+ }));
52
73
 
53
- let priorProxyEnv: Partial<Record<ProxyEnvKey, string | undefined>> = {};
54
- let priorFeishuTimeoutEnv: string | undefined;
74
+ vi.mock("./drive.js", () => ({
75
+ registerFeishuDriveTools: registerFeishuDriveToolsMock,
76
+ }));
77
+
78
+ vi.mock("./perm.js", () => ({
79
+ registerFeishuPermTools: registerFeishuPermToolsMock,
80
+ }));
81
+
82
+ vi.mock("./bitable.js", () => ({
83
+ registerFeishuBitableTools: registerFeishuBitableToolsMock,
84
+ }));
85
+
86
+ vi.mock("./runtime.js", () => ({
87
+ setFeishuRuntime: setFeishuRuntimeMock,
88
+ }));
89
+
90
+ vi.mock("./subagent-hooks.js", () => ({
91
+ registerFeishuSubagentHooks: registerFeishuSubagentHooksMock,
92
+ }));
55
93
 
56
94
  const baseAccount: ResolvedFeishuAccount = {
57
95
  accountId: "main",
@@ -61,14 +99,70 @@ const baseAccount: ResolvedFeishuAccount = {
61
99
  appId: "app_123",
62
100
  appSecret: "secret_123", // pragma: allowlist secret
63
101
  domain: "feishu",
64
- config: {} as FeishuConfig,
102
+ config: FeishuConfigSchema.parse({}),
65
103
  };
66
104
 
67
- function firstWsClientOptions(): { agent?: unknown } {
68
- const calls = wsClientCtorMock.mock.calls as unknown as Array<[options: { agent?: unknown }]>;
69
- return calls[0]?.[0] ?? {};
105
+ function isRecord(value: unknown): value is Record<string, unknown> {
106
+ return typeof value === "object" && value !== null;
70
107
  }
71
108
 
109
+ type HttpInstanceLike = {
110
+ get: (url: string, options?: Record<string, unknown>) => Promise<unknown>;
111
+ post: (url: string, body?: unknown, options?: Record<string, unknown>) => Promise<unknown>;
112
+ };
113
+
114
+ function readCallOptions(
115
+ mock: { mock: { calls: unknown[][] } },
116
+ index = -1,
117
+ ): Record<string, unknown> {
118
+ const call = index < 0 ? mock.mock.calls.at(index)?.[0] : mock.mock.calls[index]?.[0];
119
+ return isRecord(call) ? call : {};
120
+ }
121
+
122
+ function firstWsClientOptions(): {
123
+ agent?: unknown;
124
+ wsConfig?: unknown;
125
+ onError?: unknown;
126
+ onReady?: unknown;
127
+ onReconnected?: unknown;
128
+ onReconnecting?: unknown;
129
+ } {
130
+ const options = readCallOptions(wsClientCtorMock, 0);
131
+ return {
132
+ agent: options.agent,
133
+ wsConfig: options.wsConfig,
134
+ onError: options.onError,
135
+ onReady: options.onReady,
136
+ onReconnected: options.onReconnected,
137
+ onReconnecting: options.onReconnecting,
138
+ };
139
+ }
140
+
141
+ beforeAll(async () => {
142
+ vi.doMock("@larksuiteoapi/node-sdk", () => ({
143
+ AppType: { SelfBuild: "self" },
144
+ Domain: { Feishu: "https://open.feishu.cn", Lark: "https://open.larksuite.com" },
145
+ LoggerLevel: { info: "info" },
146
+ Client: clientCtorMock,
147
+ WSClient: wsClientCtorMock,
148
+ EventDispatcher: vi.fn(),
149
+ defaultHttpInstance: mockBaseHttpInstance,
150
+ }));
151
+ vi.doMock("proxy-agent", () => ({
152
+ ProxyAgent: proxyAgentCtorMock,
153
+ }));
154
+
155
+ ({
156
+ createFeishuClient,
157
+ createFeishuWSClient,
158
+ clearClientCache,
159
+ setFeishuClientRuntimeForTest,
160
+ FEISHU_HTTP_TIMEOUT_MS,
161
+ FEISHU_HTTP_TIMEOUT_MAX_MS,
162
+ FEISHU_HTTP_TIMEOUT_ENV_VAR,
163
+ } = await import("./client.js"));
164
+ });
165
+
72
166
  beforeEach(() => {
73
167
  priorProxyEnv = {};
74
168
  priorFeishuTimeoutEnv = process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
@@ -78,6 +172,21 @@ beforeEach(() => {
78
172
  delete process.env[key];
79
173
  }
80
174
  vi.clearAllMocks();
175
+ clearClientCache();
176
+ setFeishuClientRuntimeForTest({
177
+ sdk: {
178
+ AppType: { SelfBuild: "self" } as never,
179
+ Domain: {
180
+ Feishu: "https://open.feishu.cn",
181
+ Lark: "https://open.larksuite.com",
182
+ } as never,
183
+ LoggerLevel: { info: "info" } as never,
184
+ Client: clientCtorMock as never,
185
+ WSClient: wsClientCtorMock as never,
186
+ EventDispatcher: vi.fn() as never,
187
+ defaultHttpInstance: mockBaseHttpInstance as never,
188
+ },
189
+ });
81
190
  });
82
191
 
83
192
  afterEach(() => {
@@ -94,19 +203,23 @@ afterEach(() => {
94
203
  } else {
95
204
  process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = priorFeishuTimeoutEnv;
96
205
  }
206
+ setFeishuClientRuntimeForTest();
97
207
  });
98
208
 
99
209
  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;
210
+ const getLastClientHttpInstance = (): HttpInstanceLike | undefined => {
211
+ const httpInstance = readCallOptions(clientCtorMock).httpInstance;
212
+ if (
213
+ isRecord(httpInstance) &&
214
+ typeof httpInstance.get === "function" &&
215
+ typeof httpInstance.post === "function"
216
+ ) {
217
+ return {
218
+ get: httpInstance.get as HttpInstanceLike["get"],
219
+ post: httpInstance.post as HttpInstanceLike["post"],
220
+ };
221
+ }
222
+ return undefined;
110
223
  };
111
224
 
112
225
  const expectGetCallTimeout = async (timeout: number) => {
@@ -122,21 +235,16 @@ describe("createFeishuClient HTTP timeout", () => {
122
235
  it("passes a custom httpInstance with default timeout to Lark.Client", () => {
123
236
  createFeishuClient({ appId: "app_1", appSecret: "secret_1", accountId: "timeout-test" }); // pragma: allowlist secret
124
237
 
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();
238
+ expect(readCallOptions(clientCtorMock).httpInstance).toBeDefined();
128
239
  });
129
240
 
130
241
  it("injects default timeout into HTTP request options", async () => {
131
242
  createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" }); // pragma: allowlist secret
132
243
 
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;
244
+ const httpInstance = getLastClientHttpInstance();
138
245
 
139
- await httpInstance.post(
246
+ expect(httpInstance).toBeDefined();
247
+ await httpInstance?.post(
140
248
  "https://example.com/api",
141
249
  { data: 1 },
142
250
  { headers: { "X-Custom": "yes" } },
@@ -152,13 +260,10 @@ describe("createFeishuClient HTTP timeout", () => {
152
260
  it("allows explicit timeout override per-request", async () => {
153
261
  createFeishuClient({ appId: "app_3", appSecret: "secret_3", accountId: "timeout-override" }); // pragma: allowlist secret
154
262
 
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;
263
+ const httpInstance = getLastClientHttpInstance();
160
264
 
161
- await httpInstance.get("https://example.com/api", { timeout: 5_000 });
265
+ expect(httpInstance).toBeDefined();
266
+ await httpInstance?.get("https://example.com/api", { timeout: 5_000 });
162
267
 
163
268
  expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
164
269
  "https://example.com/api",
@@ -241,13 +346,10 @@ describe("createFeishuClient HTTP timeout", () => {
241
346
  config: { httpTimeoutMs: 45_000 },
242
347
  });
243
348
 
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");
349
+ expect(clientCtorMock.mock.calls.length).toBe(2);
350
+ const httpInstance = getLastClientHttpInstance();
351
+ expect(httpInstance).toBeDefined();
352
+ await httpInstance?.get("https://example.com/api");
251
353
 
252
354
  expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
253
355
  "https://example.com/api",
@@ -257,68 +359,75 @@ describe("createFeishuClient HTTP timeout", () => {
257
359
  });
258
360
 
259
361
  describe("createFeishuWSClient proxy handling", () => {
260
- it("does not set a ws proxy agent when proxy env is absent", () => {
261
- createFeishuWSClient(baseAccount);
362
+ it("passes heartbeat wsConfig defaults to Lark.WSClient", async () => {
363
+ await createFeishuWSClient(baseAccount);
262
364
 
263
- expect(httpsProxyAgentCtorMock).not.toHaveBeenCalled();
264
365
  const options = firstWsClientOptions();
265
- expect(options?.agent).toBeUndefined();
366
+ expect(options.wsConfig).toEqual({
367
+ PingInterval: 30,
368
+ PingTimeout: 3,
369
+ });
266
370
  });
267
371
 
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";
372
+ it("passes lifecycle callbacks while preserving heartbeat wsConfig defaults", async () => {
373
+ const onError = vi.fn();
374
+ const onReady = vi.fn();
375
+ const onReconnected = vi.fn();
376
+ const onReconnecting = vi.fn();
377
+
378
+ await createFeishuWSClient(baseAccount, {
379
+ onError,
380
+ onReady,
381
+ onReconnected,
382
+ onReconnecting,
383
+ });
384
+
385
+ const options = firstWsClientOptions();
386
+ expect(options.onError).toBe(onError);
387
+ expect(options.onReady).toBe(onReady);
388
+ expect(options.onReconnected).toBe(onReconnected);
389
+ expect(options.onReconnecting).toBe(onReconnecting);
390
+ expect(options.wsConfig).toEqual({
391
+ PingInterval: 30,
392
+ PingTimeout: 3,
393
+ });
394
+ });
276
395
 
277
- createFeishuWSClient(baseAccount);
396
+ it("does not set a ws proxy agent when proxy env is absent", async () => {
397
+ await createFeishuWSClient(baseAccount);
278
398
 
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);
399
+ expect(proxyAgentCtorMock).not.toHaveBeenCalled();
285
400
  const options = firstWsClientOptions();
286
- expect(options.agent).toEqual({ proxyUrl: expectedProxy });
401
+ expect(options.agent).toBeUndefined();
287
402
  });
288
403
 
289
- it("accepts lowercase https_proxy when it is the configured HTTPS proxy var", () => {
404
+ it("creates a ws proxy agent when lowercase https_proxy is set", async () => {
290
405
  process.env.https_proxy = "http://lower-https:8001";
291
406
 
292
- createFeishuWSClient(baseAccount);
407
+ await createFeishuWSClient(baseAccount);
293
408
 
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);
409
+ expect(proxyAgentCtorMock).toHaveBeenCalledTimes(1);
298
410
  const options = firstWsClientOptions();
299
- expect(options.agent).toEqual({ proxyUrl: expectedHttpsProxy });
411
+ expect(options.agent).toEqual({ proxied: true });
300
412
  });
301
413
 
302
- it("uses HTTPS_PROXY when https_proxy is unset", () => {
414
+ it("creates a ws proxy agent when uppercase HTTPS_PROXY is set", async () => {
303
415
  process.env.HTTPS_PROXY = "http://upper-https:8002";
304
- process.env.http_proxy = "http://lower-http:8003";
305
416
 
306
- createFeishuWSClient(baseAccount);
417
+ await createFeishuWSClient(baseAccount);
307
418
 
308
- expect(httpsProxyAgentCtorMock).toHaveBeenCalledTimes(1);
309
- expect(httpsProxyAgentCtorMock).toHaveBeenCalledWith("http://upper-https:8002");
419
+ expect(proxyAgentCtorMock).toHaveBeenCalledTimes(1);
310
420
  const options = firstWsClientOptions();
311
- expect(options.agent).toEqual({ proxyUrl: "http://upper-https:8002" });
421
+ expect(options.agent).toEqual({ proxied: true });
312
422
  });
313
423
 
314
- it("passes HTTP_PROXY to ws client when https vars are unset", () => {
424
+ it("falls back to HTTP_PROXY for ws proxy agent creation", async () => {
315
425
  process.env.HTTP_PROXY = "http://upper-http:8999";
316
426
 
317
- createFeishuWSClient(baseAccount);
427
+ await createFeishuWSClient(baseAccount);
318
428
 
319
- expect(httpsProxyAgentCtorMock).toHaveBeenCalledTimes(1);
320
- expect(httpsProxyAgentCtorMock).toHaveBeenCalledWith("http://upper-http:8999");
429
+ expect(proxyAgentCtorMock).toHaveBeenCalledTimes(1);
321
430
  const options = firstWsClientOptions();
322
- expect(options.agent).toEqual({ proxyUrl: "http://upper-http:8999" });
431
+ expect(options.agent).toEqual({ proxied: true });
323
432
  });
324
433
  });
package/src/client.ts CHANGED
@@ -1,20 +1,94 @@
1
+ import type { Agent } from "node:https";
2
+ import { createRequire } from "node:module";
1
3
  import * as Lark from "@larksuiteoapi/node-sdk";
2
- import { HttpsProxyAgent } from "https-proxy-agent";
4
+ import {
5
+ readPluginPackageVersion,
6
+ resolveAmbientNodeProxyAgent,
7
+ } from "openclaw/plugin-sdk/extension-shared";
3
8
  import type { FeishuConfig, FeishuDomain, ResolvedFeishuAccount } from "./types.js";
4
9
 
10
+ const require = createRequire(import.meta.url);
11
+ const pluginVersion = readPluginPackageVersion({ require });
12
+
13
+ export { pluginVersion };
14
+
15
+ const FEISHU_USER_AGENT = `openclaw-feishu-builtin/${pluginVersion}/${process.platform}`;
16
+ export { FEISHU_USER_AGENT };
17
+
18
+ const FEISHU_WS_CONFIG = {
19
+ PingInterval: 30,
20
+ PingTimeout: 3,
21
+ } as const;
22
+
23
+ /** User-Agent header value for all Feishu API requests. */
24
+ export function getFeishuUserAgent(): string {
25
+ return FEISHU_USER_AGENT;
26
+ }
27
+
28
+ type FeishuClientSdk = Pick<
29
+ typeof Lark,
30
+ | "AppType"
31
+ | "Client"
32
+ | "defaultHttpInstance"
33
+ | "Domain"
34
+ | "EventDispatcher"
35
+ | "LoggerLevel"
36
+ | "WSClient"
37
+ >;
38
+
39
+ const defaultFeishuClientSdk: FeishuClientSdk = {
40
+ AppType: Lark.AppType,
41
+ Client: Lark.Client,
42
+ defaultHttpInstance: Lark.defaultHttpInstance,
43
+ Domain: Lark.Domain,
44
+ EventDispatcher: Lark.EventDispatcher,
45
+ LoggerLevel: Lark.LoggerLevel,
46
+ WSClient: Lark.WSClient,
47
+ };
48
+
49
+ let feishuClientSdk: FeishuClientSdk = defaultFeishuClientSdk;
50
+
51
+ // Override the SDK's default User-Agent interceptor.
52
+ // The Lark SDK registers an axios request interceptor that sets
53
+ // 'oapi-node-sdk/1.0.0'. Axios request interceptors execute in LIFO order
54
+ // (last-registered runs first), so simply appending ours doesn't work — the
55
+ // SDK's interceptor would run last and overwrite our UA. We must clear
56
+ // handlers[] first, then register our own as the sole interceptor.
57
+ //
58
+ // Risk is low: the SDK only registers one interceptor (UA) at init time, and
59
+ // we clear it at module load before any other code can register handlers.
60
+ // If a future SDK version adds more interceptors, the upgrade will need
61
+ // compatibility verification regardless.
62
+ {
63
+ const inst = Lark.defaultHttpInstance as {
64
+ interceptors?: {
65
+ request: { handlers: unknown[]; use: (fn: (req: unknown) => unknown) => void };
66
+ };
67
+ };
68
+ if (inst.interceptors?.request) {
69
+ inst.interceptors.request.handlers = [];
70
+ inst.interceptors.request.use((req: unknown) => {
71
+ const r = req as { headers?: Record<string, string> };
72
+ if (r.headers) {
73
+ r.headers["User-Agent"] = getFeishuUserAgent();
74
+ }
75
+ return req;
76
+ });
77
+ }
78
+ }
79
+
5
80
  /** Default HTTP timeout for Feishu API requests (30 seconds). */
6
81
  export const FEISHU_HTTP_TIMEOUT_MS = 30_000;
7
82
  export const FEISHU_HTTP_TIMEOUT_MAX_MS = 300_000;
8
83
  export const FEISHU_HTTP_TIMEOUT_ENV_VAR = "OPENCLAW_FEISHU_HTTP_TIMEOUT_MS";
9
84
 
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);
85
+ type FeishuHttpInstanceLike = Pick<
86
+ typeof feishuClientSdk.defaultHttpInstance,
87
+ "request" | "get" | "post" | "put" | "patch" | "delete" | "head" | "options"
88
+ >;
89
+
90
+ async function getWsProxyAgent() {
91
+ return resolveAmbientNodeProxyAgent<Agent>();
18
92
  }
19
93
 
20
94
  // Multi-account client cache
@@ -28,21 +102,21 @@ const clientCache = new Map<
28
102
 
29
103
  function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
30
104
  if (domain === "lark") {
31
- return Lark.Domain.Lark;
105
+ return feishuClientSdk.Domain.Lark;
32
106
  }
33
107
  if (domain === "feishu" || !domain) {
34
- return Lark.Domain.Feishu;
108
+ return feishuClientSdk.Domain.Feishu;
35
109
  }
36
110
  return domain.replace(/\/+$/, ""); // Custom URL for private deployment
37
111
  }
38
112
 
39
113
  /**
40
114
  * 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).
115
+ * but injects a default request timeout and User-Agent header to prevent
116
+ * indefinite hangs and set a standardized User-Agent per OAPI best practices.
43
117
  */
44
118
  function createTimeoutHttpInstance(defaultTimeoutMs: number): Lark.HttpInstance {
45
- const base: Lark.HttpInstance = Lark.defaultHttpInstance as unknown as Lark.HttpInstance;
119
+ const base: FeishuHttpInstanceLike = feishuClientSdk.defaultHttpInstance;
46
120
 
47
121
  function injectTimeout<D>(opts?: Lark.HttpRequestOptions<D>): Lark.HttpRequestOptions<D> {
48
122
  return { timeout: defaultTimeoutMs, ...opts } as Lark.HttpRequestOptions<D>;
@@ -129,10 +203,10 @@ export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client
129
203
  }
130
204
 
131
205
  // Create new client with timeout-aware HTTP instance
132
- const client = new Lark.Client({
206
+ const client = new feishuClientSdk.Client({
133
207
  appId,
134
208
  appSecret,
135
- appType: Lark.AppType.SelfBuild,
209
+ appType: feishuClientSdk.AppType.SelfBuild,
136
210
  domain: resolveDomain(domain),
137
211
  httpInstance: createTimeoutHttpInstance(defaultHttpTimeoutMs),
138
212
  });
@@ -146,24 +220,36 @@ export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client
146
220
  return client;
147
221
  }
148
222
 
223
+ export type FeishuWsClientCallbacks = Pick<
224
+ ConstructorParameters<typeof feishuClientSdk.WSClient>[0],
225
+ "onError" | "onReady" | "onReconnected" | "onReconnecting"
226
+ >;
227
+
149
228
  /**
150
229
  * Create a Feishu WebSocket client for an account.
151
230
  * Note: WSClient is not cached since each call creates a new connection.
152
231
  */
153
- export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSClient {
232
+ export async function createFeishuWSClient(
233
+ account: ResolvedFeishuAccount,
234
+ callbacks: FeishuWsClientCallbacks = {},
235
+ ): Promise<Lark.WSClient> {
154
236
  const { accountId, appId, appSecret, domain } = account;
155
237
 
156
238
  if (!appId || !appSecret) {
157
239
  throw new Error(`Feishu credentials not configured for account "${accountId}"`);
158
240
  }
159
241
 
160
- const agent = getWsProxyAgent();
161
- return new Lark.WSClient({
242
+ const agent = await getWsProxyAgent();
243
+ return new feishuClientSdk.WSClient({
162
244
  appId,
163
245
  appSecret,
164
246
  domain: resolveDomain(domain),
165
- loggerLevel: Lark.LoggerLevel.info,
247
+ ...callbacks,
248
+ loggerLevel: feishuClientSdk.LoggerLevel.info,
249
+ wsConfig: FEISHU_WS_CONFIG,
166
250
  ...(agent ? { agent } : {}),
251
+ } as ConstructorParameters<typeof feishuClientSdk.WSClient>[0] & {
252
+ wsConfig: typeof FEISHU_WS_CONFIG;
167
253
  });
168
254
  }
169
255
 
@@ -171,7 +257,7 @@ export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSCli
171
257
  * Create an event dispatcher for an account.
172
258
  */
173
259
  export function createEventDispatcher(account: ResolvedFeishuAccount): Lark.EventDispatcher {
174
- return new Lark.EventDispatcher({
260
+ return new feishuClientSdk.EventDispatcher({
175
261
  encryptKey: account.encryptKey,
176
262
  verificationToken: account.verificationToken,
177
263
  });
@@ -194,3 +280,11 @@ export function clearClientCache(accountId?: string): void {
194
280
  clientCache.clear();
195
281
  }
196
282
  }
283
+
284
+ export function setFeishuClientRuntimeForTest(overrides?: {
285
+ sdk?: Partial<FeishuClientSdk>;
286
+ }): void {
287
+ feishuClientSdk = overrides?.sdk
288
+ ? { ...defaultFeishuClientSdk, ...overrides.sdk }
289
+ : defaultFeishuClientSdk;
290
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ createReplyPrefixContext,
3
+ type ClawdbotConfig,
4
+ type ReplyPayload,
5
+ type RuntimeEnv,
6
+ } from "../runtime-api.js";