mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 12:18:37 +00:00
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/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/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/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.
|
- Memory/LanceDB: support custom OpenAI `baseUrl` and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|||||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||||
import { registerFeishuBitableTools } from "./src/bitable.js";
|
import { registerFeishuBitableTools } from "./src/bitable.js";
|
||||||
import { feishuPlugin } from "./src/channel.js";
|
import { feishuPlugin } from "./src/channel.js";
|
||||||
|
import { registerFeishuChatTools } from "./src/chat.js";
|
||||||
import { registerFeishuDocTools } from "./src/docx.js";
|
import { registerFeishuDocTools } from "./src/docx.js";
|
||||||
import { registerFeishuDriveTools } from "./src/drive.js";
|
import { registerFeishuDriveTools } from "./src/drive.js";
|
||||||
import { registerFeishuPermTools } from "./src/perm.js";
|
import { registerFeishuPermTools } from "./src/perm.js";
|
||||||
@@ -53,6 +54,7 @@ const plugin = {
|
|||||||
setFeishuRuntime(api.runtime);
|
setFeishuRuntime(api.runtime);
|
||||||
api.registerChannel({ plugin: feishuPlugin });
|
api.registerChannel({ plugin: feishuPlugin });
|
||||||
registerFeishuDocTools(api);
|
registerFeishuDocTools(api);
|
||||||
|
registerFeishuChatTools(api);
|
||||||
registerFeishuWikiTools(api);
|
registerFeishuWikiTools(api);
|
||||||
registerFeishuDriveTools(api);
|
registerFeishuDriveTools(api);
|
||||||
registerFeishuPermTools(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
|
const FeishuToolsConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
doc: z.boolean().optional(), // Document operations (default: true)
|
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)
|
wiki: z.boolean().optional(), // Knowledge base operations (default: true, requires doc)
|
||||||
drive: z.boolean().optional(), // Cloud storage operations (default: true)
|
drive: z.boolean().optional(), // Cloud storage operations (default: true)
|
||||||
perm: z.boolean().optional(), // Permission management (default: false, sensitive)
|
perm: z.boolean().optional(), // Permission management (default: false, sensitive)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function resolveAnyEnabledFeishuToolsConfig(
|
|||||||
): Required<FeishuToolsConfig> {
|
): Required<FeishuToolsConfig> {
|
||||||
const merged: Required<FeishuToolsConfig> = {
|
const merged: Required<FeishuToolsConfig> = {
|
||||||
doc: false,
|
doc: false,
|
||||||
|
chat: false,
|
||||||
wiki: false,
|
wiki: false,
|
||||||
drive: false,
|
drive: false,
|
||||||
perm: false,
|
perm: false,
|
||||||
@@ -49,6 +50,7 @@ export function resolveAnyEnabledFeishuToolsConfig(
|
|||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
const cfg = resolveToolsConfig(account.config.tools);
|
const cfg = resolveToolsConfig(account.config.tools);
|
||||||
merged.doc = merged.doc || cfg.doc;
|
merged.doc = merged.doc || cfg.doc;
|
||||||
|
merged.chat = merged.chat || cfg.chat;
|
||||||
merged.wiki = merged.wiki || cfg.wiki;
|
merged.wiki = merged.wiki || cfg.wiki;
|
||||||
merged.drive = merged.drive || cfg.drive;
|
merged.drive = merged.drive || cfg.drive;
|
||||||
merged.perm = merged.perm || cfg.perm;
|
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.
|
* Default tool configuration.
|
||||||
* - doc, wiki, drive, scopes: enabled by default
|
* - doc, chat, wiki, drive, scopes: enabled by default
|
||||||
* - perm: disabled by default (sensitive operation)
|
* - perm: disabled by default (sensitive operation)
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_TOOLS_CONFIG: Required<FeishuToolsConfig> = {
|
export const DEFAULT_TOOLS_CONFIG: Required<FeishuToolsConfig> = {
|
||||||
doc: true,
|
doc: true,
|
||||||
|
chat: true,
|
||||||
wiki: true,
|
wiki: true,
|
||||||
drive: true,
|
drive: true,
|
||||||
perm: false,
|
perm: false,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export type FeishuMediaInfo = {
|
|||||||
|
|
||||||
export type FeishuToolsConfig = {
|
export type FeishuToolsConfig = {
|
||||||
doc?: boolean;
|
doc?: boolean;
|
||||||
|
chat?: boolean;
|
||||||
wiki?: boolean;
|
wiki?: boolean;
|
||||||
drive?: boolean;
|
drive?: boolean;
|
||||||
perm?: boolean;
|
perm?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user