feat(feishu): add chat info/member tool (openclaw#14674)

* feat(feishu): add chat members/info tool support

* Feishu: harden chat tool schema and coverage

---------

Co-authored-by: Nereo <nereo@Nereos-Mac-mini.local>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
刘苇
2026-03-01 00:00:31 +08:00
committed by GitHub
parent 0740fb83d7
commit 5209c48923
10 changed files with 273 additions and 1 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- Feishu/Chat tooling: add `feishu_chat` tool actions for chat info and member queries, with configurable enablement under `channels.feishu.tools.chat`. (#14674)
- Memory/LanceDB: support custom OpenAI `baseUrl` and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc.
### Fixes

View File

@@ -2,6 +2,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { registerFeishuBitableTools } from "./src/bitable.js";
import { feishuPlugin } from "./src/channel.js";
import { registerFeishuChatTools } from "./src/chat.js";
import { registerFeishuDocTools } from "./src/docx.js";
import { registerFeishuDriveTools } from "./src/drive.js";
import { registerFeishuPermTools } from "./src/perm.js";
@@ -53,6 +54,7 @@ const plugin = {
setFeishuRuntime(api.runtime);
api.registerChannel({ plugin: feishuPlugin });
registerFeishuDocTools(api);
registerFeishuChatTools(api);
registerFeishuWikiTools(api);
registerFeishuDriveTools(api);
registerFeishuPermTools(api);

View File

@@ -0,0 +1,24 @@
import { Type, type Static } from "@sinclair/typebox";
const CHAT_ACTION_VALUES = ["members", "info"] as const;
const MEMBER_ID_TYPE_VALUES = ["open_id", "user_id", "union_id"] as const;
export const FeishuChatSchema = Type.Object({
action: Type.Unsafe<(typeof CHAT_ACTION_VALUES)[number]>({
type: "string",
enum: [...CHAT_ACTION_VALUES],
description: "Action to run: members | info",
}),
chat_id: Type.String({ description: "Chat ID (from URL or event payload)" }),
page_size: Type.Optional(Type.Number({ description: "Page size (1-100, default 50)" })),
page_token: Type.Optional(Type.String({ description: "Pagination token" })),
member_id_type: Type.Optional(
Type.Unsafe<(typeof MEMBER_ID_TYPE_VALUES)[number]>({
type: "string",
enum: [...MEMBER_ID_TYPE_VALUES],
description: "Member ID type (default: open_id)",
}),
),
});
export type FeishuChatParams = Static<typeof FeishuChatSchema>;

View File

@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerFeishuChatTools } from "./chat.js";
const createFeishuClientMock = vi.hoisted(() => vi.fn());
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
describe("registerFeishuChatTools", () => {
const chatGetMock = vi.hoisted(() => vi.fn());
const chatMembersGetMock = vi.hoisted(() => vi.fn());
beforeEach(() => {
vi.clearAllMocks();
createFeishuClientMock.mockReturnValue({
im: {
chat: { get: chatGetMock },
chatMembers: { get: chatMembersGetMock },
},
});
});
it("registers feishu_chat and handles info/members actions", async () => {
const registerTool = vi.fn();
registerFeishuChatTools({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret",
tools: { chat: true },
},
},
} as any,
logger: { debug: vi.fn(), info: vi.fn() } as any,
registerTool,
} as any);
expect(registerTool).toHaveBeenCalledTimes(1);
const tool = registerTool.mock.calls[0]?.[0];
expect(tool?.name).toBe("feishu_chat");
chatGetMock.mockResolvedValueOnce({
code: 0,
data: { name: "group name", user_count: 3 },
});
const infoResult = await tool.execute("tc_1", { action: "info", chat_id: "oc_1" });
expect(infoResult.details).toEqual(
expect.objectContaining({ chat_id: "oc_1", name: "group name", user_count: 3 }),
);
chatMembersGetMock.mockResolvedValueOnce({
code: 0,
data: {
has_more: false,
page_token: "",
items: [{ member_id: "ou_1", name: "member1", member_id_type: "open_id" }],
},
});
const membersResult = await tool.execute("tc_2", { action: "members", chat_id: "oc_1" });
expect(membersResult.details).toEqual(
expect.objectContaining({
chat_id: "oc_1",
members: [expect.objectContaining({ member_id: "ou_1", name: "member1" })],
}),
);
});
it("skips registration when chat tool is disabled", () => {
const registerTool = vi.fn();
registerFeishuChatTools({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret",
tools: { chat: false },
},
},
} as any,
logger: { debug: vi.fn(), info: vi.fn() } as any,
registerTool,
} as any);
expect(registerTool).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,130 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { FeishuChatSchema, type FeishuChatParams } from "./chat-schema.js";
import { createFeishuClient } from "./client.js";
import { resolveToolsConfig } from "./tools-config.js";
function json(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
details: data,
};
}
async function getChatInfo(client: Lark.Client, chatId: string) {
const res = await client.im.chat.get({ path: { chat_id: chatId } });
if (res.code !== 0) {
throw new Error(res.msg);
}
const chat = res.data;
return {
chat_id: chatId,
name: chat?.name,
description: chat?.description,
owner_id: chat?.owner_id,
tenant_key: chat?.tenant_key,
user_count: chat?.user_count,
chat_mode: chat?.chat_mode,
chat_type: chat?.chat_type,
join_message_visibility: chat?.join_message_visibility,
leave_message_visibility: chat?.leave_message_visibility,
membership_approval: chat?.membership_approval,
moderation_permission: chat?.moderation_permission,
avatar: chat?.avatar,
};
}
async function getChatMembers(
client: Lark.Client,
chatId: string,
pageSize?: number,
pageToken?: string,
memberIdType?: "open_id" | "user_id" | "union_id",
) {
const page_size = pageSize ? Math.max(1, Math.min(100, pageSize)) : 50;
const res = await client.im.chatMembers.get({
path: { chat_id: chatId },
params: {
page_size,
page_token: pageToken,
member_id_type: memberIdType ?? "open_id",
},
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return {
chat_id: chatId,
has_more: res.data?.has_more,
page_token: res.data?.page_token,
members:
res.data?.items?.map((item) => ({
member_id: item.member_id,
name: item.name,
tenant_key: item.tenant_key,
member_id_type: item.member_id_type,
})) ?? [],
};
}
export function registerFeishuChatTools(api: OpenClawPluginApi) {
if (!api.config) {
api.logger.debug?.("feishu_chat: No config available, skipping chat tools");
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_chat: No Feishu accounts configured, skipping chat tools");
return;
}
const firstAccount = accounts[0];
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
if (!toolsCfg.chat) {
api.logger.debug?.("feishu_chat: chat tool disabled in config");
return;
}
const getClient = () => createFeishuClient(firstAccount);
api.registerTool(
{
name: "feishu_chat",
label: "Feishu Chat",
description: "Feishu chat operations. Actions: members, info",
parameters: FeishuChatSchema,
async execute(_toolCallId, params) {
const p = params as FeishuChatParams;
try {
const client = getClient();
switch (p.action) {
case "members":
return json(
await getChatMembers(
client,
p.chat_id,
p.page_size,
p.page_token,
p.member_id_type,
),
);
case "info":
return json(await getChatInfo(client, p.chat_id));
default:
return json({ error: `Unknown action: ${String(p.action)}` });
}
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
{ name: "feishu_chat" },
);
api.logger.info?.("feishu_chat: Registered feishu_chat tool");
}

View File

@@ -82,6 +82,7 @@ const DynamicAgentCreationSchema = z
const FeishuToolsConfigSchema = z
.object({
doc: z.boolean().optional(), // Document operations (default: true)
chat: z.boolean().optional(), // Chat info + member query operations (default: true)
wiki: z.boolean().optional(), // Knowledge base operations (default: true, requires doc)
drive: z.boolean().optional(), // Cloud storage operations (default: true)
perm: z.boolean().optional(), // Permission management (default: false, sensitive)

View File

@@ -41,6 +41,7 @@ export function resolveAnyEnabledFeishuToolsConfig(
): Required<FeishuToolsConfig> {
const merged: Required<FeishuToolsConfig> = {
doc: false,
chat: false,
wiki: false,
drive: false,
perm: false,
@@ -49,6 +50,7 @@ export function resolveAnyEnabledFeishuToolsConfig(
for (const account of accounts) {
const cfg = resolveToolsConfig(account.config.tools);
merged.doc = merged.doc || cfg.doc;
merged.chat = merged.chat || cfg.chat;
merged.wiki = merged.wiki || cfg.wiki;
merged.drive = merged.drive || cfg.drive;
merged.perm = merged.perm || cfg.perm;

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { FeishuConfigSchema } from "./config-schema.js";
import { resolveToolsConfig } from "./tools-config.js";
describe("feishu tools config", () => {
it("enables chat tool by default", () => {
const resolved = resolveToolsConfig(undefined);
expect(resolved.chat).toBe(true);
});
it("accepts tools.chat in config schema", () => {
const parsed = FeishuConfigSchema.parse({
enabled: true,
tools: {
chat: false,
},
});
expect(parsed.tools?.chat).toBe(false);
});
});

View File

@@ -2,11 +2,12 @@ import type { FeishuToolsConfig } from "./types.js";
/**
* Default tool configuration.
* - doc, wiki, drive, scopes: enabled by default
* - doc, chat, wiki, drive, scopes: enabled by default
* - perm: disabled by default (sensitive operation)
*/
export const DEFAULT_TOOLS_CONFIG: Required<FeishuToolsConfig> = {
doc: true,
chat: true,
wiki: true,
drive: true,
perm: false,

View File

@@ -67,6 +67,7 @@ export type FeishuMediaInfo = {
export type FeishuToolsConfig = {
doc?: boolean;
chat?: boolean;
wiki?: boolean;
drive?: boolean;
perm?: boolean;