From 83db14b938012227c3ab115734305d3a72c59435 Mon Sep 17 00:00:00 2001 From: Wang He Date: Thu, 13 Aug 2026 13:07:17 +0800 Subject: [PATCH 1/4] feat(channels): rich-text markdown rendering for feishu and dingtalk 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. --- backend/.env.example | 1 + backend/app/channels/adapters/dingtalk.py | 30 +- backend/app/channels/adapters/feishu.py | 36 +- backend/app/channels/markdown_render.py | 674 ++++++++++++++++++++++ backend/app/config.py | 3 + backend/tests/test_channel_dingtalk.py | 139 +++++ backend/tests/test_feishu_adapter.py | 145 +++++ backend/tests/test_markdown_render.py | 457 +++++++++++++++ 8 files changed, 1477 insertions(+), 8 deletions(-) create mode 100644 backend/app/channels/markdown_render.py create mode 100644 backend/tests/test_markdown_render.py diff --git a/backend/.env.example b/backend/.env.example index 77fdd1c4..9c87b802 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -18,3 +18,4 @@ STAFFDECK_ROLE="all" WECHAT_ILINK_BASE_URL="https://ilinkai.weixin.qq.com" CHANNEL_DELIVERY_POLL_SECONDS="1.0" CHANNEL_DELIVERY_MAX_ATTEMPTS="8" +CHANNEL_RICH_RENDER_ENABLED="true" diff --git a/backend/app/channels/adapters/dingtalk.py b/backend/app/channels/adapters/dingtalk.py index 662e17f1..aaebe4d1 100644 --- a/backend/app/channels/adapters/dingtalk.py +++ b/backend/app/channels/adapters/dingtalk.py @@ -24,6 +24,13 @@ stream_download_with_limit, ) from app.channels.crypto import decrypt_channel_secret +from app.channels.markdown_render import ( + ensure_code_fences, + extract_dingtalk_title, + has_markdown, + split_markdown_by_lines, +) +from app.config import get_settings from app.db import engine from app.db.models import ChannelBinding @@ -543,12 +550,31 @@ def send( expires_ms = int(target.get("session_webhook_expired_time") or 0) if expires_ms and expires_ms <= int(datetime.now(tz=UTC).timestamp() * 1000): raise DingTalkPermanentError("钉钉会话回复地址已过期") + rich_enabled = bool(get_settings().channel_rich_render_enabled) + use_rich = rich_enabled and has_markdown(text) + if use_rich: + chunks = split_markdown_by_lines(text, DINGTALK_TEXT_LIMIT) + if not chunks: + chunks = [text] + else: + chunks = split_channel_text(text, DINGTALK_TEXT_LIMIT) try: with self._client_factory() as client: - for chunk in split_channel_text(text, DINGTALK_TEXT_LIMIT): + for chunk in chunks: + if use_rich: + fenced = ensure_code_fences(chunk) + body = { + "msgtype": "markdown", + "markdown": { + "title": extract_dingtalk_title(fenced), + "text": fenced, + }, + } + else: + body = {"msgtype": "text", "text": {"content": chunk}} response = client.post( webhook, - json={"msgtype": "text", "text": {"content": chunk}}, + json=body, headers={"Content-Type": "application/json"}, ) data = response.json() diff --git a/backend/app/channels/adapters/feishu.py b/backend/app/channels/adapters/feishu.py index 0e67a347..72e5a628 100644 --- a/backend/app/channels/adapters/feishu.py +++ b/backend/app/channels/adapters/feishu.py @@ -9,12 +9,20 @@ import httpx from app.channels.adapters.base import ( + CHANNEL_TEXT_LIMIT, ChannelInboundAttachment, register_channel_adapter, split_channel_text, stream_download_with_limit, ) from app.channels.crypto import decrypt_channel_secret +from app.channels.markdown_render import ( + has_markdown, + parse_markdown, + render_feishu_post, + split_markdown_by_lines, +) +from app.config import get_settings from app.db.models import ChannelBinding FEISHU_API_BASE = "https://open.feishu.cn/open-apis" @@ -405,12 +413,28 @@ def send( receive_id_type = str(target.get("receive_id_type") or "").strip() if not message_id and (not receive_id or not receive_id_type): raise FeishuPermanentError("飞书投递目标无效") - for index, chunk in enumerate(split_channel_text(text)): - body: dict[str, Any] = { - "msg_type": "text", - "content": json.dumps({"text": chunk}, ensure_ascii=False), - "uuid": self._uuid(key, index), - } + rich_enabled = bool(get_settings().channel_rich_render_enabled) + use_rich = rich_enabled and has_markdown(text) + if use_rich: + chunks = split_markdown_by_lines(text, CHANNEL_TEXT_LIMIT) + if not chunks: + chunks = [text] + else: + chunks = split_channel_text(text) + for index, chunk in enumerate(chunks): + if use_rich: + post_content = render_feishu_post(parse_markdown(chunk)) + body: dict[str, Any] = { + "msg_type": "post", + "content": json.dumps(post_content, ensure_ascii=False), + "uuid": self._uuid(key, index), + } + else: + body = { + "msg_type": "text", + "content": json.dumps({"text": chunk}, ensure_ascii=False), + "uuid": self._uuid(key, index), + } if message_id: body["reply_in_thread"] = bool(target.get("reply_in_thread")) self._post( diff --git a/backend/app/channels/markdown_render.py b/backend/app/channels/markdown_render.py new file mode 100644 index 00000000..5a676cd4 --- /dev/null +++ b/backend/app/channels/markdown_render.py @@ -0,0 +1,674 @@ +"""Markdown 子集解析器 + 飞书 post 富文本渲染。 + +设计目标(见 channel-render-plan-feishu-dingtalk.md §3.2): +- 零新增依赖,手写解析器覆盖受控子集; +- 输出通用块模型供飞书渲染器消费(钉钉原生 markdown 直接透传,不走块模型); +- `has_markdown(text)` 做语法检测,纯文本返回 False 以走原 text 路径,避免回归。 + +覆盖子集:标题 / 粗体 / 斜体 / 行内代码 / 围栏代码块 / 链接 / 有序无序列表 / 引用 / 分隔线。 +不支持的(表格、HTML、不闭合围栏等)按纯文本处理,绝不抛异常。 +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + + +@dataclass +class Span: + """行内文本片段。styles 为空集合表示普通文本。""" + + text: str + styles: frozenset[str] = field(default_factory=frozenset) + href: str = "" + + +@dataclass +class Heading: + level: int + spans: list[Span] + + +@dataclass +class Paragraph: + spans: list[Span] + + +@dataclass +class CodeBlock: + language: str + text: str + + +@dataclass +class ListItem: + ordered: bool + index: int + spans: list[Span] + + +@dataclass +class Quote: + spans: list[Span] + + +@dataclass +class ThematicBreak: + pass + + +@dataclass +class TableBlock: + """表格降级:按纯文本行保留,飞书 post 不支持表格。""" + + lines: list[str] + + +Block = Heading | Paragraph | CodeBlock | ListItem | Quote | ThematicBreak | TableBlock + + +# 行级正则 +_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$") +_FENCE_RE = re.compile(r"^(`{3,}|~{3,})\s*([\w+-]*)\s*$") +_UNORDERED_RE = re.compile(r"^\s*[-*+]\s+(.*)$") +_ORDERED_RE = re.compile(r"^\s*(\d+)\.\s+(.*)$") +_QUOTE_RE = re.compile(r"^\s*>\s?(.*)$") +_HR_RE = re.compile(r"^\s*([-*_])\1{2,}\s*$") +_TABLE_SEP_RE = re.compile(r"^\s*\|?[\s:|-]+\|?\s*$") +_INDENT_CODE_RE = re.compile(r"^( |\t)(.*)$") + +# 顶格代码起始行检测:def/class/import/from/if __name__/function/func 等明显的代码关键字 +_CODE_START_RE = re.compile( + r"^(def |class |import |from \S+ import |if __name__|async def |@)" +) + +# 行内正则 +_INLINE_CODE_RE = re.compile(r"`([^`\n]+)`") +_BOLD_RE = re.compile(r"\*\*([^*\n]+?)\*\*|__([^_\n]+?)__") +_ITALIC_RE = re.compile(r"(?\s?\S"), # 引用 + re.compile(r"(?m)^\s{0,3}([-*_])\1{2,}\s*$"), # 分隔线 + re.compile(r"(?m)^\s*```"), # 围栏代码块 + re.compile(r"(?m)^(def |class |import |from \S+ import |if __name__|async def |@)"), # 顶格代码 + re.compile(r"\*\*[^*\n]+\*\*|__[^_\n]+__"), # 粗体 + re.compile(r"`[^`\n]+`"), # 行内代码 + re.compile(r"\[[^\]]*\]\([^)\s]+\)"), # 链接 +] + + +def has_markdown(text: str) -> bool: + """检测文本是否含 markdown 语法标记。 + + 纯文本(含普通破折号、单星号装饰但不构成语法)应返回 False,避免外观变化。 + 斜体 `*x*` / `_x_` 不参与检测,因其误判率高(如 "a * b"、文件名 a_b_c)。 + """ + if not text: + return False + return any(pattern.search(text) for pattern in _MD_DETECT_PATTERNS) + + +def _is_markdown_block_line(line: str) -> bool: + """判断一行是否是 markdown 块级语法行(标题/围栏/列表/引用/分隔线)。 + + 用于顶格代码块收集时判断后续行是否属于代码还是 markdown 语法。 + """ + return bool( + _HEADING_RE.match(line) + or _FENCE_RE.match(line) + or _HR_RE.match(line) + or _UNORDERED_RE.match(line) + or _ORDERED_RE.match(line) + or _QUOTE_RE.match(line) + ) + + +def _is_code_continuation(line: str) -> bool: + """判断一行是否可以作为顶格代码块的续行。 + + 保守策略:接受缩进行、代码起始行、以 # 开头的注释行, + 以及顶格的赋值/函数调用行(含 = 或以 print/return/await/yield 开头)。 + 其他顶格行(自然语言段落等)视为代码块结束。 + """ + if not line.strip(): + return False + if _INDENT_CODE_RE.match(line): + return True + if _CODE_START_RE.match(line): + return True + # # 开头的行在代码上下文中是注释,不是标题 + if re.match(r"^#\s", line): + return True + # 顶格赋值行:var = ... / var: type = ... + stripped = line.strip() + if re.match(r"^\w[\w.]*\s*[:=]", stripped): + return True + # 顶格函数调用行:print(...) / foo(...) / await ... + if re.match(r"^(print|return|await|yield|raise|break|continue)\b", stripped): + return True + return bool(re.match(r"^\w[\w.]*\s*\(", stripped)) + + +def parse_markdown(text: str) -> list[Block]: + """把 markdown 文本解析为块模型列表。不抛异常,无法解析的行降级为 Paragraph。""" + if not text: + return [] + lines = text.split("\n") + blocks: list[Block] = [] + i = 0 + n = len(lines) + while i < n: + line = lines[i] + # 围栏代码块 + fence_match = _FENCE_RE.match(line) + if fence_match: + fence_marker = fence_match.group(1) + fence_char = re.escape(fence_marker[0]) + language = fence_match.group(2) or "" + code_lines: list[str] = [] + i += 1 + while i < n: + cur = lines[i] + if re.match(rf"^\s*{fence_char}{{3,}}\s*$", cur): + i += 1 + break + code_lines.append(cur) + i += 1 + # 不闭合围栏:把已收集的行作为代码块返回,飞书仍可渲染 + blocks.append(CodeBlock(language=language, text="\n".join(code_lines))) + continue + + # 分隔线 + if _HR_RE.match(line): + blocks.append(ThematicBreak()) + i += 1 + continue + + # 表格(含分隔行 |---|) + if "|" in line and i + 1 < n and _TABLE_SEP_RE.match(lines[i + 1]): + table_lines: list[str] = [] + table_lines.append(line.strip()) + i += 1 + table_lines.append(lines[i].strip()) # 分隔行 + i += 1 + while i < n and "|" in lines[i] and lines[i].strip(): + table_lines.append(lines[i].strip()) + i += 1 + blocks.append(TableBlock(lines=table_lines)) + continue + + # 标题 + heading_match = _HEADING_RE.match(line) + if heading_match: + level = len(heading_match.group(1)) + content = heading_match.group(2) + spans = _parse_inline(content) + blocks.append(Heading(level=level, spans=spans)) + i += 1 + continue + + # 引用(连续行合并为单个 Quote) + quote_match = _QUOTE_RE.match(line) + if quote_match: + quote_text_parts: list[str] = [quote_match.group(1)] + i += 1 + while i < n: + qm = _QUOTE_RE.match(lines[i]) + if not qm: + break + quote_text_parts.append(qm.group(1)) + i += 1 + spans = _parse_inline(" ".join(part for part in quote_text_parts if part)) + blocks.append(Quote(spans=spans)) + continue + + # 无序列表(连续项各自成块,便于飞书分行) + unordered_match = _UNORDERED_RE.match(line) + if unordered_match: + spans = _parse_inline(unordered_match.group(1)) + blocks.append(ListItem(ordered=False, index=0, spans=spans)) + i += 1 + continue + + # 有序列表 + ordered_match = _ORDERED_RE.match(line) + if ordered_match: + idx = int(ordered_match.group(1)) + spans = _parse_inline(ordered_match.group(2)) + blocks.append(ListItem(ordered=True, index=idx, spans=spans)) + i += 1 + continue + + # 空行 + if not line.strip(): + i += 1 + continue + + # 缩进代码块(4 空格或 tab):连续缩进行(含中间空行)合并为代码块 + if _INDENT_CODE_RE.match(line): + code_lines: list[str] = [] + while i < n: + cur = lines[i] + m = _INDENT_CODE_RE.match(cur) + if m: + code_lines.append(m.group(2)) + i += 1 + elif not cur.strip(): + # 收集连续空行,看后面是否还有缩进行 + blank_start = i + while i < n and not lines[i].strip(): + i += 1 + if i < n and _INDENT_CODE_RE.match(lines[i]): + code_lines.extend([""] * (i - blank_start)) + else: + i = blank_start + break + else: + break + blocks.append(CodeBlock(language="", text="\n".join(code_lines))) + continue + + # 顶格代码块:以 def/class/import/from import/if __name__/async def/@decorator 开头 + # 收集该行及后续行,直到遇到空行后明显非代码的内容 + if _CODE_START_RE.match(line): + code_lines = [line] + i += 1 + while i < n: + cur = lines[i] + if not cur.strip(): + # 空行:向前看,如果后续行仍是代码,则保留空行继续 + blank_start = i + while i < n and not lines[i].strip(): + i += 1 + if i < n and _is_code_continuation(lines[i]): + code_lines.extend([""] * (i - blank_start)) + else: + i = blank_start + break + elif _is_code_continuation(cur): + code_lines.append(cur) + i += 1 + else: + break + blocks.append(CodeBlock(language="", text="\n".join(code_lines))) + continue + + # 普通段落(连续非空非块行合并) + para_lines = [line] + i += 1 + while i < n: + cur = lines[i] + if not cur.strip(): + break + if ( + _HEADING_RE.match(cur) + or _FENCE_RE.match(cur) + or _HR_RE.match(cur) + or _UNORDERED_RE.match(cur) + or _ORDERED_RE.match(cur) + or _QUOTE_RE.match(cur) + or _INDENT_CODE_RE.match(cur) + or _CODE_START_RE.match(cur) + ): + break + para_lines.append(cur) + i += 1 + spans = _parse_inline("\n".join(para_lines)) + blocks.append(Paragraph(spans=spans)) + return blocks + + +def _parse_inline(text: str) -> list[Span]: + """解析行内标记:粗体 / 斜体 / 行内代码 / 链接。 + + 采用 token 扫描法:按最早出现的标记切分,递归处理。代码片段内的内容不二次解析。 + """ + if not text: + return [] + # 先抽取行内代码片段为占位,避免其内部被粗体/斜体/链接误解析 + code_segments: list[str] = [] + + def _stash_code(match: re.Match[str]) -> str: + code_segments.append(match.group(1)) + return f"\x00CODE{len(code_segments) - 1}\x00" + + work = _INLINE_CODE_RE.sub(_stash_code, text) + spans = _parse_inline_recursive(work, code_segments, set()) + return spans + + +def _parse_inline_recursive( + text: str, code_segments: list[str], styles: frozenset[str] +) -> list[Span]: + """递归解析行内标记。""" + spans: list[Span] = [] + pos = 0 + # 合并所有可能的起始标记,按位置排序处理 + patterns = [ + ("bold", _BOLD_RE), + ("italic", _ITALIC_RE), + ("link", _LINK_RE), + ] + while pos < len(text): + earliest: tuple[int, str, re.Match[str]] | None = None + for kind, pattern in patterns: + match = pattern.search(text, pos) + if match and (earliest is None or match.start() < earliest[0]): + earliest = (match.start(), kind, match) + # 代码占位回填 + code_placeholder_match = _CODE_PLACEHOLDER_RE.search(text, pos) + if code_placeholder_match: + cp_start = code_placeholder_match.start() + if earliest is None or cp_start < earliest[0]: + earliest = (cp_start, "code", code_placeholder_match) + + if earliest is None: + # 剩余纯文本 + rest = text[pos:] + if rest: + spans.append(Span(text=_restore_code(rest, code_segments), styles=styles)) + break + + start, kind, match = earliest + # 前导文本 + if start > pos: + leading = text[pos:start] + if leading: + spans.append(Span(text=_restore_code(leading, code_segments), styles=styles)) + + if kind == "code": + idx = int(match.group(1)) + spans.append( + Span(text=code_segments[idx], styles=styles | frozenset({"code"})) + ) + pos = match.end() + elif kind == "bold": + inner = match.group(1) if match.group(1) is not None else match.group(2) + spans.extend( + _parse_inline_recursive(inner, code_segments, styles | frozenset({"bold"})) + ) + pos = match.end() + elif kind == "italic": + inner = match.group(1) if match.group(1) is not None else match.group(2) + spans.extend( + _parse_inline_recursive(inner, code_segments, styles | frozenset({"italic"})) + ) + pos = match.end() + elif kind == "link": + label = match.group(1) + href = match.group(2) + label_spans = _parse_inline_recursive(label, code_segments, styles) + if label_spans: + for sp in label_spans: + sp.href = href + spans.extend(label_spans) + else: + spans.append(Span(text=href, styles=styles, href=href)) + pos = match.end() + return spans + + +def _restore_code(text: str, code_segments: list[str]) -> str: + """把代码占位符还原为实际代码文本。""" + return _CODE_PLACEHOLDER_RE.sub( + lambda m: code_segments[int(m.group(1))], + text, + ) + + +# --------------------------------------------------------------------------- +# 飞书 post 富文本渲染 +# --------------------------------------------------------------------------- + +def render_feishu_post(blocks: list[Block]) -> dict: + """把块模型渲染为飞书 post 消息的 content 结构(zh_cn 包裹)。 + + 返回形如: + {"zh_cn": {"title": "", "content": [[{tag...}, ...], ...]}} + + 每个块对应 content 数组中的一个"行"(tag 数组)。 + """ + content_rows: list[list[dict]] = [] + for block in blocks: + row = _block_to_feishu_row(block) + if row is not None: + content_rows.append(row) + return {"zh_cn": {"title": "", "content": content_rows}} + + +def _span_to_feishu_tag(span: Span) -> dict: + styles = span.styles + style_flags = [] + if "bold" in styles: + style_flags.append("bold") + if "italic" in styles: + style_flags.append("italic") + if span.href: + tag: dict = {"tag": "a", "text": span.text, "href": span.href} + elif "code" in styles: + tag = {"tag": "text", "text": span.text, "un_escape": False, "style": ["code"]} + return tag + else: + tag = {"tag": "text", "text": span.text, "un_escape": False} + if style_flags: + tag["style"] = style_flags + return tag + + +def _spans_to_feishu_tags(spans: list[Span]) -> list[dict]: + tags: list[dict] = [] + for span in spans: + if not span.text and not span.href: + continue + tags.append(_span_to_feishu_tag(span)) + return tags + + +def _block_to_feishu_row(block: Block) -> list[dict] | None: + if isinstance(block, Heading): + tags = _spans_to_feishu_tags(block.spans) + for tag in tags: + existing = tag.get("style") or [] + tag["style"] = list(dict.fromkeys(["bold", *existing])) + return tags or [{"tag": "text", "text": "", "un_escape": False}] + if isinstance(block, Paragraph): + tags = _spans_to_feishu_tags(block.spans) + if not tags: + return [{"tag": "text", "text": "", "un_escape": False}] + # 段落内若含换行(多行合并),拆成多行 + return _split_paragraph_newlines(tags) + if isinstance(block, Quote): + tags = _spans_to_feishu_tags(block.spans) + for tag in tags: + tag["text"] = f"|{tag.get('text', '')}" + return tags or [{"tag": "text", "text": "|", "un_escape": False}] + if isinstance(block, ListItem): + prefix = f"{block.index}. " if block.ordered else "• " + tags = _spans_to_feishu_tags(block.spans) + if tags: + first = tags[0] + first["text"] = f"{prefix}{first.get('text', '')}" + else: + tags = [{"tag": "text", "text": prefix, "un_escape": False}] + return tags + if isinstance(block, CodeBlock): + return [ + { + "tag": "code_block", + "language": block.language or "", + "text": block.text, + } + ] + if isinstance(block, ThematicBreak): + return [{"tag": "text", "text": "———", "un_escape": False}] + if isinstance(block, TableBlock): + # 表格降级为纯文本行 + return [{"tag": "text", "text": "\n".join(block.lines), "un_escape": False}] + return None + + +def _split_paragraph_newlines(tags: list[dict]) -> list[dict]: + """段落 spans 内若含 \n,拆成多行 tag(飞书 post 一行内不渲染换行)。""" + out: list[dict] = [] + for tag in tags: + text = tag.get("text", "") + if "\n" not in text: + out.append(tag) + continue + parts = text.split("\n") + for part in parts: + new_tag = dict(tag) + new_tag["text"] = part + out.append(new_tag) + return out if out else [{"tag": "text", "text": "", "un_escape": False}] + + +def split_markdown_by_lines(text: str, limit: int) -> list[str]: + """按行边界切分 markdown,避免把代码块/列表/粗体切成两半。 + + 用于富文本路径的分块:优先在空行处切分,其次在普通行边界,单行超限时硬切该行。 + 与 split_channel_text 不同,此函数保证不在行中间断开(除非单行超长)。 + """ + if not text: + return [] + if len(text) <= limit: + return [text] + lines = text.split("\n") + chunks: list[str] = [] + current_lines: list[str] = [] + current_len = 0 + for line in lines: + line_with_newline = line + "\n" + line_len = len(line_with_newline) + if line_len > limit: + # 当前行超长:先 flush 已累积的,再硬切该行 + if current_lines: + chunks.append("\n".join(current_lines).rstrip("\n")) + current_lines = [] + current_len = 0 + # 硬切超长行 + remaining = line + while len(remaining) > limit: + chunks.append(remaining[:limit]) + remaining = remaining[limit:] + if remaining: + current_lines = [remaining] + current_len = len(remaining) + 1 + continue + if current_len + line_len > limit: + chunks.append("\n".join(current_lines).rstrip("\n")) + current_lines = [line] + current_len = line_len + else: + current_lines.append(line) + current_len += line_len + if current_lines: + chunks.append("\n".join(current_lines).rstrip("\n")) + return [c for c in chunks if c] + + +def extract_dingtalk_title(text: str, *, max_length: int = 20) -> str: + """从 markdown 文本提取钉钉 markdown 消息的 title。 + + 规则:首个 `#` 标题文本 → 否则首个非空行 → 否则默认 "消息";截断 ≤ max_length 字。 + """ + if not text or not text.strip(): + return "消息" + for line in text.split("\n"): + match = _HEADING_RE.match(line) + if match: + title = match.group(2).strip() + if title: + return title[:max_length] + for line in text.split("\n"): + stripped = line.strip() + if stripped: + return stripped[:max_length] + return "消息" + + +def ensure_code_fences(text: str) -> str: + """为缺少围栏的代码块补上 ``` 围栏,使钉钉等原生 markdown 渲染器正确识别。 + + 利用 parse_markdown 的块识别能力定位代码块区间,然后检查原始文本中 + 对应位置是否已有围栏;没有则插入 ``` 围栏。 + """ + if not text or not text.strip(): + return text + blocks = parse_markdown(text) + lines = text.split("\n") + # 收集需要加围栏的代码块行区间 [start, end)(0-based,原始行号) + fence_ranges: list[tuple[int, int, str]] = [] + idx = 0 + for block in blocks: + if not isinstance(block, CodeBlock): + continue + # 跳过已有围栏的代码块(``` 或 ~~~ 开头) + # 找到该代码块在原始行中的起始位置 + block_text_lines = block.text.split("\n") if block.text else [] + block_line_count = len(block_text_lines) + # 从 idx 开始搜索代码块的起始行 + start = _find_block_start(lines, idx, block, block_text_lines) + if start is None: + continue + end = start + block_line_count + # 检查是否已有围栏(前一非空行是 ``` 或 ~~~) + if _has_fence_before(lines, start): + idx = end + continue + language = block.language or "" + fence_ranges.append((start, end, language)) + idx = end + + if not fence_ranges: + return text + + # 从后往前插入围栏,避免行号偏移 + result_lines = list(lines) + for start, end, language in reversed(fence_ranges): + fence_open = f"```{language}" if language else "```" + # 插入闭合围栏(在 end 位置,即代码块最后一行之后) + result_lines.insert(end, "```") + # 插入开启围栏(在 start 位置) + result_lines.insert(start, fence_open) + return "\n".join(result_lines) + + +def _find_block_start( + lines: list[str], from_idx: int, block: CodeBlock, block_lines: list[str] +) -> int | None: + """在 lines 中从 from_idx 开始查找 CodeBlock 对应的起始行号。""" + if not block_lines: + return None + first_code_line = block_lines[0].rstrip() + for i in range(from_idx, len(lines)): + if lines[i].rstrip() == first_code_line: + # 验证后续行是否匹配 + match = True + for j, bl in enumerate(block_lines): + if i + j >= len(lines): + match = False + break + if lines[i + j].rstrip() != bl.rstrip(): + match = False + break + if match: + return i + return None + + +def _has_fence_before(lines: list[str], code_start: int) -> bool: + """检查代码块起始行之前是否已有围栏标记(``` 或 ~~~)。""" + for i in range(code_start - 1, -1, -1): + if not lines[i].strip(): + continue + return bool(_FENCE_RE.match(lines[i])) + return False diff --git a/backend/app/config.py b/backend/app/config.py index 3a16054e..6abb2be8 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -41,6 +41,9 @@ class Settings(BaseSettings): # 钉钉 emotion 接口的表情常量与所需权限尚未真机验证,验证通过前默认关闭: # 否则常量失效或权限未开时,每条入站消息都会留下一条失败的 reaction 投递。 channel_dingtalk_reaction_enabled: bool = False + # 出站富文本渲染开关:开启时飞书走 post 富文本、钉钉走 markdown 消息; + # 关闭时两者回退为纯 text 消息,用于快速回退。 + channel_rich_render_enabled: bool = True model_config = SettingsConfigDict( env_file=_os.environ.get("ULTRARAG_DOTENV", ".env"), diff --git a/backend/tests/test_channel_dingtalk.py b/backend/tests/test_channel_dingtalk.py index 8716b12d..19ee2cbc 100644 --- a/backend/tests/test_channel_dingtalk.py +++ b/backend/tests/test_channel_dingtalk.py @@ -10,6 +10,7 @@ DINGTALK_ACK_EMOTION_ID, DINGTALK_ACK_EMOTION_NAME, DINGTALK_REACTION_HANDLE, + DINGTALK_TEXT_LIMIT, DingTalkAdapter, DingTalkPermanentError, DingTalkTokenProvider, @@ -713,3 +714,141 @@ def test_envelope_round_trips_attachments_as_dataclass() -> None: assert decoded.attachments[0].download_params["download_code"] == "dc_001" assert decoded.attachments[1].filename == "report.xlsx" assert decoded.attachments[1].kind == "file" + + +# --------------------------------------------------------------------------- +# 富文本(markdown)渲染 — channel-render-plan §5.3 +# --------------------------------------------------------------------------- + + +class _WebhookClient: + """记录所有 webhook POST 请求的假 client。""" + + def __init__(self, response=None): + self.calls = [] + self._response = response or _Response(200, {"errcode": 0}) + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, url, json=None, headers=None, **_kwargs): + self.calls.append({"url": url, "body": json, "headers": headers or {}}) + return self._response + + +def _send_binding(): + return ChannelBinding( + tenant_id="t", + agent_id="a", + channel="dingtalk", + config_json={"client_id": "client-1"}, + credentials_enc=encrypt_channel_secret("secret"), + ) + + +def _send_target(): + return {"session_webhook": "https://oapi.dingtalk.com/robot/send?session=x"} + + +def test_dingtalk_markdown_render_uses_markdown_msgtype(): + client = _WebhookClient() + adapter = DingTalkAdapter(client_factory=lambda: client) + adapter.send(_send_binding(), _send_target(), "**粗体** 列表", idempotency_key="d1") + assert len(client.calls) == 1 + body = client.calls[0]["body"] + assert body["msgtype"] == "markdown" + assert body["markdown"]["text"] == "**粗体** 列表" + assert body["markdown"]["title"] + + +def test_dingtalk_markdown_title_from_heading(): + client = _WebhookClient() + adapter = DingTalkAdapter(client_factory=lambda: client) + adapter.send(_send_binding(), _send_target(), "# 周报\n正文", idempotency_key="d2") + body = client.calls[0]["body"] + assert body["markdown"]["title"] == "周报" + + +def test_dingtalk_markdown_title_from_first_line_when_no_heading(): + client = _WebhookClient() + adapter = DingTalkAdapter(client_factory=lambda: client) + # 含 markdown 语法(粗体)但无标题,title 取首行截断 + adapter.send(_send_binding(), _send_target(), "**首行**\n第二行", idempotency_key="d3") + body = client.calls[0]["body"] + assert body["msgtype"] == "markdown" + assert body["markdown"]["title"] == "**首行**" + + +def test_dingtalk_plain_text_still_uses_text_msgtype(): + client = _WebhookClient() + adapter = DingTalkAdapter(client_factory=lambda: client) + adapter.send(_send_binding(), _send_target(), "hello world", idempotency_key="d4") + body = client.calls[0]["body"] + assert body["msgtype"] == "text" + assert body["text"]["content"] == "hello world" + + +def test_dingtalk_rich_render_disabled_falls_back_to_text(monkeypatch): + settings = get_settings().model_copy(update={"channel_rich_render_enabled": False}) + monkeypatch.setattr("app.channels.adapters.dingtalk.get_settings", lambda: settings) + client = _WebhookClient() + adapter = DingTalkAdapter(client_factory=lambda: client) + adapter.send(_send_binding(), _send_target(), "**粗体**", idempotency_key="d5") + body = client.calls[0]["body"] + assert body["msgtype"] == "text" + assert body["text"]["content"] == "**粗体**" + + +def test_dingtalk_markdown_long_text_chunked_with_titles(): + client = _WebhookClient() + adapter = DingTalkAdapter(client_factory=lambda: client) + long_md = "\n".join(f"# 标题{i}\n内容{i}" for i in range(800)) + adapter.send(_send_binding(), _send_target(), long_md, idempotency_key="d6") + assert len(client.calls) >= 2 + for call in client.calls: + assert call["body"]["msgtype"] == "markdown" + assert call["body"]["markdown"]["title"] + assert len(call["body"]["markdown"]["text"]) <= DINGTALK_TEXT_LIMIT + + +def test_dingtalk_markdown_rejects_untrusted_webhook(): + client = _WebhookClient() + adapter = DingTalkAdapter(client_factory=lambda: client) + with pytest.raises(DingTalkPermanentError): + adapter.send( + _send_binding(), + {"session_webhook": "https://attacker.example/steal"}, + "**x**", + idempotency_key="d7", + ) + assert client.calls == [] + + +def test_dingtalk_markdown_rejects_expired_webhook(): + client = _WebhookClient() + adapter = DingTalkAdapter(client_factory=lambda: client) + target = { + "session_webhook": "https://oapi.dingtalk.com/robot/send?session=x", + "session_webhook_expired_time": 1, + } + with pytest.raises(DingTalkPermanentError, match="过期"): + adapter.send(_send_binding(), target, "**x**", idempotency_key="d8") + assert client.calls == [] + + +def test_dingtalk_markdown_send_failure_is_permanent(): + client = _WebhookClient(_Response(400, {"errcode": 1})) + adapter = DingTalkAdapter(client_factory=lambda: client) + with pytest.raises(DingTalkPermanentError): + adapter.send(_send_binding(), _send_target(), "**x**", idempotency_key="d9") + + +def test_dingtalk_markdown_5xx_is_transient(): + client = _WebhookClient(_Response(503, {"errcode": 0})) + adapter = DingTalkAdapter(client_factory=lambda: client) + with pytest.raises(DingTalkTransientError): + adapter.send(_send_binding(), _send_target(), "**x**", idempotency_key="d10") + diff --git a/backend/tests/test_feishu_adapter.py b/backend/tests/test_feishu_adapter.py index 94331780..5c0b51f4 100644 --- a/backend/tests/test_feishu_adapter.py +++ b/backend/tests/test_feishu_adapter.py @@ -940,3 +940,148 @@ def test_normalize_post_message_without_locale_wrapper() -> None: assert len(inbound.attachments) == 1 assert inbound.attachments[0].media_id == "img_v3_nolocale" assert inbound.text == "查询假期余额" + + +# --------------------------------------------------------------------------- +# 富文本(post)渲染 — channel-render-plan §5.2 +# --------------------------------------------------------------------------- + + +def _rich_handler(calls): + def handler(url, kwargs): + if "/auth/" in url: + return _response(200, {"code": 0, "tenant_access_token": "token", "expire": 7200}, url) + calls.append((url, kwargs)) + return _response(200, {"code": 0}, url) + return handler + + +def test_rich_post_render_for_markdown_text() -> None: + calls = [] + adapter = FeishuAdapter(client_factory=lambda: FakeClient(_rich_handler(calls))) + adapter.send( + _binding(), + {"message_id": "om_source"}, + "# 标题\n\n**粗体** 与 [链接](https://x.com)", + idempotency_key="rich-1", + ) + send = calls[0] + assert send[1]["json"]["msg_type"] == "post" + content = json.loads(send[1]["json"]["content"]) + rows = content["zh_cn"]["content"] + # 第一行标题,应含 bold style + assert any("bold" in (tag.get("style") or []) for tag in rows[0]) + # 存在链接 tag + flat_tags = [tag for row in rows for tag in row] + assert any(tag.get("tag") == "a" and tag.get("href") == "https://x.com" for tag in flat_tags) + + +def test_rich_post_code_block_tag() -> None: + calls = [] + adapter = FeishuAdapter(client_factory=lambda: FakeClient(_rich_handler(calls))) + adapter.send( + _binding(), + {"message_id": "om_source"}, + "```python\nprint(1)\n```", + idempotency_key="rich-code", + ) + content = json.loads(calls[0][1]["json"]["content"]) + tag = content["zh_cn"]["content"][0][0] + assert tag["tag"] == "code_block" + assert tag["language"] == "python" + assert tag["text"] == "print(1)" + + +def test_plain_text_still_uses_text_msg_type() -> None: + calls = [] + adapter = FeishuAdapter(client_factory=lambda: FakeClient(_rich_handler(calls))) + adapter.send(_binding(), {"message_id": "om_source"}, "hello", idempotency_key="t1") + assert calls[0][1]["json"]["msg_type"] == "text" + assert json.loads(calls[0][1]["json"]["content"]) == {"text": "hello"} + + +def test_rich_render_disabled_falls_back_to_text(monkeypatch) -> None: + from app.config import get_settings + settings = get_settings().model_copy(update={"channel_rich_render_enabled": False}) + monkeypatch.setattr("app.channels.adapters.feishu.get_settings", lambda: settings) + calls = [] + adapter = FeishuAdapter(client_factory=lambda: FakeClient(_rich_handler(calls))) + adapter.send( + _binding(), + {"message_id": "om_source"}, + "**粗体**", + idempotency_key="rich-off", + ) + assert calls[0][1]["json"]["msg_type"] == "text" + assert json.loads(calls[0][1]["json"]["content"]) == {"text": "**粗体**"} + + +def test_rich_long_markdown_chunks_use_distinct_stable_uuids() -> None: + calls = [] + + def handler(url, kwargs): + if "/auth/" in url: + return _response(200, {"code": 0, "tenant_access_token": "token", "expire": 7200}, url) + calls.append(kwargs["json"]) + return _response(200, {"code": 0}, url) + + adapter = FeishuAdapter(client_factory=lambda: FakeClient(handler)) + target = {"message_id": "om_source"} + # 构造超长 markdown:多行,每行含粗体,触发分块 + long_md = "\n".join(f"**第{i}行**" for i in range(500)) + adapter.send(_binding(), target, long_md, idempotency_key="long-rich") + first_uuids = [body["uuid"] for body in calls] + assert len(first_uuids) >= 2 + assert len(set(first_uuids)) == len(first_uuids) # 互不相同 + # 全部走 post + assert all(body["msg_type"] == "post" for body in calls) + # 幂等:重发 uuid 稳定 + calls.clear() + adapter.send(_binding(), target, long_md, idempotency_key="long-rich") + assert [body["uuid"] for body in calls] == first_uuids + + +def test_rich_render_path_refreshes_token_on_401() -> None: + tokens = iter(["token-old", "token-new"]) + send_count = 0 + + def handler(url, kwargs): + nonlocal send_count + if "/auth/" in url: + return _response( + 200, + {"code": 0, "tenant_access_token": next(tokens), "expire": 7200}, + url, + ) + send_count += 1 + if send_count == 1: + return _response(401, {"code": 99991663}, url) + assert kwargs["headers"]["Authorization"] == "Bearer token-new" + return _response(200, {"code": 0}, url) + + def factory(): + return FakeClient(handler) + adapter = FeishuAdapter(client_factory=factory) + adapter.send( + _binding(), + {"message_id": "om_source"}, + "**粗体**", + idempotency_key="rich-401", + ) + assert send_count == 2 + + +def test_rich_create_message_uses_receive_id() -> None: + calls = [] + adapter = FeishuAdapter(client_factory=lambda: FakeClient(_rich_handler(calls))) + adapter.send( + _binding(), + {"receive_id": "ou_user", "receive_id_type": "open_id"}, + "# 标题", + idempotency_key="rich-create", + ) + send = calls[0] + assert send[1]["params"] == {"receive_id_type": "open_id"} + assert send[1]["json"]["receive_id"] == "ou_user" + assert send[1]["json"]["msg_type"] == "post" + diff --git a/backend/tests/test_markdown_render.py b/backend/tests/test_markdown_render.py new file mode 100644 index 00000000..7923b541 --- /dev/null +++ b/backend/tests/test_markdown_render.py @@ -0,0 +1,457 @@ +from __future__ import annotations + +import signal + +from app.channels.markdown_render import ( + CodeBlock, + Heading, + ListItem, + Paragraph, + Quote, + TableBlock, + ThematicBreak, + ensure_code_fences, + extract_dingtalk_title, + has_markdown, + parse_markdown, + render_feishu_post, + split_markdown_by_lines, +) + +# --------------------------------------------------------------------------- +# has_markdown +# --------------------------------------------------------------------------- + + +def test_has_markdown_detects_common_syntax(): + assert has_markdown("# 标题") + assert has_markdown("**bold**") + assert has_markdown("`code`") + assert has_markdown("[link](http://x)") + assert has_markdown("- 列表项") + assert has_markdown("1. 有序项") + assert has_markdown("> 引用") + assert has_markdown("```\ncode\n```") + assert has_markdown("---") + + +def test_has_markdown_false_for_plain_text(): + assert not has_markdown("hello world") + assert not has_markdown("a * b = c") + assert not has_markdown("file_name_with_underscores") + assert not has_markdown("普通中文回复") + assert not has_markdown("") + # 单星号装饰不构成斜体语法(已被 _ITALIC_RE 排除检测),但 * 列表项会命中 + assert not has_markdown("价格 * 3 = 9") + # 破折号不是分隔线(少于3个) + assert not has_markdown("a - b") + + +# --------------------------------------------------------------------------- +# parse_markdown +# --------------------------------------------------------------------------- + + +def test_parse_heading(): + blocks = parse_markdown("## 标题二") + assert len(blocks) == 1 + assert isinstance(blocks[0], Heading) + assert blocks[0].level == 2 + assert blocks[0].spans[0].text == "标题二" + + +def test_parse_bold_and_italic(): + blocks = parse_markdown("**粗** 和 *斜*") + para = blocks[0] + assert isinstance(para, Paragraph) + texts = [(s.text, set(s.styles)) for s in para.spans] + assert ("粗", {"bold"}) in texts + assert ("斜", {"italic"}) in texts + + +def test_parse_inline_code(): + blocks = parse_markdown("用 `printf` 输出") + spans = blocks[0].spans + code_span = next(s for s in spans if "code" in s.styles) + assert code_span.text == "printf" + + +def test_parse_multiple_inline_code_no_infinite_loop(): + """Regression: multiple inline code segments caused an infinite loop because + the code-placeholder regex searched text[pos:] but used the relative match.end() + as the absolute pos, never advancing past the second placeholder.""" + def _handler(signum, frame): + raise TimeoutError("parse_markdown did not complete in time") + + signal.signal(signal.SIGALRM, _handler) + signal.alarm(5) + try: + blocks = parse_markdown("a `b` c `d` e") + finally: + signal.alarm(0) + spans = blocks[0].spans + code_spans = [s for s in spans if "code" in s.styles] + assert len(code_spans) == 2 + assert code_spans[0].text == "b" + assert code_spans[1].text == "d" + + +def test_parse_code_in_bold_not_reparsed(): + blocks = parse_markdown("**`x`**") + spans = blocks[0].spans + # 粗体包裹,内部代码不应被二次拆为 code span,而是粗体文本 + assert any(set(s.styles) == {"bold"} and s.text == "`x`" or s.text == "x" + for s in spans) + + +def test_parse_fenced_code_block_with_language(): + blocks = parse_markdown("```python\nprint(1)\nprint(2)\n```") + assert len(blocks) == 1 + assert isinstance(blocks[0], CodeBlock) + assert blocks[0].language == "python" + assert blocks[0].text == "print(1)\nprint(2)" + + +def test_parse_fenced_code_block_no_language(): + blocks = parse_markdown("```\nraw\n```") + assert isinstance(blocks[0], CodeBlock) + assert blocks[0].language == "" + assert blocks[0].text == "raw" + + +def test_parse_unclosed_fence_does_not_raise(): + blocks = parse_markdown("```\nunclosed code") + assert isinstance(blocks[0], CodeBlock) + assert "unclosed code" in blocks[0].text + + +def test_parse_indented_code_block(): + blocks = parse_markdown(" print(1)\n print(2)") + assert isinstance(blocks[0], CodeBlock) + assert blocks[0].text == "print(1)\nprint(2)" + + +def test_parse_indented_code_block_with_blank_lines(): + text = " def f():\n return 1\n\n print(f())" + blocks = parse_markdown(text) + assert len(blocks) == 1 + assert isinstance(blocks[0], CodeBlock) + assert "return 1" in blocks[0].text + assert "print(f())" in blocks[0].text + + +def test_parse_indented_code_block_double_blank_merges(): + text = " line1\n\n\n line2" + blocks = parse_markdown(text) + assert len(blocks) == 1 + assert isinstance(blocks[0], CodeBlock) + assert blocks[0].text == "line1\n\n\nline2" + + +def test_parse_indented_code_block_followed_by_paragraph(): + text = " code line\n\nparagraph text" + blocks = parse_markdown(text) + assert isinstance(blocks[0], CodeBlock) + assert blocks[0].text == "code line" + assert isinstance(blocks[1], Paragraph) + assert blocks[1].spans[0].text == "paragraph text" + + +def test_parse_indented_code_block_after_paragraph(): + text = "intro text\n\n code here" + blocks = parse_markdown(text) + assert isinstance(blocks[0], Paragraph) + assert blocks[0].spans[0].text == "intro text" + assert isinstance(blocks[1], CodeBlock) + assert blocks[1].text == "code here" + + +def test_parse_toplevel_code_block_def(): + text = "def f():\n return 1" + blocks = parse_markdown(text) + assert isinstance(blocks[0], CodeBlock) + assert "def f():" in blocks[0].text + assert "return 1" in blocks[0].text + + +def test_parse_toplevel_code_block_with_following_paragraph(): + text = "def f():\n return 1\n\n这是说明文字。" + blocks = parse_markdown(text) + assert isinstance(blocks[0], CodeBlock) + assert "def f():" in blocks[0].text + assert isinstance(blocks[1], Paragraph) + assert blocks[1].spans[0].text == "这是说明文字。" + + +def test_parse_toplevel_code_block_includes_assignment_and_print(): + text = ( + "def f():\n" + " return 1\n" + "\n" + "result = f()\n" + "print(result)\n" + "# output: 1\n" + "\n" + "说明文字。" + ) + blocks = parse_markdown(text) + assert isinstance(blocks[0], CodeBlock) + assert "def f():" in blocks[0].text + assert "result = f()" in blocks[0].text + assert "print(result)" in blocks[0].text + assert "# output: 1" in blocks[0].text + assert isinstance(blocks[1], Paragraph) + assert blocks[1].spans[0].text == "说明文字。" + + +def test_parse_toplevel_code_block_hash_comment_not_heading(): + text = "def f():\n return 1\n\n# [1, 2, 3]\n\n说明。" + blocks = parse_markdown(text) + assert isinstance(blocks[0], CodeBlock) + assert "# [1, 2, 3]" in blocks[0].text + assert not any(isinstance(b, Heading) for b in blocks) + + +def test_parse_link(): + blocks = parse_markdown("[StaffDeck](https://staffdeck.ai)") + spans = blocks[0].spans + link = next(s for s in spans if s.href) + assert link.text == "StaffDeck" + assert link.href == "https://staffdeck.ai" + + +def test_parse_unordered_list(): + blocks = parse_markdown("- 项一\n- 项二") + assert all(isinstance(b, ListItem) for b in blocks) + assert blocks[0].ordered is False + assert blocks[0].spans[0].text == "项一" + assert blocks[1].spans[0].text == "项二" + + +def test_parse_ordered_list_keeps_index(): + blocks = parse_markdown("1. 第一\n2. 第二") + assert blocks[0].ordered is True + assert blocks[0].index == 1 + assert blocks[1].index == 2 + + +def test_parse_quote(): + blocks = parse_markdown("> 引用一\n> 引用二") + assert len(blocks) == 1 + assert isinstance(blocks[0], Quote) + assert "引用一" in blocks[0].spans[0].text + assert "引用二" in blocks[0].spans[0].text + + +def test_parse_thematic_break(): + blocks = parse_markdown("---") + assert isinstance(blocks[0], ThematicBreak) + + +def test_parse_table_degrades_to_text(): + md = "| a | b |\n|---|---|\n| 1 | 2 |" + blocks = parse_markdown(md) + assert len(blocks) == 1 + assert isinstance(blocks[0], TableBlock) + assert len(blocks[0].lines) == 3 + + +def test_parse_html_tags_treated_as_text(): + blocks = parse_markdown("") + para = blocks[0] + assert isinstance(para, Paragraph) + assert "") + post = render_feishu_post(blocks) + tag = post["zh_cn"]["content"][0][0] + assert tag["tag"] == "text" + assert "