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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
24
extensions/feishu/src/chat-schema.ts
Normal file
24
extensions/feishu/src/chat-schema.ts
Normal 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>;
|
||||
89
extensions/feishu/src/chat.test.ts
Normal file
89
extensions/feishu/src/chat.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
130
extensions/feishu/src/chat.ts
Normal file
130
extensions/feishu/src/chat.ts
Normal 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");
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
21
extensions/feishu/src/tools-config.test.ts
Normal file
21
extensions/feishu/src/tools-config.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -67,6 +67,7 @@ export type FeishuMediaInfo = {
|
||||
|
||||
export type FeishuToolsConfig = {
|
||||
doc?: boolean;
|
||||
chat?: boolean;
|
||||
wiki?: boolean;
|
||||
drive?: boolean;
|
||||
perm?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user