@openclaw/feishu 2026.2.25 → 2026.3.2

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 (73) hide show
  1. package/index.ts +2 -0
  2. package/package.json +2 -1
  3. package/skills/feishu-doc/SKILL.md +109 -3
  4. package/src/accounts.test.ts +161 -0
  5. package/src/accounts.ts +76 -8
  6. package/src/async.ts +62 -0
  7. package/src/bitable.ts +189 -215
  8. package/src/bot.card-action.test.ts +63 -0
  9. package/src/bot.checkBotMentioned.test.ts +56 -1
  10. package/src/bot.test.ts +1271 -56
  11. package/src/bot.ts +499 -215
  12. package/src/card-action.ts +79 -0
  13. package/src/channel.ts +26 -4
  14. package/src/chat-schema.ts +24 -0
  15. package/src/chat.test.ts +89 -0
  16. package/src/chat.ts +130 -0
  17. package/src/client.test.ts +121 -0
  18. package/src/client.ts +13 -0
  19. package/src/config-schema.test.ts +101 -1
  20. package/src/config-schema.ts +66 -11
  21. package/src/dedup.ts +47 -1
  22. package/src/doc-schema.ts +135 -0
  23. package/src/docx-batch-insert.ts +190 -0
  24. package/src/docx-color-text.ts +149 -0
  25. package/src/docx-table-ops.ts +298 -0
  26. package/src/docx.account-selection.test.ts +70 -0
  27. package/src/docx.test.ts +331 -9
  28. package/src/docx.ts +996 -72
  29. package/src/drive.ts +38 -33
  30. package/src/media.test.ts +227 -7
  31. package/src/media.ts +52 -11
  32. package/src/mention.ts +1 -1
  33. package/src/monitor.account.ts +534 -0
  34. package/src/monitor.reaction.test.ts +578 -0
  35. package/src/monitor.startup.test.ts +203 -0
  36. package/src/monitor.startup.ts +51 -0
  37. package/src/monitor.state.defaults.test.ts +46 -0
  38. package/src/monitor.state.ts +152 -0
  39. package/src/monitor.test-mocks.ts +12 -0
  40. package/src/monitor.transport.ts +163 -0
  41. package/src/monitor.ts +44 -346
  42. package/src/monitor.webhook-security.test.ts +53 -10
  43. package/src/onboarding.status.test.ts +25 -0
  44. package/src/onboarding.ts +144 -52
  45. package/src/outbound.test.ts +181 -0
  46. package/src/outbound.ts +94 -7
  47. package/src/perm.ts +37 -30
  48. package/src/policy.test.ts +56 -1
  49. package/src/policy.ts +5 -1
  50. package/src/post.test.ts +105 -0
  51. package/src/post.ts +274 -0
  52. package/src/probe.test.ts +271 -0
  53. package/src/probe.ts +131 -19
  54. package/src/reply-dispatcher.test.ts +300 -0
  55. package/src/reply-dispatcher.ts +159 -46
  56. package/src/secret-input.ts +19 -0
  57. package/src/send-target.test.ts +74 -0
  58. package/src/send-target.ts +6 -2
  59. package/src/send.reply-fallback.test.ts +105 -0
  60. package/src/send.test.ts +168 -0
  61. package/src/send.ts +143 -18
  62. package/src/streaming-card.ts +131 -43
  63. package/src/targets.test.ts +55 -1
  64. package/src/targets.ts +32 -7
  65. package/src/tool-account-routing.test.ts +129 -0
  66. package/src/tool-account.ts +70 -0
  67. package/src/tool-factory-test-harness.ts +76 -0
  68. package/src/tools-config.test.ts +21 -0
  69. package/src/tools-config.ts +2 -1
  70. package/src/types.ts +10 -1
  71. package/src/typing.test.ts +144 -0
  72. package/src/typing.ts +140 -10
  73. package/src/wiki.ts +55 -50
@@ -0,0 +1,79 @@
1
+ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
2
+ import { resolveFeishuAccount } from "./accounts.js";
3
+ import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
4
+
5
+ export type FeishuCardActionEvent = {
6
+ operator: {
7
+ open_id: string;
8
+ user_id: string;
9
+ union_id: string;
10
+ };
11
+ token: string;
12
+ action: {
13
+ value: Record<string, unknown>;
14
+ tag: string;
15
+ };
16
+ context: {
17
+ open_id: string;
18
+ user_id: string;
19
+ chat_id: string;
20
+ };
21
+ };
22
+
23
+ export async function handleFeishuCardAction(params: {
24
+ cfg: ClawdbotConfig;
25
+ event: FeishuCardActionEvent;
26
+ botOpenId?: string;
27
+ runtime?: RuntimeEnv;
28
+ accountId?: string;
29
+ }): Promise<void> {
30
+ const { cfg, event, runtime, accountId } = params;
31
+ const account = resolveFeishuAccount({ cfg, accountId });
32
+ const log = runtime?.log ?? console.log;
33
+
34
+ // Extract action value
35
+ const actionValue = event.action.value;
36
+ let content = "";
37
+ if (typeof actionValue === "object" && actionValue !== null) {
38
+ if ("text" in actionValue && typeof actionValue.text === "string") {
39
+ content = actionValue.text;
40
+ } else if ("command" in actionValue && typeof actionValue.command === "string") {
41
+ content = actionValue.command;
42
+ } else {
43
+ content = JSON.stringify(actionValue);
44
+ }
45
+ } else {
46
+ content = String(actionValue);
47
+ }
48
+
49
+ // Construct a synthetic message event
50
+ const messageEvent: FeishuMessageEvent = {
51
+ sender: {
52
+ sender_id: {
53
+ open_id: event.operator.open_id,
54
+ user_id: event.operator.user_id,
55
+ union_id: event.operator.union_id,
56
+ },
57
+ },
58
+ message: {
59
+ message_id: `card-action-${event.token}`,
60
+ chat_id: event.context.chat_id || event.operator.open_id,
61
+ chat_type: event.context.chat_id ? "group" : "p2p",
62
+ message_type: "text",
63
+ content: JSON.stringify({ text: content }),
64
+ },
65
+ };
66
+
67
+ log(
68
+ `feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`,
69
+ );
70
+
71
+ // Dispatch as normal message
72
+ await handleFeishuMessage({
73
+ cfg,
74
+ event: messageEvent,
75
+ botOpenId: params.botOpenId,
76
+ runtime,
77
+ accountId,
78
+ });
79
+ }
package/src/channel.ts CHANGED
@@ -38,6 +38,22 @@ const meta: ChannelMeta = {
38
38
  order: 70,
39
39
  };
40
40
 
41
+ const secretInputJsonSchema = {
42
+ oneOf: [
43
+ { type: "string" },
44
+ {
45
+ type: "object",
46
+ additionalProperties: false,
47
+ required: ["source", "provider", "id"],
48
+ properties: {
49
+ source: { type: "string", enum: ["env", "file", "exec"] },
50
+ provider: { type: "string", minLength: 1 },
51
+ id: { type: "string", minLength: 1 },
52
+ },
53
+ },
54
+ ],
55
+ } as const;
56
+
41
57
  export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
42
58
  id: "feishu",
43
59
  meta: {
@@ -79,10 +95,11 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
79
95
  additionalProperties: false,
80
96
  properties: {
81
97
  enabled: { type: "boolean" },
98
+ defaultAccount: { type: "string" },
82
99
  appId: { type: "string" },
83
- appSecret: { type: "string" },
100
+ appSecret: secretInputJsonSchema,
84
101
  encryptKey: { type: "string" },
85
- verificationToken: { type: "string" },
102
+ verificationToken: secretInputJsonSchema,
86
103
  domain: {
87
104
  oneOf: [
88
105
  { type: "string", enum: ["feishu", "lark"] },
@@ -101,7 +118,12 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
101
118
  items: { oneOf: [{ type: "string" }, { type: "number" }] },
102
119
  },
103
120
  requireMention: { type: "boolean" },
121
+ groupSessionScope: {
122
+ type: "string",
123
+ enum: ["group", "group_sender", "group_topic", "group_topic_sender"],
124
+ },
104
125
  topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
126
+ replyInThread: { type: "string", enum: ["disabled", "enabled"] },
105
127
  historyLimit: { type: "integer", minimum: 0 },
106
128
  dmHistoryLimit: { type: "integer", minimum: 0 },
107
129
  textChunkLimit: { type: "integer", minimum: 1 },
@@ -116,9 +138,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
116
138
  enabled: { type: "boolean" },
117
139
  name: { type: "string" },
118
140
  appId: { type: "string" },
119
- appSecret: { type: "string" },
141
+ appSecret: secretInputJsonSchema,
120
142
  encryptKey: { type: "string" },
121
- verificationToken: { type: "string" },
143
+ verificationToken: secretInputJsonSchema,
122
144
  domain: { type: "string", enum: ["feishu", "lark"] },
123
145
  connectionMode: { type: "string", enum: ["websocket", "webhook"] },
124
146
  webhookHost: { type: "string" },
@@ -0,0 +1,24 @@
1
+ import { Type, type Static } from "@sinclair/typebox";
2
+
3
+ const CHAT_ACTION_VALUES = ["members", "info"] as const;
4
+ const MEMBER_ID_TYPE_VALUES = ["open_id", "user_id", "union_id"] as const;
5
+
6
+ export const FeishuChatSchema = Type.Object({
7
+ action: Type.Unsafe<(typeof CHAT_ACTION_VALUES)[number]>({
8
+ type: "string",
9
+ enum: [...CHAT_ACTION_VALUES],
10
+ description: "Action to run: members | info",
11
+ }),
12
+ chat_id: Type.String({ description: "Chat ID (from URL or event payload)" }),
13
+ page_size: Type.Optional(Type.Number({ description: "Page size (1-100, default 50)" })),
14
+ page_token: Type.Optional(Type.String({ description: "Pagination token" })),
15
+ member_id_type: Type.Optional(
16
+ Type.Unsafe<(typeof MEMBER_ID_TYPE_VALUES)[number]>({
17
+ type: "string",
18
+ enum: [...MEMBER_ID_TYPE_VALUES],
19
+ description: "Member ID type (default: open_id)",
20
+ }),
21
+ ),
22
+ });
23
+
24
+ export type FeishuChatParams = Static<typeof FeishuChatSchema>;
@@ -0,0 +1,89 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { registerFeishuChatTools } from "./chat.js";
3
+
4
+ const createFeishuClientMock = vi.hoisted(() => vi.fn());
5
+
6
+ vi.mock("./client.js", () => ({
7
+ createFeishuClient: createFeishuClientMock,
8
+ }));
9
+
10
+ describe("registerFeishuChatTools", () => {
11
+ const chatGetMock = vi.hoisted(() => vi.fn());
12
+ const chatMembersGetMock = vi.hoisted(() => vi.fn());
13
+
14
+ beforeEach(() => {
15
+ vi.clearAllMocks();
16
+ createFeishuClientMock.mockReturnValue({
17
+ im: {
18
+ chat: { get: chatGetMock },
19
+ chatMembers: { get: chatMembersGetMock },
20
+ },
21
+ });
22
+ });
23
+
24
+ it("registers feishu_chat and handles info/members actions", async () => {
25
+ const registerTool = vi.fn();
26
+ registerFeishuChatTools({
27
+ config: {
28
+ channels: {
29
+ feishu: {
30
+ enabled: true,
31
+ appId: "app_id",
32
+ appSecret: "app_secret",
33
+ tools: { chat: true },
34
+ },
35
+ },
36
+ } as any,
37
+ logger: { debug: vi.fn(), info: vi.fn() } as any,
38
+ registerTool,
39
+ } as any);
40
+
41
+ expect(registerTool).toHaveBeenCalledTimes(1);
42
+ const tool = registerTool.mock.calls[0]?.[0];
43
+ expect(tool?.name).toBe("feishu_chat");
44
+
45
+ chatGetMock.mockResolvedValueOnce({
46
+ code: 0,
47
+ data: { name: "group name", user_count: 3 },
48
+ });
49
+ const infoResult = await tool.execute("tc_1", { action: "info", chat_id: "oc_1" });
50
+ expect(infoResult.details).toEqual(
51
+ expect.objectContaining({ chat_id: "oc_1", name: "group name", user_count: 3 }),
52
+ );
53
+
54
+ chatMembersGetMock.mockResolvedValueOnce({
55
+ code: 0,
56
+ data: {
57
+ has_more: false,
58
+ page_token: "",
59
+ items: [{ member_id: "ou_1", name: "member1", member_id_type: "open_id" }],
60
+ },
61
+ });
62
+ const membersResult = await tool.execute("tc_2", { action: "members", chat_id: "oc_1" });
63
+ expect(membersResult.details).toEqual(
64
+ expect.objectContaining({
65
+ chat_id: "oc_1",
66
+ members: [expect.objectContaining({ member_id: "ou_1", name: "member1" })],
67
+ }),
68
+ );
69
+ });
70
+
71
+ it("skips registration when chat tool is disabled", () => {
72
+ const registerTool = vi.fn();
73
+ registerFeishuChatTools({
74
+ config: {
75
+ channels: {
76
+ feishu: {
77
+ enabled: true,
78
+ appId: "app_id",
79
+ appSecret: "app_secret",
80
+ tools: { chat: false },
81
+ },
82
+ },
83
+ } as any,
84
+ logger: { debug: vi.fn(), info: vi.fn() } as any,
85
+ registerTool,
86
+ } as any);
87
+ expect(registerTool).not.toHaveBeenCalled();
88
+ });
89
+ });
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";
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,121 @@
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
+ vi.mock("@larksuiteoapi/node-sdk", () => ({
16
+ AppType: { SelfBuild: "self" },
17
+ Domain: { Feishu: "https://open.feishu.cn", Lark: "https://open.larksuite.com" },
18
+ LoggerLevel: { info: "info" },
19
+ Client: vi.fn(),
20
+ WSClient: wsClientCtorMock,
21
+ EventDispatcher: vi.fn(),
22
+ }));
23
+
24
+ vi.mock("https-proxy-agent", () => ({
25
+ HttpsProxyAgent: httpsProxyAgentCtorMock,
26
+ }));
27
+
28
+ import { createFeishuWSClient } from "./client.js";
29
+
30
+ const proxyEnvKeys = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"] as const;
31
+ type ProxyEnvKey = (typeof proxyEnvKeys)[number];
32
+
33
+ let priorProxyEnv: Partial<Record<ProxyEnvKey, string | undefined>> = {};
34
+
35
+ const baseAccount: ResolvedFeishuAccount = {
36
+ accountId: "main",
37
+ selectionSource: "explicit",
38
+ enabled: true,
39
+ configured: true,
40
+ appId: "app_123",
41
+ appSecret: "secret_123",
42
+ domain: "feishu",
43
+ config: {} as FeishuConfig,
44
+ };
45
+
46
+ function firstWsClientOptions(): { agent?: unknown } {
47
+ const calls = wsClientCtorMock.mock.calls as unknown as Array<[options: { agent?: unknown }]>;
48
+ return calls[0]?.[0] ?? {};
49
+ }
50
+
51
+ beforeEach(() => {
52
+ priorProxyEnv = {};
53
+ for (const key of proxyEnvKeys) {
54
+ priorProxyEnv[key] = process.env[key];
55
+ delete process.env[key];
56
+ }
57
+ vi.clearAllMocks();
58
+ });
59
+
60
+ afterEach(() => {
61
+ for (const key of proxyEnvKeys) {
62
+ const value = priorProxyEnv[key];
63
+ if (value === undefined) {
64
+ delete process.env[key];
65
+ } else {
66
+ process.env[key] = value;
67
+ }
68
+ }
69
+ });
70
+
71
+ describe("createFeishuWSClient proxy handling", () => {
72
+ it("does not set a ws proxy agent when proxy env is absent", () => {
73
+ createFeishuWSClient(baseAccount);
74
+
75
+ expect(httpsProxyAgentCtorMock).not.toHaveBeenCalled();
76
+ const options = firstWsClientOptions();
77
+ expect(options?.agent).toBeUndefined();
78
+ });
79
+
80
+ it("prefers HTTPS proxy vars over HTTP proxy vars across runtimes", () => {
81
+ process.env.https_proxy = "http://lower-https:8001";
82
+ process.env.HTTPS_PROXY = "http://upper-https:8002";
83
+ process.env.http_proxy = "http://lower-http:8003";
84
+ process.env.HTTP_PROXY = "http://upper-http:8004";
85
+
86
+ createFeishuWSClient(baseAccount);
87
+
88
+ // On Windows env keys are case-insensitive, so setting HTTPS_PROXY may
89
+ // overwrite https_proxy. We assert https proxies still win over http.
90
+ const expectedProxy = process.env.https_proxy || process.env.HTTPS_PROXY;
91
+ expect(expectedProxy).toBeTruthy();
92
+ expect(httpsProxyAgentCtorMock).toHaveBeenCalledTimes(1);
93
+ expect(httpsProxyAgentCtorMock).toHaveBeenCalledWith(expectedProxy);
94
+ const options = firstWsClientOptions();
95
+ expect(options.agent).toEqual({ proxyUrl: expectedProxy });
96
+ });
97
+
98
+ it("accepts lowercase https_proxy when it is the configured HTTPS proxy var", () => {
99
+ process.env.https_proxy = "http://lower-https:8001";
100
+
101
+ createFeishuWSClient(baseAccount);
102
+
103
+ const expectedHttpsProxy = process.env.https_proxy || process.env.HTTPS_PROXY;
104
+ expect(httpsProxyAgentCtorMock).toHaveBeenCalledTimes(1);
105
+ expect(expectedHttpsProxy).toBeTruthy();
106
+ expect(httpsProxyAgentCtorMock).toHaveBeenCalledWith(expectedHttpsProxy);
107
+ const options = firstWsClientOptions();
108
+ expect(options.agent).toEqual({ proxyUrl: expectedHttpsProxy });
109
+ });
110
+
111
+ it("passes HTTP_PROXY to ws client when https vars are unset", () => {
112
+ process.env.HTTP_PROXY = "http://upper-http:8999";
113
+
114
+ createFeishuWSClient(baseAccount);
115
+
116
+ expect(httpsProxyAgentCtorMock).toHaveBeenCalledTimes(1);
117
+ expect(httpsProxyAgentCtorMock).toHaveBeenCalledWith("http://upper-http:8999");
118
+ const options = firstWsClientOptions();
119
+ expect(options.agent).toEqual({ proxyUrl: "http://upper-http:8999" });
120
+ });
121
+ });
package/src/client.ts CHANGED
@@ -1,6 +1,17 @@
1
1
  import * as Lark from "@larksuiteoapi/node-sdk";
2
+ import { HttpsProxyAgent } from "https-proxy-agent";
2
3
  import type { FeishuDomain, ResolvedFeishuAccount } from "./types.js";
3
4
 
5
+ function getWsProxyAgent(): HttpsProxyAgent<string> | undefined {
6
+ const proxyUrl =
7
+ process.env.https_proxy ||
8
+ process.env.HTTPS_PROXY ||
9
+ process.env.http_proxy ||
10
+ process.env.HTTP_PROXY;
11
+ if (!proxyUrl) return undefined;
12
+ return new HttpsProxyAgent(proxyUrl);
13
+ }
14
+
4
15
  // Multi-account client cache
5
16
  const clientCache = new Map<
6
17
  string,
@@ -81,11 +92,13 @@ export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSCli
81
92
  throw new Error(`Feishu credentials not configured for account "${accountId}"`);
82
93
  }
83
94
 
95
+ const agent = getWsProxyAgent();
84
96
  return new Lark.WSClient({
85
97
  appId,
86
98
  appSecret,
87
99
  domain: resolveDomain(domain),
88
100
  loggerLevel: Lark.LoggerLevel.info,
101
+ ...(agent ? { agent } : {}),
89
102
  });
90
103
  }
91
104
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { FeishuConfigSchema } from "./config-schema.js";
2
+ import { FeishuConfigSchema, FeishuGroupSchema } from "./config-schema.js";
3
3
 
4
4
  describe("FeishuConfigSchema webhook validation", () => {
5
5
  it("applies top-level defaults", () => {
@@ -85,4 +85,104 @@ describe("FeishuConfigSchema webhook validation", () => {
85
85
 
86
86
  expect(result.success).toBe(true);
87
87
  });
88
+
89
+ it("accepts SecretRef verificationToken in webhook mode", () => {
90
+ const result = FeishuConfigSchema.safeParse({
91
+ connectionMode: "webhook",
92
+ verificationToken: {
93
+ source: "env",
94
+ provider: "default",
95
+ id: "FEISHU_VERIFICATION_TOKEN",
96
+ },
97
+ appId: "cli_top",
98
+ appSecret: {
99
+ source: "env",
100
+ provider: "default",
101
+ id: "FEISHU_APP_SECRET",
102
+ },
103
+ });
104
+
105
+ expect(result.success).toBe(true);
106
+ });
107
+ });
108
+
109
+ describe("FeishuConfigSchema replyInThread", () => {
110
+ it("accepts replyInThread at top level", () => {
111
+ const result = FeishuConfigSchema.parse({ replyInThread: "enabled" });
112
+ expect(result.replyInThread).toBe("enabled");
113
+ });
114
+
115
+ it("defaults replyInThread to undefined when not set", () => {
116
+ const result = FeishuConfigSchema.parse({});
117
+ expect(result.replyInThread).toBeUndefined();
118
+ });
119
+
120
+ it("rejects invalid replyInThread value", () => {
121
+ const result = FeishuConfigSchema.safeParse({ replyInThread: "always" });
122
+ expect(result.success).toBe(false);
123
+ });
124
+
125
+ it("accepts replyInThread in group config", () => {
126
+ const result = FeishuGroupSchema.parse({ replyInThread: "enabled" });
127
+ expect(result.replyInThread).toBe("enabled");
128
+ });
129
+
130
+ it("accepts replyInThread in account config", () => {
131
+ const result = FeishuConfigSchema.parse({
132
+ accounts: {
133
+ main: { replyInThread: "enabled" },
134
+ },
135
+ });
136
+ expect(result.accounts?.main?.replyInThread).toBe("enabled");
137
+ });
138
+ });
139
+
140
+ describe("FeishuConfigSchema optimization flags", () => {
141
+ it("defaults top-level typingIndicator and resolveSenderNames to true", () => {
142
+ const result = FeishuConfigSchema.parse({});
143
+ expect(result.typingIndicator).toBe(true);
144
+ expect(result.resolveSenderNames).toBe(true);
145
+ });
146
+
147
+ it("accepts account-level optimization flags", () => {
148
+ const result = FeishuConfigSchema.parse({
149
+ accounts: {
150
+ main: {
151
+ typingIndicator: false,
152
+ resolveSenderNames: false,
153
+ },
154
+ },
155
+ });
156
+ expect(result.accounts?.main?.typingIndicator).toBe(false);
157
+ expect(result.accounts?.main?.resolveSenderNames).toBe(false);
158
+ });
159
+ });
160
+
161
+ describe("FeishuConfigSchema defaultAccount", () => {
162
+ it("accepts defaultAccount when it matches an account key", () => {
163
+ const result = FeishuConfigSchema.safeParse({
164
+ defaultAccount: "router-d",
165
+ accounts: {
166
+ "router-d": { appId: "cli_router", appSecret: "secret_router" },
167
+ },
168
+ });
169
+
170
+ expect(result.success).toBe(true);
171
+ });
172
+
173
+ it("rejects defaultAccount when it does not match an account key", () => {
174
+ const result = FeishuConfigSchema.safeParse({
175
+ defaultAccount: "router-d",
176
+ accounts: {
177
+ backup: { appId: "cli_backup", appSecret: "secret_backup" },
178
+ },
179
+ });
180
+
181
+ expect(result.success).toBe(false);
182
+ if (!result.success) {
183
+ expect(result.error.issues.some((issue) => issue.path.join(".") === "defaultAccount")).toBe(
184
+ true,
185
+ );
186
+ }
187
+ });
88
188
  });