ForcePilot/backend/package/yuxi/channel/extensions/feishu/render_post.py
Kris 5e91bb9985 feat(feishu): 新增飞书渠道完整插件实现
新增飞书渠道插件,包含基础配置、事件处理、消息收发、工具调用、权限管理等完整功能模块,支持WebSocket长连接、卡片消息、流式响应、群管理、审批通知等能力
2026-05-21 10:46:42 +08:00

213 lines
7.2 KiB
Python

from __future__ import annotations
import json
import re
from yuxi.channel.security.html_guard import sanitize_text_content_preserve_entities, sanitize_url
from yuxi.channel.render.parser import parse_markdown
from yuxi.channel.render.ir import (
BlockQuote,
Bold,
BoldItalic,
CodeBlock,
Document,
Heading,
Image,
InlineCode,
IrNode,
Italic,
LineBreak,
Link,
ListItem,
OrderedList,
Paragraph,
Strikethrough,
Table,
Text,
ThematicBreak,
UnorderedList,
)
_FEISHU_ESCAPE_RE = re.compile(r"([\\*_{}\[\]()#+\-!|>~])")
def _escape_fs(text: str) -> str:
return _FEISHU_ESCAPE_RE.sub(r"\\\1", text)
def _build_text(content: str, bold: bool = False, italic: bool = False, strikethrough: bool = False) -> dict:
style: list[str] = []
if bold:
style.append("bold")
if italic:
style.append("italic")
if strikethrough:
style.append("strikethrough")
return {"tag": "text", "text": sanitize_text_content_preserve_entities(content), "style": style}
def _build_paragraph(elements: list[dict]) -> dict:
return {"tag": "p", "children": elements if elements else [_build_text("")]}
def _build_code_block(code: str, language: str = "") -> dict:
return {
"tag": "pre",
"children": [
{
"tag": "code",
"code": sanitize_text_content_preserve_entities(code),
"code_language": sanitize_text_content_preserve_entities(language) or "plaintext",
}
],
}
def _render_inline_fs(nodes: list[IrNode]) -> list[dict]:
elements: list[dict] = []
for node in nodes:
if isinstance(node, Text):
cleaned = sanitize_text_content_preserve_entities(node.content)
elements.append(_build_text(_escape_fs(cleaned)))
elif isinstance(node, Bold):
inner = _render_inline_fs(node.children)
for el in inner:
if el["tag"] == "text":
el.setdefault("style", []).append("bold")
elements.extend(inner)
elif isinstance(node, Italic):
inner = _render_inline_fs(node.children)
for el in inner:
if el["tag"] == "text":
el.setdefault("style", []).append("italic")
elements.extend(inner)
elif isinstance(node, BoldItalic):
inner = _render_inline_fs(node.children)
for el in inner:
if el["tag"] == "text":
style = el.setdefault("style", [])
style.append("bold")
style.append("italic")
elements.extend(inner)
elif isinstance(node, Strikethrough):
inner = _render_inline_fs(node.children)
for el in inner:
if el["tag"] == "text":
el.setdefault("style", []).append("strikethrough")
elements.extend(inner)
elif isinstance(node, InlineCode):
elements.append(_build_text(node.code))
elif isinstance(node, Link):
inner = _render_inline_fs(node.children)
safe_url = sanitize_url(node.url)
for el in inner:
if el["tag"] == "text":
el["text"] = f"{el['text']} ({safe_url})"
elements.extend(inner)
elif isinstance(node, Image):
elements.append(_build_text(f"[{node.alt or 'Image'}]"))
elif isinstance(node, LineBreak):
elements.append(_build_text("\n"))
return elements
def _render_list_item_fs(item: ListItem) -> list[list[dict]]:
paragraphs: list[list[dict]] = []
inline_nodes: list[IrNode] = []
for child in item.children:
if isinstance(child, (UnorderedList, OrderedList)):
if inline_nodes:
paragraphs.append(_render_inline_fs(inline_nodes))
inline_nodes = []
paragraphs.extend(_render_block_fs(child))
elif isinstance(child, Paragraph):
if inline_nodes:
paragraphs.append(_render_inline_fs(inline_nodes))
inline_nodes = []
paragraphs.append(_render_inline_fs(child.children))
else:
inline_nodes.append(child)
if inline_nodes:
paragraphs.append(_render_inline_fs(inline_nodes))
return paragraphs
def _render_block_fs(node: IrNode) -> list[list[dict]]:
if isinstance(node, Paragraph):
inline = _render_inline_fs(node.children)
return [inline] if inline else [[_build_text("")]]
elif isinstance(node, Heading):
inline = _render_inline_fs(node.children)
for el in inline:
if el["tag"] == "text":
el.setdefault("style", []).append("bold")
return [inline]
elif isinstance(node, CodeBlock):
return [[_build_text("")]]
elif isinstance(node, BlockQuote):
result: list[list[dict]] = []
for child in node.children:
result.extend(_render_block_fs(child))
return result
elif isinstance(node, UnorderedList):
result: list[list[dict]] = []
for item in node.items:
item_paras = _render_list_item_fs(item)
for para in item_paras:
result.append([_build_text("\u2022 " + "".join(el.get("text", "") for el in para))])
return result
elif isinstance(node, OrderedList):
result: list[list[dict]] = []
for idx, item in enumerate(node.items, start=node.start):
item_paras = _render_list_item_fs(item)
for para in item_paras:
result.append([_build_text(f"{idx}. " + "".join(el.get("text", "") for el in para))])
return result
elif isinstance(node, ThematicBreak):
return [[_build_text("---")]]
elif isinstance(node, Table):
rows: list[list[dict]] = []
if node.header:
header_text = " | ".join(
"".join(el.get("text", "") for el in _render_inline_fs(cell.children))
for cell in node.header.cells
)
rows.append([_build_text(header_text, bold=True)])
for row in node.rows:
row_text = " | ".join(
"".join(el.get("text", "") for el in _render_inline_fs(cell.children))
for cell in row.cells
)
rows.append([_build_text(row_text)])
if rows:
rows.insert(1, [_build_text("-" * 20)])
return rows
else:
return [[_build_text("")]]
def render_to_feishu_post(doc: Document) -> dict:
content: list[list[dict]] = []
for node in doc.children:
if isinstance(node, CodeBlock):
content.append([_build_text("")])
continue
content.extend(_render_block_fs(node))
if not content:
content = [[_build_text("")]]
return {"zh_cn": {"title": "", "content": [content]}}
def render_to_feishu_post_json(doc: Document) -> str:
post = render_to_feishu_post(doc)
return json.dumps(post, ensure_ascii=False)
def render_markdown_to_feishu_post(md_text: str) -> dict:
doc = parse_markdown(md_text)
return render_to_feishu_post(doc)
def render_markdown_to_feishu_post_json(md_text: str) -> str:
doc = parse_markdown(md_text)
return render_to_feishu_post_json(doc)