feat(feishu): 支持 markdown 格式的消息处理,优化内容解析逻辑

This commit is contained in:
ooodc 2026-04-21 21:21:49 +08:00
parent 0c724e37bb
commit a0fe7c57bd

View File

@ -1189,6 +1189,11 @@ fn parse_post_content(content: &str) -> String {
/// Extract text from a single post element (text, link, at-mention). /// Extract text from a single post element (text, link, at-mention).
fn extract_element(el: &serde_json::Value, out: &mut Vec<String>) { fn extract_element(el: &serde_json::Value, out: &mut Vec<String>) {
match el.get("tag").and_then(|t| t.as_str()).unwrap_or("") { match el.get("tag").and_then(|t| t.as_str()).unwrap_or("") {
"md" => {
if let Some(text) = el.get("text").and_then(|t| t.as_str()) {
out.push(text.to_string());
}
}
"text" => { "text" => {
if let Some(text) = el.get("text").and_then(|t| t.as_str()) { if let Some(text) = el.get("text").and_then(|t| t.as_str()) {
out.push(text.to_string()); out.push(text.to_string());
@ -1582,8 +1587,8 @@ struct MdPatterns {
impl MdPatterns { impl MdPatterns {
fn new() -> Self { fn new() -> Self {
Self { Self {
table_re: Regex::new(r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)").unwrap(), table_re: Regex::new(r"(?m)((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)").unwrap(),
heading_re: Regex::new(r"^(#{1,6})\s+(.+)$").unwrap(), heading_re: Regex::new(r"(?m)^(#{1,6})\s+(.+)$").unwrap(),
code_block_re: Regex::new(r"(```[\s\S]*?```)").unwrap(), code_block_re: Regex::new(r"(```[\s\S]*?```)").unwrap(),
bold_re: Regex::new(r"\*\*(.+?)\*\*|__(.+?)__").unwrap(), bold_re: Regex::new(r"\*\*(.+?)\*\*|__(.+?)__").unwrap(),
// Simple pattern: detect *text* or _text_ markers (may overlap with bold, but that's ok for detection) // Simple pattern: detect *text* or _text_ markers (may overlap with bold, but that's ok for detection)
@ -1591,8 +1596,8 @@ impl MdPatterns {
italic_re: Regex::new(r"\*[^*]+\*|_[^_]+_").unwrap(), italic_re: Regex::new(r"\*[^*]+\*|_[^_]+_").unwrap(),
strike_re: Regex::new(r"~~.+?~~").unwrap(), strike_re: Regex::new(r"~~.+?~~").unwrap(),
link_re: Regex::new(r"\[([^\]]+)\]\((https?://[^\)]+)\)").unwrap(), link_re: Regex::new(r"\[([^\]]+)\]\((https?://[^\)]+)\)").unwrap(),
list_re: Regex::new(r"^[\s]*[-*+]\s+").unwrap(), list_re: Regex::new(r"(?m)^[\s]*[-*+]\s+").unwrap(),
olist_re: Regex::new(r"^[\s]*\d+\.\s+").unwrap(), olist_re: Regex::new(r"(?m)^[\s]*\d+\.\s+").unwrap(),
} }
} }
} }
@ -1609,11 +1614,8 @@ impl FeishuChannel {
let patterns = MdPatterns::new(); let patterns = MdPatterns::new();
let stripped = content.trim(); let stripped = content.trim();
// Complex markdown (code blocks, tables, headings) → always interactive card // Tables and headings are not supported by post `md` nodes, so use cards.
if patterns.table_re.is_match(stripped) if patterns.table_re.is_match(stripped) || patterns.heading_re.is_match(stripped) {
|| patterns.heading_re.is_match(stripped)
|| patterns.code_block_re.is_match(stripped)
{
return MsgFormat::Interactive; return MsgFormat::Interactive;
} }
@ -1622,21 +1624,15 @@ impl FeishuChannel {
return MsgFormat::Interactive; return MsgFormat::Interactive;
} }
// Has bold/italic/strikethrough → interactive card (post format can't render these) // Markdown that is supported by Feishu post `md` should be sent as post.
if patterns.bold_re.is_match(stripped) if patterns.bold_re.is_match(stripped)
|| patterns.italic_re.is_match(stripped) || patterns.italic_re.is_match(stripped)
|| patterns.strike_re.is_match(stripped) || patterns.strike_re.is_match(stripped)
|| patterns.code_block_re.is_match(stripped)
|| patterns.list_re.is_match(stripped)
|| patterns.olist_re.is_match(stripped)
|| patterns.link_re.is_match(stripped)
{ {
return MsgFormat::Interactive;
}
// Has list items → interactive card (post format can't render list bullets well)
if patterns.list_re.is_match(stripped) || patterns.olist_re.is_match(stripped) {
return MsgFormat::Interactive;
}
// Has links → post format (supports <a> tags)
if patterns.link_re.is_match(stripped) {
return MsgFormat::Post; return MsgFormat::Post;
} }
@ -1866,62 +1862,14 @@ impl FeishuChannel {
} }
/// Convert markdown content to Feishu post message JSON. /// Convert markdown content to Feishu post message JSON.
/// Handles links [text](url) as <a> tags; everything else as text tags. /// Feishu Markdown is sent via a post message with an `md` node.
fn markdown_to_post(content: &str) -> String { fn markdown_to_post(content: &str) -> String {
let patterns = MdPatterns::new();
let lines = content.trim().split('\n');
let mut paragraphs: Vec<Vec<serde_json::Value>> = Vec::new();
for line in lines {
let mut elements: Vec<serde_json::Value> = Vec::new();
let mut last_end = 0;
for cap in patterns.link_re.captures_iter(line) {
// Text before this link
let m = cap.get(0).unwrap();
let before = &line[last_end..m.start()];
if !before.is_empty() {
elements.push(serde_json::json!({
"tag": "text",
"text": before
}));
}
let link_text = cap.get(1).map(|g| g.as_str()).unwrap_or("");
let link_href = cap.get(2).map(|g| g.as_str()).unwrap_or("");
elements.push(serde_json::json!({
"tag": "a",
"text": link_text,
"href": link_href
}));
last_end = m.end();
}
// Remaining text after last link
let remaining = &line[last_end..];
if !remaining.is_empty() {
elements.push(serde_json::json!({
"tag": "text",
"text": remaining
}));
}
// Empty line → empty paragraph for spacing
if elements.is_empty() {
elements.push(serde_json::json!({
"tag": "text",
"text": ""
}));
}
paragraphs.push(elements);
}
let post_body = serde_json::json!({ let post_body = serde_json::json!({
"zh_cn": { "zh_cn": {
"content": paragraphs "content": [[{
"tag": "md",
"text": content.trim()
}]]
} }
}); });
@ -1972,6 +1920,33 @@ impl FeishuChannel {
} }
} }
#[cfg(test)]
mod tests {
use super::{FeishuChannel, MsgFormat};
#[test]
fn markdown_post_uses_md_tag() {
let content = "**bold**\n1. item1\n2. item2\n[link](https://open.feishu.cn)";
let post = FeishuChannel::markdown_to_post(content);
let parsed: serde_json::Value = serde_json::from_str(&post).unwrap();
assert_eq!(parsed["zh_cn"]["content"][0][0]["tag"], "md");
assert_eq!(parsed["zh_cn"]["content"][0][0]["text"], content);
}
#[test]
fn multiline_markdown_is_not_misclassified_as_plain_post() {
let content = "intro\n1. item1\n2. item2";
assert_eq!(FeishuChannel::detect_msg_format(content), MsgFormat::Post);
}
#[test]
fn headings_still_use_interactive() {
let content = "intro\n## heading";
assert_eq!(FeishuChannel::detect_msg_format(content), MsgFormat::Interactive);
}
}
#[async_trait] #[async_trait]
impl Channel for FeishuChannel { impl Channel for FeishuChannel {
fn name(&self) -> &str { fn name(&self) -> &str {