Skip to content

Commit 5209c48

Browse files
liuweiflyNereoTakhoffman
authored
feat(feishu): add chat info/member tool (#14674)
* feat(feishu): add chat members/info tool support * Feishu: harden chat tool schema and coverage --------- Co-authored-by: Nereo <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent 0740fb8 commit 5209c48

File tree

10 files changed

+273
-1
lines changed

10 files changed

+273
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
1414
- Feishu/Doc permissions: support optional owner permission grant fields on `feishu_doc` create and report permission metadata only when the grant call succeeds, with regression coverage for success/failure/omitted-owner paths. (#28295) Thanks @zhoulongchao77.
1515
- Feishu/Docx tables + uploads: add `feishu_doc` actions for Docx table creation/cell writing (`create_table`, `write_table_cells`, `create_table_with_values`) and image/file uploads (`upload_image`, `upload_file`) with stricter create/upload error handling for missing `document_id` and placeholder cleanup failures. (#20304) Thanks @xuhao1.
1616
- Feishu/Reactions: add inbound `im.message.reaction.created_v1` handling, route verified reactions through synthetic inbound turns, and harden verification with timeout + fail-closed filtering so non-bot or unverified reactions are dropped. (#16716) Thanks @schumilin.
17+
- Feishu/Chat tooling: add `feishu_chat` tool actions for chat info and member queries, with configurable enablement under `channels.feishu.tools.chat`. (#14674)
1718
- Memory/LanceDB: support custom OpenAI `baseUrl` and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc.
1819

1920
### Fixes

extensions/feishu/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
22
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
33
import { registerFeishuBitableTools } from "./src/bitable.js";
44
import { feishuPlugin } from "./src/channel.js";
5+
import { registerFeishuChatTools } from "./src/chat.js";
56
import { registerFeishuDocTools } from "./src/docx.js";
67
import { registerFeishuDriveTools } from "./src/drive.js";
78
import { registerFeishuPermTools } from "./src/perm.js";
@@ -53,6 +54,7 @@ const plugin = {
5354
setFeishuRuntime(api.runtime);
5455
api.registerChannel({ plugin: feishuPlugin });
5556
registerFeishuDocTools(api);
57+
registerFeishuChatTools(api);
5658
registerFeishuWikiTools(api);
5759
registerFeishuDriveTools(api);
5860
registerFeishuPermTools(api);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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>;

extensions/feishu/src/chat.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

extensions/feishu/src/chat.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

extensions/feishu/src/config-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ const DynamicAgentCreationSchema = z
8282
const FeishuToolsConfigSchema = z
8383
.object({
8484
doc: z.boolean().optional(), // Document operations (default: true)
85+
chat: z.boolean().optional(), // Chat info + member query operations (default: true)
8586
wiki: z.boolean().optional(), // Knowledge base operations (default: true, requires doc)
8687
drive: z.boolean().optional(), // Cloud storage operations (default: true)
8788
perm: z.boolean().optional(), // Permission management (default: false, sensitive)

extensions/feishu/src/tool-account.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export function resolveAnyEnabledFeishuToolsConfig(
4141
): Required<FeishuToolsConfig> {
4242
const merged: Required<FeishuToolsConfig> = {
4343
doc: false,
44+
chat: false,
4445
wiki: false,
4546
drive: false,
4647
perm: false,
@@ -49,6 +50,7 @@ export function resolveAnyEnabledFeishuToolsConfig(
4950
for (const account of accounts) {
5051
const cfg = resolveToolsConfig(account.config.tools);
5152
merged.doc = merged.doc || cfg.doc;
53+
merged.chat = merged.chat || cfg.chat;
5254
merged.wiki = merged.wiki || cfg.wiki;
5355
merged.drive = merged.drive || cfg.drive;
5456
merged.perm = merged.perm || cfg.perm;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, expect, it } from "vitest";
2+
import { FeishuConfigSchema } from "./config-schema.js";
3+
import { resolveToolsConfig } from "./tools-config.js";
4+
5+
describe("feishu tools config", () => {
6+
it("enables chat tool by default", () => {
7+
const resolved = resolveToolsConfig(undefined);
8+
expect(resolved.chat).toBe(true);
9+
});
10+
11+
it("accepts tools.chat in config schema", () => {
12+
const parsed = FeishuConfigSchema.parse({
13+
enabled: true,
14+
tools: {
15+
chat: false,
16+
},
17+
});
18+
19+
expect(parsed.tools?.chat).toBe(false);
20+
});
21+
});

extensions/feishu/src/tools-config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import type { FeishuToolsConfig } from "./types.js";
22

33
/**
44
* Default tool configuration.
5-
* - doc, wiki, drive, scopes: enabled by default
5+
* - doc, chat, wiki, drive, scopes: enabled by default
66
* - perm: disabled by default (sensitive operation)
77
*/
88
export const DEFAULT_TOOLS_CONFIG: Required<FeishuToolsConfig> = {
99
doc: true,
10+
chat: true,
1011
wiki: true,
1112
drive: true,
1213
perm: false,

extensions/feishu/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export type FeishuMediaInfo = {
6767

6868
export type FeishuToolsConfig = {
6969
doc?: boolean;
70+
chat?: boolean;
7071
wiki?: boolean;
7172
drive?: boolean;
7273
perm?: boolean;

0 commit comments

Comments
 (0)