@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.
- package/index.ts +2 -2
- package/package.json +1 -1
- package/src/accounts.test.ts +199 -13
- package/src/accounts.ts +45 -17
- package/src/bitable.ts +40 -28
- package/src/bot.checkBotMentioned.test.ts +8 -0
- package/src/bot.stripBotMention.test.ts +118 -22
- package/src/bot.test.ts +516 -9
- package/src/bot.ts +366 -109
- package/src/card-action.ts +1 -1
- package/src/channel.test.ts +1 -1
- package/src/channel.ts +52 -64
- package/src/chat.test.ts +2 -2
- package/src/chat.ts +1 -1
- package/src/client.test.ts +207 -4
- package/src/client.ts +70 -5
- package/src/config-schema.test.ts +14 -6
- package/src/config-schema.ts +5 -1
- package/src/dedup.ts +1 -1
- package/src/directory.test.ts +40 -0
- package/src/directory.ts +29 -50
- package/src/docx-batch-insert.test.ts +90 -0
- package/src/docx-batch-insert.ts +8 -11
- package/src/docx.account-selection.test.ts +3 -3
- package/src/docx.ts +1 -1
- package/src/drive.ts +13 -17
- package/src/dynamic-agent.ts +1 -1
- package/src/feishu-command-handler.ts +59 -0
- package/src/media.test.ts +60 -13
- package/src/media.ts +23 -9
- package/src/monitor.account.ts +19 -8
- package/src/monitor.reaction.test.ts +111 -105
- package/src/monitor.startup.test.ts +11 -10
- package/src/monitor.startup.ts +20 -7
- package/src/monitor.state.ts +4 -1
- package/src/monitor.test-mocks.ts +42 -9
- package/src/monitor.transport.ts +4 -1
- package/src/monitor.ts +4 -4
- package/src/monitor.webhook-security.test.ts +8 -23
- package/src/onboarding.status.test.ts +1 -1
- package/src/onboarding.test.ts +143 -0
- package/src/onboarding.ts +86 -71
- package/src/outbound.test.ts +178 -0
- package/src/outbound.ts +39 -6
- package/src/perm.ts +11 -15
- package/src/policy.test.ts +40 -0
- package/src/policy.ts +9 -10
- package/src/probe.test.ts +18 -18
- package/src/reactions.ts +1 -1
- package/src/reply-dispatcher.test.ts +175 -0
- package/src/reply-dispatcher.ts +69 -21
- package/src/runtime.ts +1 -1
- package/src/secret-input.ts +8 -14
- package/src/send-message.ts +71 -0
- package/src/send-target.test.ts +1 -1
- package/src/send-target.ts +1 -1
- package/src/send.reply-fallback.test.ts +74 -0
- package/src/send.test.ts +1 -1
- package/src/send.ts +88 -49
- package/src/streaming-card.test.ts +54 -0
- package/src/streaming-card.ts +96 -28
- package/src/targets.ts +5 -1
- package/src/tool-account-routing.test.ts +3 -3
- package/src/tool-account.ts +1 -1
- package/src/tool-factory-test-harness.ts +1 -1
- package/src/tool-result.test.ts +32 -0
- package/src/tool-result.ts +14 -0
- package/src/types.ts +2 -3
- package/src/typing.ts +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
252
|
-
|
|
262
|
+
return collectAllowlistProviderRestrictSendersWarnings({
|
|
263
|
+
cfg,
|
|
253
264
|
providerConfigPresent: cfg.channels?.feishu !== undefined,
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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";
|
package/src/client.test.ts
CHANGED
|
@@ -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 {
|
|
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("
|
|
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
|
|
package/src/config-schema.ts
CHANGED
|
@@ -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.
|
|
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,
|