Merge 94517cfa7096a1fc1e156d2ae631d36a5a0fe9dd into 9fb78453e088cd7b553d7779faa0de5c83708e70

This commit is contained in:
MumuTW 2026-03-21 05:08:46 +00:00 committed by GitHub
commit 12dc8ef2c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 311 additions and 220 deletions

View File

@ -1,12 +1,34 @@
import { Type, type Static } from "@sinclair/typebox";
const FEISHU_DOC_ACTION_VALUES = [
"read",
"write",
"append",
"insert",
"create",
"list_blocks",
"get_block",
"update_block",
"delete_block",
"create_table",
"write_table_cells",
"create_table_with_values",
"insert_table_row",
"insert_table_column",
"delete_table_rows",
"delete_table_columns",
"merge_table_cells",
"upload_image",
"upload_file",
"color_text",
] as const;
const tableCreationProperties = {
doc_token: Type.String({ description: "Document token" }),
parent_block_id: Type.Optional(
Type.String({ description: "Parent block ID (default: document root)" }),
),
row_size: Type.Integer({ description: "Table row count", minimum: 1 }),
column_size: Type.Integer({ description: "Table column count", minimum: 1 }),
row_size: Type.Optional(Type.Integer({ description: "Table row count", minimum: 1 })),
column_size: Type.Optional(Type.Integer({ description: "Table column count", minimum: 1 })),
column_width: Type.Optional(
Type.Array(Type.Number({ minimum: 1 }), {
description: "Column widths in px (length should match column_size)",
@ -14,169 +36,103 @@ const tableCreationProperties = {
),
};
export const FeishuDocSchema = Type.Union([
Type.Object({
action: Type.Literal("read"),
doc_token: Type.String({ description: "Document token (extract from URL /docx/XXX)" }),
}),
Type.Object({
action: Type.Literal("write"),
doc_token: Type.String({ description: "Document token" }),
content: Type.String({
description: "Markdown content to write (replaces entire document content)",
export const FeishuDocSchema = Type.Object(
{
action: Type.Unsafe<(typeof FEISHU_DOC_ACTION_VALUES)[number]>({
type: "string",
enum: [...FEISHU_DOC_ACTION_VALUES],
description:
"Document action to run: read, write, append, insert, create, list_blocks, get_block, update_block, delete_block, create_table, write_table_cells, create_table_with_values, insert_table_row, insert_table_column, delete_table_rows, delete_table_columns, merge_table_cells, upload_image, upload_file, color_text",
}),
}),
Type.Object({
action: Type.Literal("append"),
doc_token: Type.String({ description: "Document token" }),
content: Type.String({ description: "Markdown content to append to end of document" }),
}),
Type.Object({
action: Type.Literal("insert"),
doc_token: Type.String({ description: "Document token" }),
content: Type.String({ description: "Markdown content to insert" }),
after_block_id: Type.String({
description: "Insert content after this block ID. Use list_blocks to find block IDs.",
}),
}),
Type.Object({
action: Type.Literal("create"),
title: Type.String({ description: "Document title" }),
folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })),
doc_token: Type.Optional(
Type.String({
description:
"Document token. Required for all actions except create. Extract from URL /docx/XXX.",
}),
),
content: Type.Optional(
Type.String({
description:
"Markdown or text payload. Required for write, append, insert, update_block, and color_text.",
}),
),
after_block_id: Type.Optional(
Type.String({
description: "Required for insert. Insert content after this block ID.",
}),
),
title: Type.Optional(
Type.String({
description: "Required for create and rename-style content creation flows.",
}),
),
folder_token: Type.Optional(Type.String({ description: "Optional target folder for create." })),
grant_to_requester: Type.Optional(
Type.Boolean({
description:
"Grant edit permission to the trusted requesting Feishu user from runtime context (default: true).",
"For create, grant edit permission to the trusted requesting Feishu user from runtime context.",
}),
),
block_id: Type.Optional(
Type.String({
description:
"Block ID. Required for get_block, update_block, delete_block, table row/column operations, merge_table_cells, and color_text.",
}),
),
}),
Type.Object({
action: Type.Literal("list_blocks"),
doc_token: Type.String({ description: "Document token" }),
}),
Type.Object({
action: Type.Literal("get_block"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Block ID (from list_blocks)" }),
}),
Type.Object({
action: Type.Literal("update_block"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Block ID (from list_blocks)" }),
content: Type.String({ description: "New text content" }),
}),
Type.Object({
action: Type.Literal("delete_block"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Block ID" }),
}),
// Table creation (explicit structure)
Type.Object({
action: Type.Literal("create_table"),
...tableCreationProperties,
}),
Type.Object({
action: Type.Literal("write_table_cells"),
doc_token: Type.String({ description: "Document token" }),
table_block_id: Type.String({ description: "Table block ID" }),
values: Type.Array(Type.Array(Type.String()), {
description: "2D matrix values[row][col] to write into table cells",
minItems: 1,
}),
}),
Type.Object({
action: Type.Literal("create_table_with_values"),
...tableCreationProperties,
values: Type.Array(Type.Array(Type.String()), {
description: "2D matrix values[row][col] to write into table cells",
minItems: 1,
}),
}),
// Table row/column manipulation
Type.Object({
action: Type.Literal("insert_table_row"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Table block ID" }),
table_block_id: Type.Optional(
Type.String({ description: "Table block ID. Required for write_table_cells." }),
),
values: Type.Optional(
Type.Array(Type.Array(Type.String()), {
description:
"2D matrix values[row][col]. Required for write_table_cells and create_table_with_values.",
minItems: 1,
}),
),
row_index: Type.Optional(
Type.Number({ description: "Row index to insert at (-1 for end, default: -1)" }),
Type.Number({ description: "Optional row index for insert_table_row (-1 for end)." }),
),
}),
Type.Object({
action: Type.Literal("insert_table_column"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Table block ID" }),
column_index: Type.Optional(
Type.Number({ description: "Column index to insert at (-1 for end, default: -1)" }),
Type.Number({
description: "Optional column index for insert_table_column (-1 for end).",
}),
),
}),
Type.Object({
action: Type.Literal("delete_table_rows"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Table block ID" }),
row_start: Type.Number({ description: "Start row index (0-based)" }),
row_count: Type.Optional(Type.Number({ description: "Number of rows to delete (default: 1)" })),
}),
Type.Object({
action: Type.Literal("delete_table_columns"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Table block ID" }),
column_start: Type.Number({ description: "Start column index (0-based)" }),
column_count: Type.Optional(
Type.Number({ description: "Number of columns to delete (default: 1)" }),
row_start: Type.Optional(
Type.Number({ description: "Start row index. Required for delete/merge row actions." }),
),
row_count: Type.Optional(Type.Number({ description: "Rows to delete (default: 1)." })),
row_end: Type.Optional(
Type.Number({ description: "End row index (exclusive). Required for merge_table_cells." }),
),
column_start: Type.Optional(
Type.Number({ description: "Start column index. Required for delete/merge column actions." }),
),
column_count: Type.Optional(Type.Number({ description: "Columns to delete (default: 1)." })),
column_end: Type.Optional(
Type.Number({
description: "End column index (exclusive). Required for merge_table_cells.",
}),
),
url: Type.Optional(Type.String({ description: "Remote file/image URL for upload actions." })),
file_path: Type.Optional(
Type.String({ description: "Local file path for upload_image or upload_file." }),
),
}),
Type.Object({
action: Type.Literal("merge_table_cells"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Table block ID" }),
row_start: Type.Number({ description: "Start row index" }),
row_end: Type.Number({ description: "End row index (exclusive)" }),
column_start: Type.Number({ description: "Start column index" }),
column_end: Type.Number({ description: "End column index (exclusive)" }),
}),
// Image / file upload
Type.Object({
action: Type.Literal("upload_image"),
doc_token: Type.String({ description: "Document token" }),
url: Type.Optional(Type.String({ description: "Remote image URL (http/https)" })),
file_path: Type.Optional(Type.String({ description: "Local image file path" })),
image: Type.Optional(
Type.String({
description:
"Image as data URI (data:image/png;base64,...) or plain base64 string. Use instead of url/file_path for DALL-E outputs, canvas screenshots, etc.",
"Image as data URI or base64 string for upload_image when no URL/file_path is used.",
}),
),
parent_block_id: Type.Optional(
Type.String({ description: "Parent block ID (default: document root)" }),
),
filename: Type.Optional(Type.String({ description: "Optional filename override" })),
filename: Type.Optional(Type.String({ description: "Optional upload filename override." })),
index: Type.Optional(
Type.Integer({
minimum: 0,
description: "Insert position (0-based index among siblings). Omit to append.",
description: "Optional insert position among siblings for upload_image.",
}),
),
}),
Type.Object({
action: Type.Literal("upload_file"),
doc_token: Type.String({ description: "Document token" }),
url: Type.Optional(Type.String({ description: "Remote file URL (http/https)" })),
file_path: Type.Optional(Type.String({ description: "Local file path" })),
parent_block_id: Type.Optional(
Type.String({ description: "Parent block ID (default: document root)" }),
),
filename: Type.Optional(Type.String({ description: "Optional filename override" })),
}),
// Text color / style
Type.Object({
action: Type.Literal("color_text"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Text block ID to update" }),
content: Type.String({
description:
'Text with color markup. Tags: [red], [green], [blue], [orange], [yellow], [purple], [grey], [bold], [bg:yellow]. Example: "Revenue [green]+15%[/green] YoY"',
}),
}),
]);
},
{ additionalProperties: false },
);
export type FeishuDocParams = Static<typeof FeishuDocSchema>;

View File

@ -67,4 +67,29 @@ describe("feishu_doc account selection", () => {
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-a");
});
test("registers provider-friendly feishu_doc parameters for embedded sessions", () => {
const cfg = createDocEnabledConfig();
const { api, resolveTool } = createToolFactoryHarness(cfg);
registerFeishuDocTools(api);
const docTool = resolveTool("feishu_doc", { agentAccountId: "a" });
const schema = docTool.parameters as {
type?: unknown;
anyOf?: unknown;
properties?: Record<string, { enum?: unknown; description?: unknown }>;
required?: unknown;
};
expect(schema.type).toBe("object");
expect(schema.anyOf).toBeUndefined();
expect(schema.required).toEqual(["action"]);
expect(schema.properties?.action?.enum).toEqual(
expect.arrayContaining(["read", "create", "list_blocks", "upload_image"]),
);
expect(schema.properties?.action?.description).toEqual(expect.stringContaining("create_table"));
expect(schema.properties?.doc_token?.description).toEqual(
expect.stringContaining("Required for all actions except create"),
);
});
});

View File

@ -33,6 +33,13 @@ function json(data: unknown) {
};
}
function requireParam(value: string | undefined, label: string): string {
if (value) {
return value;
}
throw new Error(`${label} is required for this action.`);
}
/** Extract image URLs from markdown content */
function extractImageUrls(markdown: string): string[] {
const regex = /!\[[^\]]*\]\(([^)]+)\)/g;
@ -1276,13 +1283,13 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
const client = getClient(p, defaultAccountId);
switch (p.action) {
case "read":
return json(await readDoc(client, p.doc_token));
return json(await readDoc(client, requireParam(p.doc_token, "doc_token")));
case "write":
return json(
await writeDoc(
client,
p.doc_token,
p.content,
requireParam(p.doc_token, "doc_token"),
requireParam(p.content, "content"),
getMediaMaxBytes(p, defaultAccountId),
api.logger,
),
@ -1291,8 +1298,8 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
return json(
await appendDoc(
client,
p.doc_token,
p.content,
requireParam(p.doc_token, "doc_token"),
requireParam(p.content, "content"),
getMediaMaxBytes(p, defaultAccountId),
api.logger,
),
@ -1301,9 +1308,9 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
return json(
await insertDoc(
client,
p.doc_token,
p.content,
p.after_block_id,
requireParam(p.doc_token, "doc_token"),
requireParam(p.content, "content"),
requireParam(p.after_block_id, "after_block_id"),
getMediaMaxBytes(p, defaultAccountId),
api.logger,
),
@ -1316,18 +1323,37 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
}),
);
case "list_blocks":
return json(await listBlocks(client, p.doc_token));
return json(await listBlocks(client, requireParam(p.doc_token, "doc_token")));
case "get_block":
return json(await getBlock(client, p.doc_token, p.block_id));
return json(
await getBlock(
client,
requireParam(p.doc_token, "doc_token"),
requireParam(p.block_id, "block_id"),
),
);
case "update_block":
return json(await updateBlock(client, p.doc_token, p.block_id, p.content));
return json(
await updateBlock(
client,
requireParam(p.doc_token, "doc_token"),
requireParam(p.block_id, "block_id"),
requireParam(p.content, "content"),
),
);
case "delete_block":
return json(await deleteBlock(client, p.doc_token, p.block_id));
return json(
await deleteBlock(
client,
requireParam(p.doc_token, "doc_token"),
requireParam(p.block_id, "block_id"),
),
);
case "create_table":
return json(
await createTable(
client,
p.doc_token,
requireParam(p.doc_token, "doc_token"),
p.row_size,
p.column_size,
p.parent_block_id,
@ -1336,13 +1362,18 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
);
case "write_table_cells":
return json(
await writeTableCells(client, p.doc_token, p.table_block_id, p.values),
await writeTableCells(
client,
requireParam(p.doc_token, "doc_token"),
requireParam(p.table_block_id, "table_block_id"),
p.values,
),
);
case "create_table_with_values":
return json(
await createTableWithValues(
client,
p.doc_token,
requireParam(p.doc_token, "doc_token"),
p.row_size,
p.column_size,
p.values,
@ -1354,7 +1385,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
return json(
await uploadImageBlock(
client,
p.doc_token,
requireParam(p.doc_token, "doc_token"),
getMediaMaxBytes(p, defaultAccountId),
p.url,
p.file_path,
@ -1368,7 +1399,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
return json(
await uploadFileBlock(
client,
p.doc_token,
requireParam(p.doc_token, "doc_token"),
getMediaMaxBytes(p, defaultAccountId),
p.url,
p.file_path,
@ -1377,19 +1408,38 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
),
);
case "color_text":
return json(await updateColorText(client, p.doc_token, p.block_id, p.content));
return json(
await updateColorText(
client,
requireParam(p.doc_token, "doc_token"),
requireParam(p.block_id, "block_id"),
requireParam(p.content, "content"),
),
);
case "insert_table_row":
return json(await insertTableRow(client, p.doc_token, p.block_id, p.row_index));
return json(
await insertTableRow(
client,
requireParam(p.doc_token, "doc_token"),
requireParam(p.block_id, "block_id"),
p.row_index,
),
);
case "insert_table_column":
return json(
await insertTableColumn(client, p.doc_token, p.block_id, p.column_index),
await insertTableColumn(
client,
requireParam(p.doc_token, "doc_token"),
requireParam(p.block_id, "block_id"),
p.column_index,
),
);
case "delete_table_rows":
return json(
await deleteTableRows(
client,
p.doc_token,
p.block_id,
requireParam(p.doc_token, "doc_token"),
requireParam(p.block_id, "block_id"),
p.row_start,
p.row_count,
),
@ -1398,8 +1448,8 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
return json(
await deleteTableColumns(
client,
p.doc_token,
p.block_id,
requireParam(p.doc_token, "doc_token"),
requireParam(p.block_id, "block_id"),
p.column_start,
p.column_count,
),
@ -1408,8 +1458,8 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
return json(
await mergeTableCells(
client,
p.doc_token,
p.block_id,
requireParam(p.doc_token, "doc_token"),
requireParam(p.block_id, "block_id"),
p.row_start,
p.row_end,
p.column_start,

View File

@ -69,6 +69,31 @@ describe("feishu tool account routing", () => {
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
});
test("wiki tool exposes object parameters with explicit action enum", () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
toolsA: { wiki: true },
}),
);
registerFeishuWikiTools(api);
const tool = resolveTool("feishu_wiki", { agentAccountId: "a" });
const schema = tool.parameters as {
type?: unknown;
anyOf?: unknown;
properties?: Record<string, { enum?: unknown; description?: unknown }>;
required?: unknown;
};
expect(schema.type).toBe("object");
expect(schema.anyOf).toBeUndefined();
expect(schema.required).toEqual(["action"]);
expect(schema.properties?.action?.enum).toEqual(
expect.arrayContaining(["spaces", "nodes", "get", "create", "rename"]),
);
expect(schema.properties?.action?.description).toEqual(expect.stringContaining("search"));
});
test("wiki tool prefers configured defaultAccount over inherited default account context", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({

View File

@ -8,6 +8,8 @@ type ToolFactoryLike = (ctx: ToolContextLike) => AnyAgentTool | AnyAgentTool[] |
export type ToolLike = {
name: string;
description?: string;
parameters?: unknown;
execute: (toolCallId: string, params: unknown) => Promise<unknown> | unknown;
};
@ -30,6 +32,8 @@ function asToolLike(tool: AnyAgentTool, fallbackName?: string): ToolLike {
}
return {
name,
description: candidate.description,
parameters: candidate.parameters,
execute: (toolCallId, params) => execute(toolCallId, params),
};
}

View File

@ -1,55 +1,60 @@
import { Type, type Static } from "@sinclair/typebox";
export const FeishuWikiSchema = Type.Union([
Type.Object({
action: Type.Literal("spaces"),
}),
Type.Object({
action: Type.Literal("nodes"),
space_id: Type.String({ description: "Knowledge space ID" }),
parent_node_token: Type.Optional(
Type.String({ description: "Parent node token (optional, omit for root)" }),
),
}),
Type.Object({
action: Type.Literal("get"),
token: Type.String({ description: "Wiki node token (from URL /wiki/XXX)" }),
}),
Type.Object({
action: Type.Literal("search"),
query: Type.String({ description: "Search query" }),
space_id: Type.Optional(Type.String({ description: "Limit search to this space (optional)" })),
}),
Type.Object({
action: Type.Literal("create"),
space_id: Type.String({ description: "Knowledge space ID" }),
title: Type.String({ description: "Node title" }),
obj_type: Type.Optional(
Type.Union([Type.Literal("docx"), Type.Literal("sheet"), Type.Literal("bitable")], {
description: "Object type (default: docx)",
const FEISHU_WIKI_ACTION_VALUES = [
"spaces",
"nodes",
"get",
"search",
"create",
"move",
"rename",
] as const;
const FEISHU_WIKI_OBJECT_TYPE_VALUES = ["docx", "sheet", "bitable"] as const;
export const FeishuWikiSchema = Type.Object(
{
action: Type.Unsafe<(typeof FEISHU_WIKI_ACTION_VALUES)[number]>({
type: "string",
enum: [...FEISHU_WIKI_ACTION_VALUES],
description: "Wiki action to run: spaces, nodes, get, search, create, move, rename",
}),
space_id: Type.Optional(
Type.String({
description: "Knowledge space ID. Required for nodes, create, move, and rename.",
}),
),
parent_node_token: Type.Optional(
Type.String({ description: "Parent node token (optional, omit for root)" }),
Type.String({
description: "Optional parent node token for nodes/create. Omit for the root level.",
}),
),
token: Type.Optional(
Type.String({ description: "Wiki node token. Required for get." }),
),
query: Type.Optional(
Type.String({ description: "Search query. Required for search." }),
),
title: Type.Optional(
Type.String({ description: "Node title. Required for create and rename." }),
),
obj_type: Type.Optional(
Type.Unsafe<(typeof FEISHU_WIKI_OBJECT_TYPE_VALUES)[number]>({
type: "string",
enum: [...FEISHU_WIKI_OBJECT_TYPE_VALUES],
description: "Object type for create (default: docx).",
}),
),
node_token: Type.Optional(
Type.String({ description: "Node token. Required for move and rename." }),
),
}),
Type.Object({
action: Type.Literal("move"),
space_id: Type.String({ description: "Source knowledge space ID" }),
node_token: Type.String({ description: "Node token to move" }),
target_space_id: Type.Optional(
Type.String({ description: "Target space ID (optional, same space if omitted)" }),
Type.String({ description: "Optional target space for move." }),
),
target_parent_token: Type.Optional(
Type.String({ description: "Target parent node token (optional, root if omitted)" }),
Type.String({ description: "Optional target parent node token for move." }),
),
}),
Type.Object({
action: Type.Literal("rename"),
space_id: Type.String({ description: "Knowledge space ID" }),
node_token: Type.String({ description: "Node token to rename" }),
title: Type.String({ description: "New title" }),
}),
]);
},
{ additionalProperties: false },
);
export type FeishuWikiParams = Static<typeof FeishuWikiSchema>;

View File

@ -17,6 +17,13 @@ const WIKI_ACCESS_HINT =
"To grant wiki access: Open wiki space → Settings → Members → Add the bot. " +
"See: https://open.feishu.cn/document/server-docs/docs/wiki-v2/wiki-qa#a40ad4ca";
function requireParam(value: string | undefined, label: string): string {
if (value) {
return value;
}
throw new Error(`${label} is required for this action.`);
}
async function listSpaces(client: Lark.Client) {
const res = await client.wiki.space.list({});
if (res.code !== 0) {
@ -192,9 +199,15 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) {
case "spaces":
return jsonToolResult(await listSpaces(client));
case "nodes":
return jsonToolResult(await listNodes(client, p.space_id, p.parent_node_token));
return jsonToolResult(
await listNodes(
client,
requireParam(p.space_id, "space_id"),
p.parent_node_token,
),
);
case "get":
return jsonToolResult(await getNode(client, p.token));
return jsonToolResult(await getNode(client, requireParam(p.token, "token")));
case "search":
return jsonToolResult({
error:
@ -202,20 +215,33 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) {
});
case "create":
return jsonToolResult(
await createNode(client, p.space_id, p.title, p.obj_type, p.parent_node_token),
await createNode(
client,
requireParam(p.space_id, "space_id"),
requireParam(p.title, "title"),
p.obj_type,
p.parent_node_token,
),
);
case "move":
return jsonToolResult(
await moveNode(
client,
p.space_id,
p.node_token,
requireParam(p.space_id, "space_id"),
requireParam(p.node_token, "node_token"),
p.target_space_id,
p.target_parent_token,
),
);
case "rename":
return jsonToolResult(await renameNode(client, p.space_id, p.node_token, p.title));
return jsonToolResult(
await renameNode(
client,
requireParam(p.space_id, "space_id"),
requireParam(p.node_token, "node_token"),
requireParam(p.title, "title"),
),
);
default:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
return unknownToolActionResult((p as { action?: unknown }).action);