From 5209c4892331a8955acb35a9b2b27edec10c55e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E8=8B=87?= Date: Sun, 1 Mar 2026 00:00:31 +0800 Subject: [PATCH] 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 Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + extensions/feishu/index.ts | 2 + extensions/feishu/src/chat-schema.ts | 24 ++++ extensions/feishu/src/chat.test.ts | 89 ++++++++++++++ extensions/feishu/src/chat.ts | 130 +++++++++++++++++++++ extensions/feishu/src/config-schema.ts | 1 + extensions/feishu/src/tool-account.ts | 2 + extensions/feishu/src/tools-config.test.ts | 21 ++++ extensions/feishu/src/tools-config.ts | 3 +- extensions/feishu/src/types.ts | 1 + 10 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 extensions/feishu/src/chat-schema.ts create mode 100644 extensions/feishu/src/chat.test.ts create mode 100644 extensions/feishu/src/chat.ts create mode 100644 extensions/feishu/src/tools-config.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cb253433..53ba37a1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts index 7b2375acf..5cb75ec64 100644 --- a/extensions/feishu/index.ts +++ b/extensions/feishu/index.ts @@ -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); diff --git a/extensions/feishu/src/chat-schema.ts b/extensions/feishu/src/chat-schema.ts new file mode 100644 index 000000000..5f7bdd6a5 --- /dev/null +++ b/extensions/feishu/src/chat-schema.ts @@ -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; diff --git a/extensions/feishu/src/chat.test.ts b/extensions/feishu/src/chat.test.ts new file mode 100644 index 000000000..631944fa1 --- /dev/null +++ b/extensions/feishu/src/chat.test.ts @@ -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(); + }); +}); diff --git a/extensions/feishu/src/chat.ts b/extensions/feishu/src/chat.ts new file mode 100644 index 000000000..a2430be9a --- /dev/null +++ b/extensions/feishu/src/chat.ts @@ -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"); +} diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index 8fd19a1ae..eb359c4d7 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -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) diff --git a/extensions/feishu/src/tool-account.ts b/extensions/feishu/src/tool-account.ts index 72b5db9b7..502d7e0e6 100644 --- a/extensions/feishu/src/tool-account.ts +++ b/extensions/feishu/src/tool-account.ts @@ -41,6 +41,7 @@ export function resolveAnyEnabledFeishuToolsConfig( ): Required { const merged: Required = { 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; diff --git a/extensions/feishu/src/tools-config.test.ts b/extensions/feishu/src/tools-config.test.ts new file mode 100644 index 000000000..6057cc835 --- /dev/null +++ b/extensions/feishu/src/tools-config.test.ts @@ -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); + }); +}); diff --git a/extensions/feishu/src/tools-config.ts b/extensions/feishu/src/tools-config.ts index 1c1321ee4..1890f6265 100644 --- a/extensions/feishu/src/tools-config.ts +++ b/extensions/feishu/src/tools-config.ts @@ -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 = { doc: true, + chat: true, wiki: true, drive: true, perm: false, diff --git a/extensions/feishu/src/types.ts b/extensions/feishu/src/types.ts index dad248aa9..4dbf2c130 100644 --- a/extensions/feishu/src/types.ts +++ b/extensions/feishu/src/types.ts @@ -67,6 +67,7 @@ export type FeishuMediaInfo = { export type FeishuToolsConfig = { doc?: boolean; + chat?: boolean; wiki?: boolean; drive?: boolean; perm?: boolean;