飞书真的能发表格吗?我赌它不能,然后输了
三个月前我做了一个判断:飞书 IM v1 API 拒收 {tag:"table"},会返 230001。
这判断没有依据。可能是某次旧测试留下的印象,可能是从某个 issue 抄来的,可能就是猜的。但它在我脑子里生了根,长成了一棵树——一整套「飞书发表格 = 降级到纯文本 + 中文提示」的代码就围着它长起来。
直到上周看到 chapafan 那个 200 行的方案。
那 200 行
chapafan/Hermes-feishu-to-table 的 README 写得克制:
修改
feishu.py,在消息发送前检测 Markdown 表格,自动转成飞书 CardKit v2 的table组件。
克制到我一度怀疑他在吹牛。但点进去看代码,3 个函数干净得像论文摘要:
def _parse_markdown_table(text) -> List[Dict]: |
加上 _build_outbound_payload 里 4 行切换,总共 200 行。
我盯着这 200 行看了十分钟。它和我脑子里那个「飞书拒收 table」的公理是矛盾的。两个里一定有一个是错的。
错的那个
移植到本地的过程很顺,但眼尖抓到一个上游隐藏 bug:
def _flush_table(): |
table_lines 定义在 _parse_markdown_table 函数顶部,嵌套函数里 nonlocal 写错位置了——Python 会忽略这个声明,结果是 table_lines = [] 变成了「给局部变量赋空列表」,外层的 table_lines 永远不清空。第二张表格的解析会混进第一张的残留行。
chapafan 自己的测试可能没覆盖多表。我用 table_lines.clear() 替换——list.clear() 是 in-place 操作,不需要 nonlocal 也工作。
顺手做了 22 个单测,全部过。然后——
实弹
跳过文档、跳过猜测,直接发:
content = """## 诊断测试 |
服务端收了。message_id 拿到了。但服务端收 ≠ 客户端渲染——这个区别我吃过亏。
打开手机飞书。**3 行表格真渲染成了 CardKit v2 组件。**表头加粗、列对齐、卡片有 header 装饰。
那个陪了我三个月的公理是错的。
边界
P0 跑通不等于能投产。飞书 CardKit v2 的官方文档列了一堆限制,每个限制都对应一个会出事的真实场景:
-
最多 50 行 → 你的用户会发 200 行的需求清单
-
最多 20 列 → 你的用户会发宽表
-
单 cell 1000 字符 → 你的用户会贴长日志
-
整张卡 30KB → 用户发 50KB 的对比表
每一个都得有守卫。加完,测试从 22 涨到 35。
加守卫的时候又踩了一脚 CJK 的坑:Python str[:n] 天然在 codepoint 边界上切,不会切坏 CJK。我一开始还写了一段 back-off 逻辑,发现是多余的,删了。删比写更难——要承认自己想多了。
嵌套表格也想了半天怎么防。最后方案是:如果 cell 里还含 |,整行合并成单 cell。粗暴,但飞书 CardKit v2 不支持嵌套,没优雅解。
200 行
给飞书发了 200 行的任务清单:
elements: 3 (['markdown', 'table', 'markdown']) |
打开飞书。前 50 行清晰渲染,最后一行右下角挂着中文提示。**没崩。**用户的 200 行变成了 50 行 + 一句解释,这不是降级,是有边界的服务。
我学到的
最大的收获不是表格能渲染。是我脑子里那棵"飞书拒收 table"的树被砍了。一个错公理曾经让我写了不必要的降级代码,让用户看不到真表格,让系统放弃了它能做的事。三个月。
如果我没偶然点进 chapafan,那棵树可能还在。
所以现在的我,每个"已知限制"都会在脑子里加一个标签:「这个有实弹证据吗?」没有的,就当传说对待。
实弹一次,胜过翻十遍文档。这是我这个月最贵的一课。
附录:commit 600db9109(P0 移植)、d1d1d5cc5(P1 边界)。代码 200 行,测试 35 个,3 次实弹。