diff --git a/src/channels/feishu.rs b/src/channels/feishu.rs index ed6c4a2..c3c1398 100644 --- a/src/channels/feishu.rs +++ b/src/channels/feishu.rs @@ -1189,6 +1189,11 @@ fn parse_post_content(content: &str) -> String { /// Extract text from a single post element (text, link, at-mention). fn extract_element(el: &serde_json::Value, out: &mut Vec) { 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" => { if let Some(text) = el.get("text").and_then(|t| t.as_str()) { out.push(text.to_string()); @@ -1582,8 +1587,8 @@ struct MdPatterns { impl MdPatterns { fn new() -> Self { Self { - table_re: Regex::new(r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)").unwrap(), - heading_re: Regex::new(r"^(#{1,6})\s+(.+)$").unwrap(), + table_re: Regex::new(r"(?m)((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)").unwrap(), + heading_re: Regex::new(r"(?m)^(#{1,6})\s+(.+)$").unwrap(), code_block_re: Regex::new(r"(```[\s\S]*?```)").unwrap(), bold_re: Regex::new(r"\*\*(.+?)\*\*|__(.+?)__").unwrap(), // 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(), strike_re: Regex::new(r"~~.+?~~").unwrap(), link_re: Regex::new(r"\[([^\]]+)\]\((https?://[^\)]+)\)").unwrap(), - list_re: Regex::new(r"^[\s]*[-*+]\s+").unwrap(), - olist_re: Regex::new(r"^[\s]*\d+\.\s+").unwrap(), + list_re: Regex::new(r"(?m)^[\s]*[-*+]\s+").unwrap(), + olist_re: Regex::new(r"(?m)^[\s]*\d+\.\s+").unwrap(), } } } @@ -1609,11 +1614,8 @@ impl FeishuChannel { let patterns = MdPatterns::new(); let stripped = content.trim(); - // Complex markdown (code blocks, tables, headings) → always interactive card - if patterns.table_re.is_match(stripped) - || patterns.heading_re.is_match(stripped) - || patterns.code_block_re.is_match(stripped) - { + // Tables and headings are not supported by post `md` nodes, so use cards. + if patterns.table_re.is_match(stripped) || patterns.heading_re.is_match(stripped) { return MsgFormat::Interactive; } @@ -1622,21 +1624,15 @@ impl FeishuChannel { 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) || patterns.italic_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 tags) - if patterns.link_re.is_match(stripped) { return MsgFormat::Post; } @@ -1866,62 +1862,14 @@ impl FeishuChannel { } /// Convert markdown content to Feishu post message JSON. - /// Handles links [text](url) as 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 { - let patterns = MdPatterns::new(); - let lines = content.trim().split('\n'); - let mut paragraphs: Vec> = Vec::new(); - - for line in lines { - let mut elements: Vec = 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!({ "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] impl Channel for FeishuChannel { fn name(&self) -> &str {