Skip to content

feat(channels): rich-text markdown rendering for feishu and dingtalk - #99

Open
skywclouds wants to merge 2 commits into
OpenBMB:mainfrom
skywclouds:main
Open

feat(channels): rich-text markdown rendering for feishu and dingtalk#99
skywclouds wants to merge 2 commits into
OpenBMB:mainfrom
skywclouds:main

Conversation

@skywclouds

Copy link
Copy Markdown
Contributor

概述

为飞书和钉钉渠道的出站消息增加 Markdown 富文本渲染能力。新增零依赖手写 Markdown 解析器,飞书走 post 富文本、钉钉走原生 markdown 透传 + 围栏补全。通过全局开关 CHANNEL_RICH_RENDER_ENABLED(默认开启)控制,关闭后回退纯文本行为。

动机

此前飞书和钉钉的出站消息均以纯 text 类型发送,AI 回复中的标题、代码块、列表、粗体、行内代码等 Markdown 语法全部以纯文本呈现,可读性差。尤其是代码——缩进丢失、无等宽字体、函数体与说明文字混为一团。

改动范围

只做出站渲染,不改入站解析、不改微信/企微、不改前端控制台。

文件说明

backend/app/channels/markdown_render.py 新增:Markdown 解析器 + 飞书 post 渲染 + 钉钉围栏补全 + 分块 + title 提取
backend/app/channels/adapters/feishu.py send() 富文本化:含 markdown 走 post,纯文本走 text
backend/app/channels/adapters/dingtalk.py send() 富文本化:含 markdown 走 markdown msgtype + ensure_code_fences 围栏补全
backend/app/config.py 新增 channel_rich_render_enabled: bool = True
backend/.env.example 新增 CHANNEL_RICH_RENDER_ENABLED="true"
backend/tests/test_markdown_render.py 新增:解析器 + 渲染器 + 围栏补全 52 项单测
backend/tests/test_feishu_adapter.py 新增 7 项富文本 send 测试
backend/tests/test_channel_dingtalk.py 新增 11 项富文本 send 测试

技术方案

Markdown 解析器(markdown_render.py,零新增依赖):

  • 块级语法:标题 / 围栏代码块 / 4 空格缩进代码块 / 顶格代码块 / 有序无序列表 / 引用 / 分隔线 / 表格
  • 行内语法:粗体 / 斜体 / 行内代码 / 链接(token 扫描 + 递归下降,代码片段占位隔离不二次解析)
  • has_markdown(text) 检测:命中任一 markdown 语法标记返回 True,纯文本返回 False 以走原 text 路径

飞书渲染:

  • parse_markdown → 块模型 → render_feishu_post → 飞书 post content 结构
  • CodeBlock → code_block tag(等宽字体、保留缩进)
  • 含多行内代码的段落正确渲染

钉钉渲染:

  • 原生 markdown 透传,ensure_code_fences 自动为顶格代码补 ``` 围栏
  • extract_dingtalk_title 提取标题(首标题 → 首行 → 默认"消息",截断 ≤20 字)

顶格代码块识别:

  • AI 输出常以 def/class/import/from ... import/async def/@decorator 顶格开头输出代码,无围栏无 4 空格缩进
  • _CODE_START_RE 检测代码起始行,_is_code_continuation 保守判断续行(缩进行、# 注释、赋值、函数调用)
  • has_markdown 也检测顶格代码起始行,确保纯代码消息走富文本路径

开关与回退

  • CHANNEL_RICH_RENDER_ENABLED=true(默认):启用富文本渲染
  • CHANNEL_RICH_RENDER_ENABLED=false:完全回退到原有纯文本行为
  • 飞书:has_markdown 返回 False 的消息始终走 text 路径,无外观变化
  • 钉钉:同上,走 text msgtype

测试

  • 全量 1450 项测试通过
  • ruff 检查干净
  • 新增 70 项单测覆盖解析器、渲染器、围栏补全、飞书/钉钉 adapter send

风险

  • 飞书 post 消息与 text 消息外观有差异(富文本格式),关闭开关可完全回退
  • 顶格代码块续行判断是启发式的,极端情况下可能将非代码行误纳入代码块或截断代码块
  • 钉钉 markdown 渲染能力受钉钉自身限制(不支持表格、部分嵌套语法)

Add markdown_render.py with a hand-written parser (zero new deps) that
covers headings, bold, inline code, fenced/indented/toplevel code blocks,
lists, quotes, links, tables, and thematic breaks.

Feishu: messages containing markdown are rendered as post rich text with
code_block tags; plain text falls back to the original text msgtype.

DingTalk: messages containing markdown are sent as native markdown with
automatic code-fence insertion (ensure_code_fences) so toplevel code
(def/class/import) is properly rendered by DingTalk's markdown engine.

Key fixes during development:
- Fix infinite loop in _parse_inline_recursive when multiple inline code
  segments were present (code placeholder regex used text[pos:] causing
  relative match.end() to never advance pos past the second placeholder).
  This caused 95% CPU spin -> health check failure -> app crash loop.
- Add 4-space indented code block support.
- Add toplevel code block detection (def/class/import/from/async def/@decorator)
  with conservative continuation heuristics (indent lines, # comments,
  assignments, function calls).
- Add toplevel code start to has_markdown detection so code-only messages
  are routed through the rich-text path.

Config: CHANNEL_RICH_RENDER_ENABLED (default true) gates the feature;
setting it to false restores the original plain-text behavior.

Tests: 1450 passed, ruff clean.
@fadeoreo

Copy link
Copy Markdown
Collaborator

#99 目前有一个需要修改的 P1 问题
[P1] Markdown 分片会破坏代码块

位置:

  • backend/app/channels/markdown_render.py:534
  • 飞书调用:backend/app/channels/adapters/feishu.py:419
  • 钉钉调用:backend/app/channels/adapters/dingtalk.py:556

split_markdown_by_lines() 的注释说会避免把代码块切开,但实际上只按普通行长度切分。比如:

```python
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

当单行超过渠道限制时,会被拆成:

```text
```python
xxxxxxxxxxxxxxxxxxxx
xxxxxxxxxx

实际发送时,飞书会把第一段和后续段分别转成不同的 `post` 消息,钉钉也会分别发送多个 Markdown 消息。这样代码围栏会跨消息断裂,导致代码块被当成普通文本或渲染异常。`ensure_code_fences()` 也无法修复,因为每个 chunk 单独处理时,围栏信息已经丢失。

建议:

- 分片时显式跟踪 fenced code block 状态;
- 每个 chunk 独立闭合并重新打开代码围栏;
- 或者代码块超限时降级为纯文本/附件;
- 增加“超长 fenced code block”的回归测试,验证每个发送 chunk 都是合法 Markdown。

除此之外:

- #99 与当前 `main` 没有新的 Git merge conflict;
- 针对性测试通过:`138 passed`;
- 飞书和钉钉的普通标题、粗体、链接、列表、引用等渲染逻辑整体方向是对的;
- 前面 #95 的微信/企微附件功能不在这个 PR 里,#99 只覆盖飞书和钉钉出站 Markdown。

split_markdown_by_lines now tracks fenced code block state. When a split
point falls inside a code fence, the previous chunk is closed and the
next chunk reopens the fence with the original language, so every chunk
is self-contained valid Markdown. Fixes broken code fences on feishu
post and dingtalk markdown when messages exceed the channel length limit.

Adds 9 regression tests covering overlong fenced blocks, language
preservation, content recovery, tilde fences, and adapter-level chunk
validation.
@skywclouds

Copy link
Copy Markdown
Contributor Author

感谢详细的 review,P1 问题已修复并推送(commit 204e4c7)。

修复方案

重写了 split_markdown_by_lines()(backend/app/channels/markdown_render.py:551),新增围栏代码块状态跟踪:

  • 新增 _detect_fence() / _is_fence_close() 辅助函数识别围栏起止行
  • 当切分点落在围栏代码块内部时,在前一段末尾补闭合围栏、下一段开头按原语言重新打开围栏,确保每个 chunk 都是自包含的合法 Markdown
  • 同时支持 ``` 和 ~~~ 两种围栏;未闭合围栏(文末)也会补上闭合围栏
  • 超长单行硬切时同样会重新打开/闭合围栏
    这样飞书 parse_markdown(chunk) 每段都得到完整 CodeBlock,钉钉 ensure_code_fences(chunk) 不会因围栏已存在而重复处理。选用了你建议的第二种方案(每个 chunk 独立闭合并重新打开代码围栏),因为它不改变现有渠道发送语义、无需降级为附件。

回归测试

新增 9 项测试覆盖该场景:

  • test_markdown_render.py:超长围栏块每段围栏成对、保留语言标识、内容可拼回、短块不切、~~~ 围栏、代码块+后续文本、超长单行
  • test_feishu_adapter.py:每个发送 chunk 含 code_block tag,代码内容完整覆盖首尾行(line_0 到 line_299)
  • test_channel_dingtalk.py:每个发送 chunk 围栏成对、不超长度限制

验证

  • 全量 1459 passed
  • ruff check 对改动文件干净(test_feishu_adapter.py 残留的 I001 import 排序为既有问题,非本次改动引入,diff 未触及任何 import 行)

关于您提到的其余几点确认如下:

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.

2 participants