Skip to content

feat: 飞书文档工具支持原生表格 - #274

Merged
lishuceo merged 5 commits into
mainfrom
feat/claude-session-4e9d8c
Jul 22, 2026
Merged

feat: 飞书文档工具支持原生表格#274
lishuceo merged 5 commits into
mainfrom
feat/claude-session-4e9d8c

Conversation

@lishuceo

Copy link
Copy Markdown
Owner

背景

feishu_doc 工具此前遇到 Markdown 表格时,会把整张表降级渲染成 plaintext 代码块markdown-to-blocks.ts 里 “Feishu API doesn't support inline table creation” 的注释)。实际上飞书文档 API 支持通过 documentBlockDescendant.create 一次性提交嵌套结构来创建原生表格,本 PR 实现这条路径。

改动

markdown-to-blocks.ts

  • parseMarkdownTable:解析 GFM 表格 —— 识别 :--/--:/:-: 对齐分隔符、检测表头、补齐不规则行、支持无表头表格
  • markdownToSegments:把 Markdown 拆成有序 segment,普通 block 成组,每个表格独立成 table segment
  • buildTableDescendants:构建 table(31)table_cell(32)text(2) 的嵌套 descendant 结构(临时 block id);空单元格用空 elements 数组
  • markdownToBlocks 保持向后兼容(表格仍降级为代码块,供扁平 API 使用)

doc.ts

  • 新增 writeMarkdownContent:普通 block 走 documentBlockChildren.create(分批),表格走 documentBlockDescendant.create;按 segment 顺序写入,insert_blocks 传 index 时按顶层 block 数递增
  • write / append / create / insert_blocks 统一改用该助手
  • 顺带修复历史 bugBLOCK_TYPE_NAMES 映射表 16–33 段整体错位一位(导致 read/list_blocks 里 todo/divider/table/table_cell 等显示成错误的类型名);read_blocks 里 divider 判断 2122(权威枚举 Divider=22、Table=31、TableCell=32,已核对飞书开放平台文档)

测试

  • 新增 16 个表格相关单测(解析、分段、descendant 构建、行内 markdown、空单元格、向后兼容)
  • npm run typecheck
  • npm run lint(改动文件)✓
  • npx vitest run:1774/1775 通过。唯一失败是 memory/quality.test.ts 的语义检索质量基准(依赖本地记忆库内容,与本改动无代码交集,属既有/环境性失败)

⚠️ 待真实文档冒烟验证

代码在离线环境完成,未对真实飞书文档做过端到端写入。建议 review 后跑一次真实建表,重点确认:空单元格用空 elements: [] 是否被 Feishu 接受(这是唯一无法离线确认的 API 行为选择)。

🤖 Generated with Claude Code

lishuceo and others added 3 commits July 22, 2026 08:27
新增表格转换能力,为飞书文档写入原生 table 铺路:
- parseMarkdownTable: 解析 GFM 表格,识别对齐分隔符、检测表头、
  补齐不规则行、支持无表头表格
- markdownToSegments: 将 markdown 拆成有序 segment,普通 block 成组,
  每个表格独立成 table segment(供 descendant API 使用)
- buildTableDescendants: 构建 table→table_cell→text 的嵌套 descendant
  结构(临时 block id),空单元格用空 elements 数组
- 新增 TABLE=31 / TABLE_CELL=32 常量
- markdownToBlocks 保持向后兼容:表格降级为 plaintext 代码块

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
为 parseMarkdownTable、markdownToSegments、buildTableDescendants 增加
单元测试:表头检测、对齐分隔符、不规则行补齐、无表头表格、文本/表格
交错分段、相邻表格独立成段、单元格行内 markdown、空单元格、以及
markdownToBlocks 表格降级为代码块的向后兼容行为。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 新增 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>
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @lishuceo's task in 3m 17s —— View job


Claude Code Review

  • 检查并处理已有的 review 评论(无历史评论)
  • 阅读 CLAUDE.md 与 PR 意图
  • Review markdown-to-blocks.ts
  • Review doc.ts
  • Review 测试文件
  • 汇总并发布 review

⚠️ Issues Found

整体实现质量高:GFM 表格解析、segment 分段、descendant 构建都清晰且测试覆盖到位,BLOCK_TYPE_NAMES 的错位修复和 divider 22 的更正也核对无误。发现 1 个本 PR 触发的真实 bug

🟡 write 覆盖含原生表格的文档会失败src/feishu/tools/doc.ts:236-248(confidence 80)

documentBlock.list 返回的是扁平化的全部 block(含表格的 table_cell 及 cell 内的 text 子块)。而这里的 batchDelete 删除的是 page block 的直接子节点 [start_index, end_index)end_index 必须 ≤ page 的直接子块数。

  • 本 PR 之前:所有内容都是 page 的直接子块(表格降级为代码块、列表平铺),blockIds.length(非 page 的全部块数)恰好等于直接子块数,逻辑成立。
  • 本 PR 之后:文档可包含嵌套块(table → table_cell → text)。此时 blockIds.length 会把 cell 和 cell 内文本块也算进去 → end_index 超过 page 直接子块数 → Feishu 以 index 非法拒绝。

净效果:凡是用本工具写过原生表格的文档,之后再 write 覆盖都会报错。这是本 PR 通过自身产出的新块结构使既有删除路径失效,属回归。

建议按 page 的直接子节点数来定 end_index

const pageBlock = (listResp.data?.items ?? []).find((b) => b.block_type === 1);
const pageBlockId = pageBlock?.block_id ?? args.doc_token;
const directChildCount = (pageBlock?.children as string[] | undefined)?.length ?? 0;
if (directChildCount > 0) {
  const delResp = await client.docx.documentBlockChildren.batchDelete({
    path: { document_id: args.doc_token, block_id: pageBlockId },
    data: { start_index: 0, end_index: directChildCount },
  });
  if (delResp.code !== 0) throw new Error(`删除 blocks 失败 (${delResp.code}): ${delResp.msg}`);
}

(无法用行内评论,因为这段删除逻辑未在本 PR diff 中改动。)

Fix this →


📝 次要观察(非阻塞,无需处理)

  • 空单元格 elements: []:PR 已自行标注需真实文档冒烟验证。这是唯一无法离线确认的 API 行为,review 通过后建议实测一次建表 + 空单元格。
  • isTableSeparator:一行纯 | --- | 数据会被误判为分隔符。但这在 Markdown 语义下本就是分隔符,实际影响可忽略。

✅ 亮点

  • parseMarkdownTable 对齐符识别、补齐不规则行、无表头表格处理完整,边界(仅分隔符 → null)已覆盖。
  • buildTableDescendants 的临时 block id 在单请求内唯一,行主序 cell 引用正确,row_size/column_size/header_row 与实际结构一致。
  • markdownToBlocks 向后兼容(表格仍降级为代码块)保留,并有专门测试。
  • 16 个新单测覆盖到位,符合 CLAUDE.md 的测试政策。

结论:建议修掉上面的 write 覆盖回归(并补一条回归测试)后合并。其余改动可以放心。

真实飞书 API 验证发现:table_cell 内的 text block 若 elements 为空数组
([]),descendant.create 返回 1770001 invalid param。改为始终走
parseInlineMarkdown(content)——空串会返回 [{text_run:{content:''}}],
飞书接受。已用真实文档端到端验证:原生 table(31)+table_cell(32) 正确
创建,空单元格渲染为空、行内 markdown 生效、与其它 block 混排顺序正确。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lishuceo

Copy link
Copy Markdown
Owner Author

✅ 已完成真实飞书 API 端到端验证

用 DevBot 凭证直连飞书创建真实文档验证,发现并修复了一个离线无法确认的问题:

Bug:空单元格用 elements: [] 会被飞书拒绝(1770001 invalid param)。
Fix(commit b9a25aa):改为始终 parseInlineMarkdown(content),空串返回 [{text_run:{content:''}}],飞书接受。

验证结果(真实文档读回):

  • descendant.create(table)code=0
  • block_type 统计:{1:page, 3:heading1, 2:文本×11, 31:table×1, 32:table_cell×9}原生 table 确认存在
  • 单元格文本行主序:["姓名","角色","Git ID","姜黎","负责人","lishuceo","林美辰","引擎",""]
    • 行内 code `lishuceo` 正确渲染
    • 空单元格正确显示为空
    • heading → 段落 → 表格 → 段落 的混排顺序正确

typecheck ✓ / 表格单测 52 passed ✓(含更新后的空单元格回归用例)

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @lishuceo's task in 1m 44s —— View job


Claude Code Review (re-run on synchronize)

  • 检查历史 review 评论(无历史 inline 评论需处理)
  • 阅读 CLAUDE.md 与 PR 意图
  • 复核空单元格修复 (commit b9a25aa)
  • 复核上一轮提出的 write 覆盖回归是否已修
  • 汇总并发布 review

⚠️ 复核结论:1 个既有问题仍未修复

✅ 已修复:空单元格 elements: [] 被飞书拒绝
commit b9a25aa 改为 text: { elements: parseInlineMarkdown(content) },空串返回 [{text_run:{content:''}}]markdown-to-blocks.ts:475-482,注释说明清晰)。作者已用真实飞书 API 端到端验证(descendant.createcode=0,空单元格正确显示),这个观察点已闭环。

🟡 仍存在:write 覆盖含原生表格的文档会失败src/feishu/tools/doc.ts:236-249(confidence 85)

上一轮 review 提出的这个回归本次提交未处理。当前代码仍是:

const blockIds = (listResp.data?.items ?? [])
  .filter((b) => b.block_type !== 1)   // 排除 page,但仍含 table_cell / cell 内 text
  .map((b) => b.block_id)
  .filter((id): id is string => !!id);

if (blockIds.length > 0) {
  const pageBlock = (listResp.data?.items ?? []).find((b) => b.block_type === 1);
  const pageBlockId = pageBlock?.block_id ?? args.doc_token;
  const delResp = await client.docx.documentBlockChildren.batchDelete({
    path: { document_id: args.doc_token, block_id: pageBlockId },
    data: { start_index: 0, end_index: blockIds.length },   // ← 用了扁平总数
  });
}

documentBlock.list 返回的是扁平化的全部 block(本文件 read_blockscollectDescendants 依赖 children 字段重建层级,正好印证了这一点)。而 batchDelete 删的是 page 的直接子节点 [start_index, end_index)end_index 必须 ≤ page 的直接子块数。

  • 本 PR 之前:内容全是 page 直接子块(表格降级为代码块、列表平铺),blockIds.length == 直接子块数,逻辑成立。
  • 本 PR 之后:文档可含嵌套块 table(31) → table_cell(32) → text(2)。此时 blockIds.length 把 cell 和 cell 内文本也算进去 → end_index 远超 page 直接子块数 → 飞书以 index 非法拒绝(1770001)。

净效果:凡是用本工具写过原生表格的文档,之后再 write 覆盖都会报错——本 PR 通过自身产出的嵌套结构使既有删除路径失效,属回归。这次真实建表验证只覆盖了 create + descendant.create 路径,没有覆盖“先建表、再对同一文档 write 覆盖”,所以离线/线上都未触发。

建议按 page 的直接子节点数来定 end_index

const pageBlock = (listResp.data?.items ?? []).find((b) => b.block_type === 1);
const pageBlockId = pageBlock?.block_id ?? args.doc_token;
const directChildCount = (pageBlock?.children as string[] | undefined)?.length ?? 0;
if (directChildCount > 0) {
  const delResp = await client.docx.documentBlockChildren.batchDelete({
    path: { document_id: args.doc_token, block_id: pageBlockId },
    data: { start_index: 0, end_index: directChildCount },
  });
  if (delResp.code !== 0) throw new Error(`删除 blocks 失败 (${delResp.code}): ${delResp.msg}`);
}

(这段删除逻辑不在本 PR 的 diff 行内,无法用 inline 评论;且建议补一条“文档含原生表格 → write 覆盖”的回归测试,符合 CLAUDE.md 的测试政策。)

Fix this →


✅ 亮点(本轮复核确认)

  • buildTableDescendants 空单元格处理正确,注释准确解释了为何无需特判。
  • parseMarkdownTable 对齐符识别、补齐不规则行、无表头表格、纯分隔符 → null 均已覆盖。
  • 临时 block id 在单请求内唯一,行主序 cell 引用与 row_size/column_size/header_row 一致。
  • insert_blocks 传 index 时,表格 segment 按 childrenId.length(=1,仅顶层 table)递增,混排顺序正确。
  • markdownToBlocks 向后兼容(表格降级为代码块)保留并有测试。

结论⚠️ Issues Found — 空单元格问题已修复并实测通过;但上一轮的 write 覆盖回归(confidence 85)本次未处理,建议修复 + 补回归测试后合并。

处理 PR review 反馈(confidence 85 的真实回归):write 覆盖时用
documentBlock.list 的扁平总数作为 batchDelete 的 end_index,但 list
返回的是含嵌套 table_cell/cell-text 的全部 block,而 batchDelete 删的是
page 的直接子节点。文档一旦写过原生表格(嵌套结构),end_index 就会
远超 page 直接子块数,导致再次 write 覆盖被飞书拒绝 (1770001)。

改为按 page block 的 children 数组长度(直接子块数)计算 end_index。
补充 doc.test.ts 回归用例:含原生表格的文档(page 4 直接子块 / 12 扁平
block)覆盖时 end_index=4 而非 12;空文档跳过 batchDelete。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lishuceo

Copy link
Copy Markdown
Owner Author

Fixed (commit 3a22947) — 处理 review 复核指出的 write 覆盖回归。

根因确认documentBlock.list 返回扁平化的全部 block(含嵌套 table_cell / cell-text),而 batchDelete 删的是 page 的直接子节点。文档写过原生表格后,blockIds.length(扁平总数)远超 page 直接子块数,end_index 越界 → 1770001

修复:改用 page block 的 children 数组长度作为 end_index

const directChildCount = (pageBlock?.children as string[] | undefined)?.length ?? 0;
if (directChildCount > 0) {
  await client.docx.documentBlockChildren.batchDelete({
    path: { document_id: args.doc_token, block_id: pageBlockId },
    data: { start_index: 0, end_index: directChildCount },
  });
}

回归测试doc.test.ts 新增用例 —— page 4 个直接子块 / 12 个扁平 block 的含表格文档,断言 batchDeleteend_index=4(非 12);另加空文档跳过 batchDelete 的用例。full suite 1776 passed(唯一失败是无关的 memory 检索基准)。

复核提到的另一点(空单元格 elements: [])此前已在 b9a25aa 修复并实测。

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @lishuceo's task in 1m 16s —— View job


Claude Code Review (re-run on synchronize)

  • 检查历史 review 评论(无历史 inline 评论需处理)
  • 复核 write 覆盖回归修复 (commit 3a22947)
  • 复核新增回归测试
  • 汇总并发布 review

✅ Approved — 上一轮的 write 覆盖回归已正确修复

修复确认src/feishu/tools/doc.ts:235-249

改用 page block 的 children 数组长度作为 batchDeleteend_index,逻辑正确:

const pageBlock = (listResp.data?.items ?? []).find((b) => b.block_type === 1);
const pageBlockId = pageBlock?.block_id ?? args.doc_token;
const directChildCount = (pageBlock?.children as string[] | undefined)?.length ?? 0;

if (directChildCount > 0) {
  const delResp = await client.docx.documentBlockChildren.batchDelete({
    path: { document_id: args.doc_token, block_id: pageBlockId },
    data: { start_index: 0, end_index: directChildCount },
  });
  if (delResp.code !== 0) throw new Error(`删除 blocks 失败 (${delResp.code}): ${delResp.msg}`);
}

逐点核对:

  • end_index 现在取 page 的直接子块数children.length),不再用扁平总数 blockIds.length → 含原生表格(嵌套 table → table_cell → text)的文档覆盖不再越界,1770001 回归已消除。
  • directChildCount === 0 时跳过 batchDelete(空文档不会用非法 end_index:0 去调删除),处理得当。
  • pageBlockId 回退到 args.doc_token,与 append 分支行为一致。
  • 注释准确记录了根因(扁平 list vs 直接子节点删除),便于后续维护。

回归测试确认src/feishu/tools/__tests__/doc.test.ts:392-448

  • 「含原生表格」用例构造了 page 4 个直接子块 / 12 个扁平 block 的文档,断言 end_index === 4(而非 12)且 block_id === 'page_1' —— 精准锁定这个回归,若退回旧实现会立即失败。
  • 「空文档」用例断言 batchDelete 不被调用,覆盖了 directChildCount === 0 分支。

两条测试正是上一轮 review 建议补的用例,符合 CLAUDE.md 的测试政策。


复核历史问题闭环

问题 状态
空单元格 elements: [] 被飞书拒绝 (1770001) ✅ 已修 (b9a25aa),作者真实 API 端到端验证通过
write 覆盖含原生表格文档越界失败 ✅ 已修 (3a22947),含精准回归测试
BLOCK_TYPE_NAMES 段整体错位 + divider 22 ✅ 上一轮已核对无误

说明:因权限限制本次未能在 CI 环境跑测试套件,代码审查基于源码静态复核;作者已报告 npx vitest run 全套 1776 通过(唯一失败为无关的 memory 检索基准)。


结论

Approved — 2 个此前提出的问题均已修复并补齐回归测试,实现清晰、边界处理完整。可以合并。

@lishuceo
lishuceo merged commit 5005cac into main Jul 22, 2026
4 checks passed
@lishuceo
lishuceo deleted the feat/claude-session-4e9d8c branch July 22, 2026 00:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant