Skip to content

Commit eab7bf6

Browse files
lishuceoclaude
andcommitted
feat: feishu_doc 写入原生表格 + 修复 block_type 映射错位
- 新增 writeMarkdownContent 助手:普通 block 走 documentBlockChildren.create (分批),表格走 documentBlockDescendant.create 创建原生飞书表格; 按 segment 顺序写入,支持 insert 时按顶层 block 数递增 index - write/append/create/insert_blocks 统一改用该助手,不再把表格降级为代码块 - 修复 BLOCK_TYPE_NAMES 16–33 段整体错位一位的历史 bug (之前 todo/divider/table/table_cell 等在 read/list_blocks 显示错误名) - 修复 read_blocks 中 divider 判断 21→22 - 工具描述补充表格支持说明 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b335c70 commit eab7bf6

1 file changed

Lines changed: 78 additions & 53 deletions

File tree

src/feishu/tools/doc.ts

Lines changed: 78 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import { feishuClient } from '../client.js';
44
import { logger } from '../../utils/logger.js';
55
import { validateToken } from './validation.js';
66
import { grantOwnerPermission, grantChatMembersPermission } from './permissions.js';
7-
import { markdownToBlocks, batchBlocks, parseInlineMarkdown } from './markdown-to-blocks.js';
7+
import {
8+
markdownToSegments,
9+
buildTableDescendants,
10+
batchBlocks,
11+
parseInlineMarkdown,
12+
} from './markdown-to-blocks.js';
813

914
/** Max lines returned by read before truncation (matches Claude Code's Read tool default) */
1015
const READ_LINE_LIMIT = 2000;
@@ -14,16 +19,16 @@ const BLOCK_TYPE_NAMES: Record<number, string> = {
1419
1: 'page', 2: 'text', 3: 'heading1', 4: 'heading2', 5: 'heading3',
1520
6: 'heading4', 7: 'heading5', 8: 'heading6', 9: 'heading7',
1621
10: 'heading8', 11: 'heading9', 12: 'bullet', 13: 'ordered',
17-
14: 'code', 15: 'quote', 16: 'todo', 17: 'bitable', 18: 'callout',
18-
19: 'chat_card', 20: 'diagram', 21: 'divider', 22: 'file',
19-
23: 'grid', 24: 'grid_column', 25: 'iframe', 26: 'image',
20-
27: 'isv', 28: 'mindnote', 29: 'sheet', 30: 'table',
21-
31: 'table_cell', 32: 'view', 33: 'undefined', 999: 'virtual_merge',
22-
34: 'quote_container', 40: 'task', 41: 'okr',
22+
14: 'code', 15: 'quote', 17: 'todo', 18: 'bitable', 19: 'callout',
23+
20: 'chat_card', 21: 'diagram', 22: 'divider', 23: 'file',
24+
24: 'grid', 25: 'grid_column', 26: 'iframe', 27: 'image',
25+
28: 'isv', 29: 'mindnote', 30: 'sheet', 31: 'table',
26+
32: 'table_cell', 33: 'view', 34: 'quote_container',
27+
40: 'task', 41: 'okr',
2328
42: 'okr_objective', 43: 'okr_key_result', 44: 'okr_progress',
2429
46: 'add_ons', 48: 'jira_issue', 49: 'wiki_catalog',
2530
51: 'board', 52: 'agenda', 53: 'agenda_item',
26-
54: 'agenda_item_content',
31+
54: 'agenda_item_content', 999: 'undefined',
2732
};
2833

2934
/**
@@ -58,6 +63,58 @@ function extractBlockText(block: Record<string, unknown>): string {
5863
return '';
5964
}
6065

66+
/**
67+
* 将 Markdown 内容写入文档指定父 block,支持渲染原生飞书表格。
68+
*
69+
* 普通 block 走 `documentBlockChildren.create`(分批);每个 Markdown 表格通过
70+
* `documentBlockDescendant.create` 创建为原生 `table`。segment 按顺序写入,当传入
71+
* `startIndex` 时,插入位置按已写入的顶层 block 数量递增,从而保证混排内容的顺序。
72+
*
73+
* @returns 写入的顶层 block 数量。
74+
*/
75+
async function writeMarkdownContent(
76+
client: typeof feishuClient.raw,
77+
documentId: string,
78+
parentBlockId: string,
79+
markdown: string,
80+
startIndex?: number,
81+
): Promise<number> {
82+
const segments = markdownToSegments(markdown);
83+
let index = startIndex;
84+
let written = 0;
85+
86+
for (const seg of segments) {
87+
if (seg.type === 'blocks') {
88+
for (const batch of batchBlocks(seg.blocks)) {
89+
const resp = await client.docx.documentBlockChildren.create({
90+
path: { document_id: documentId, block_id: parentBlockId },
91+
data: { children: batch, ...(index != null ? { index } : {}) },
92+
});
93+
if (resp.code !== 0) throw new Error(`写入 blocks 失败 (${resp.code}): ${resp.msg}`);
94+
if (index != null) index += batch.length;
95+
written += batch.length;
96+
}
97+
} else {
98+
const { childrenId, descendants } = buildTableDescendants(seg.table);
99+
const resp = await client.docx.documentBlockDescendant.create({
100+
path: { document_id: documentId, block_id: parentBlockId },
101+
data: {
102+
children_id: childrenId,
103+
descendants: descendants as unknown as NonNullable<
104+
Parameters<typeof client.docx.documentBlockDescendant.create>[0]
105+
>['data']['descendants'],
106+
...(index != null ? { index } : {}),
107+
},
108+
});
109+
if (resp.code !== 0) throw new Error(`写入表格失败 (${resp.code}): ${resp.msg}`);
110+
if (index != null) index += childrenId.length;
111+
written += childrenId.length;
112+
}
113+
}
114+
115+
return written;
116+
}
117+
61118
/**
62119
* 飞书文档 MCP 工具
63120
*
@@ -80,7 +137,7 @@ export function feishuDocTool(chatId?: string) {
80137
'- insert_blocks: 在指定位置插入新 block (需要 block_id 作为父 block,index 指定位置)',
81138
'- delete_blocks: 删除指定 block (需要 block_id)',
82139
'',
83-
'write/append/insert_blocks 支持的 Markdown 语法: 标题(#)、加粗(**)、斜体(*)、删除线(~~)、行内代码(`)、链接、无序列表(-)、有序列表(1.)、代码块(```)、待办(- [ ])、分隔线(---)。',
140+
'write/append/insert_blocks 支持的 Markdown 语法: 标题(#)、加粗(**)、斜体(*)、删除线(~~)、行内代码(`)、链接、无序列表(-)、有序列表(1.)、代码块(```)、待办(- [ ])、分隔线(---)、表格(| 列1 | 列2 |,会转换为原生飞书表格,首行为表头)。',
84141
'',
85142
'读取文档的推荐流程: read (自动截断大文档) → 如需查看被截断部分,用 list_blocks 定位 → read_blocks 按需读取',
86143
'编辑他人文档的推荐流程: list_blocks → 找到目标 block_id → update_block/insert_blocks/delete_blocks',
@@ -197,15 +254,7 @@ export function feishuDocTool(chatId?: string) {
197254
const pageBlock2 = (listResp.data?.items ?? []).find((b) => b.block_type === 1);
198255
const pageBlockId2 = pageBlock2?.block_id ?? args.doc_token;
199256

200-
const blocks = markdownToBlocks(args.content);
201-
const batches = batchBlocks(blocks);
202-
for (const batch of batches) {
203-
const createResp1 = await client.docx.documentBlockChildren.create({
204-
path: { document_id: args.doc_token, block_id: pageBlockId2 },
205-
data: { children: batch },
206-
});
207-
if (createResp1.code !== 0) throw new Error(`写入 blocks 失败 (${createResp1.code}): ${createResp1.msg}`);
208-
}
257+
await writeMarkdownContent(client, args.doc_token, pageBlockId2, args.content);
209258
return { content: [{ type: 'text' as const, text: '文档已更新' }] };
210259
}
211260

@@ -220,15 +269,7 @@ export function feishuDocTool(chatId?: string) {
220269
const pageBlock3 = (listResp2.data?.items ?? []).find((b) => b.block_type === 1);
221270
const pageBlockId3 = pageBlock3?.block_id ?? args.doc_token;
222271

223-
const appendBlocks = markdownToBlocks(args.content);
224-
const appendBatches = batchBlocks(appendBlocks);
225-
for (const batch of appendBatches) {
226-
const createResp2 = await client.docx.documentBlockChildren.create({
227-
path: { document_id: args.doc_token, block_id: pageBlockId3 },
228-
data: { children: batch },
229-
});
230-
if (createResp2.code !== 0) throw new Error(`追加 blocks 失败 (${createResp2.code}): ${createResp2.msg}`);
231-
}
272+
await writeMarkdownContent(client, args.doc_token, pageBlockId3, args.content);
232273
return { content: [{ type: 'text' as const, text: '内容已追加' }] };
233274
}
234275

@@ -248,16 +289,11 @@ export function feishuDocTool(chatId?: string) {
248289

249290
// 如果提供了 content,创建后自动写入,避免空文档
250291
if (args.content) {
251-
const blocks = markdownToBlocks(args.content);
252-
const batches = batchBlocks(blocks);
253-
for (const batch of batches) {
254-
const writeResp = await client.docx.documentBlockChildren.create({
255-
path: { document_id: doc.document_id, block_id: doc.document_id },
256-
data: { children: batch },
257-
});
258-
if (writeResp.code !== 0) {
259-
logger.warn({ code: writeResp.code, msg: writeResp.msg }, 'create: 写入内容失败,文档已创建但为空');
260-
}
292+
try {
293+
await writeMarkdownContent(client, doc.document_id, doc.document_id, args.content);
294+
} catch (writeErr) {
295+
const wmsg = writeErr instanceof Error ? writeErr.message : String(writeErr);
296+
logger.warn({ err: wmsg }, 'create: 写入内容失败,文档已创建但内容可能不完整');
261297
}
262298
}
263299
}
@@ -344,7 +380,7 @@ export function feishuDocTool(chatId?: string) {
344380
const typeId = block.block_type as number;
345381
const typeName = BLOCK_TYPE_NAMES[typeId] ?? `type_${typeId}`;
346382
const text = extractBlockText(block);
347-
if (text || typeId === 21 /* divider */) {
383+
if (text || typeId === 22 /* divider */) {
348384
blockTexts.push(`[${typeName}] ${text}`);
349385
}
350386
}
@@ -376,21 +412,10 @@ export function feishuDocTool(chatId?: string) {
376412
if (!args.doc_token) throw new Error('insert_blocks 操作需要 doc_token');
377413
if (!args.block_id) throw new Error('insert_blocks 操作需要 block_id (父 block)');
378414
if (!args.content) throw new Error('insert_blocks 操作需要 content');
379-
const insertedBlocks = markdownToBlocks(args.content);
380-
const insertBatches = batchBlocks(insertedBlocks);
381-
let currentIndex = args.index;
382-
for (const batch of insertBatches) {
383-
const createResp3 = await client.docx.documentBlockChildren.create({
384-
path: { document_id: args.doc_token, block_id: args.block_id },
385-
data: {
386-
children: batch,
387-
...(currentIndex != null ? { index: currentIndex } : {}),
388-
},
389-
});
390-
if (createResp3.code !== 0) throw new Error(`插入 blocks 失败 (${createResp3.code}): ${createResp3.msg}`);
391-
if (currentIndex != null) currentIndex += batch.length;
392-
}
393-
return { content: [{ type: 'text' as const, text: `已插入 ${insertedBlocks.length} 个 block` }] };
415+
const insertedCount = await writeMarkdownContent(
416+
client, args.doc_token, args.block_id, args.content, args.index,
417+
);
418+
return { content: [{ type: 'text' as const, text: `已插入 ${insertedCount} 个 block` }] };
394419
}
395420

396421
case 'delete_blocks': {

0 commit comments

Comments
 (0)