From 2bff987be1fe3da0b9a0a9cbcff9c2caed910018 Mon Sep 17 00:00:00 2001 From: xiaoski Date: Wed, 13 May 2026 18:07:34 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A3=9E=E4=B9=A6=E6=B8=A0=E9=81=93=E7=AE=80?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/channels/feishu.rs | 496 ++++++----------------------------------- 1 file changed, 66 insertions(+), 430 deletions(-) diff --git a/src/channels/feishu.rs b/src/channels/feishu.rs index 232186f..8a78f20 100644 --- a/src/channels/feishu.rs +++ b/src/channels/feishu.rs @@ -780,32 +780,11 @@ impl FeishuChannel { Some(format!("[Reply to: {}]", text)) } - /// Send a message to Feishu chat with specified message type + /// Send a message to Feishu chat with specified message type and content. + /// Content is passed as-is (already a JSON string for file/media, or plain text for fallback). async fn send_message_to_feishu(&self, receive_id: &str, receive_id_type: &str, msg_type: &str, content: &str) -> Result<(), ChannelError> { let token = self.get_tenant_access_token().await?; - // Feishu text messages have content limits (~64KB). - // Truncate if content is too long to avoid API error 230001. - const MAX_TEXT_LENGTH: usize = 60_000; - - let payload_content = if msg_type == "text" { - let truncated = if content.len() > MAX_TEXT_LENGTH { - format!("{}...\n\n[Content truncated due to length limit]", &content[..content.ceil_char_boundary(MAX_TEXT_LENGTH)]) - } else { - content.to_string() - }; - serde_json::json!({ "text": truncated }).to_string() - } else { - // For post messages, content is already JSON (from markdown_to_post) - // But we still need to check length - if content.len() > MAX_TEXT_LENGTH { - // Fallback to truncated text for post as well - serde_json::json!({ "text": format!("{}...\n\n[Content truncated due to length limit]", &content[..content.ceil_char_boundary(MAX_TEXT_LENGTH)]) }).to_string() - } else { - content.to_string() - } - }; - let resp = self.http_client .post(format!("{}/im/v1/messages?receive_id_type={}", FEISHU_API_BASE, receive_id_type)) .header("Content-Type", "application/json") @@ -813,7 +792,7 @@ impl FeishuChannel { .json(&serde_json::json!({ "receive_id": receive_id, "msg_type": msg_type, - "content": payload_content + "content": content })) .send() .await @@ -1564,67 +1543,6 @@ fn strip_at_placeholders(text: &str) -> String { result } -// ───────────────────────────────────────────────────────────────────────────── -// Markdown parsing and Feishu card element building -// ───────────────────────────────────────────────────────────────────────────── - -/// Message format types for Feishu -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MsgFormat { - /// Plain text, short and no markdown - Text, - /// Rich text (links only, moderate length) - Post, - /// Interactive card with full markdown rendering - Interactive, -} - -/// Regex patterns for markdown detection -struct MdPatterns { - /// Regex to match markdown tables (header + separator + data rows) - table_re: Regex, - /// Regex to match headings (# heading) - heading_re: Regex, - /// Regex to match code blocks (```...```) - code_block_re: Regex, - /// Regex to match bold **text** or __text__ - bold_re: Regex, - /// Regex to match italic *text* or _text_ - italic_re: Regex, - /// Regex to match strikethrough ~~text~~ - strike_re: Regex, - /// Regex to match markdown links [text](url) - link_re: Regex, - /// Regex to match unordered list items (- item or * item) - list_re: Regex, - /// Regex to match ordered list items (1. item) - olist_re: Regex, -} - -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(), - 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) - // We use a conservative approach: detect potential italic markers - 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(), - } - } -} - -impl Default for MdPatterns { - fn default() -> Self { - Self::new() - } -} - impl FeishuChannel { fn strip_thinking_tags(content: &str) -> String { use std::sync::LazyLock; @@ -1635,328 +1553,69 @@ impl FeishuChannel { stripped.trim().to_string() } - /// Determine the optimal Feishu message format for content. - fn detect_msg_format(content: &str) -> MsgFormat { - 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) - { - return MsgFormat::Interactive; - } - - // Long content → interactive card (better readability) - if stripped.len() > 2000 { - return MsgFormat::Interactive; - } - - // Has bold/italic/strikethrough → interactive card (post format can't render these) - if patterns.bold_re.is_match(stripped) - || patterns.italic_re.is_match(stripped) - || patterns.strike_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; - } - - // Short plain text → text format - if stripped.len() <= 200 { - return MsgFormat::Text; - } - - // Medium plain text without formatting → post format - MsgFormat::Post - } - - /// Strip markdown formatting markers from text for plain display. - fn strip_md_formatting(text: &str) -> String { - let patterns = MdPatterns::new(); - let mut result = text.to_string(); - - // Remove bold markers - result = patterns.bold_re.replace_all(&result, "$1$2").to_string(); - // Remove italic markers - result = patterns.italic_re.replace_all(&result, "$1$2").to_string(); - // Remove strikethrough markers - result = patterns.strike_re.replace_all(&result, "$1").to_string(); - - result - } - - /// Parse a markdown table into a Feishu table element. - fn parse_md_table(table_text: &str) -> Option { - let lines: Vec<&str> = table_text - .trim() - .split('\n') - .map(|l| l.trim()) - .filter(|l| !l.is_empty()) - .collect(); - - if lines.len() < 3 { - return None; - } - - fn split(line: &str) -> Vec { - line.trim_start_matches('|') - .trim_end_matches('|') - .split('|') - .map(|c| c.trim().to_string()) - .collect() - } - - let headers = split(lines[0]); - let rows: Vec> = lines[2..] - .iter() - .map(|line| { - let cells: Vec = split(line); - headers - .iter() - .enumerate() - .map(|(i, _h)| { - ( - format!("c{}", i), - if i < cells.len() { - Self::strip_md_formatting(&cells[i]) - } else { - String::new() - }, - ) - }) - .collect() - }) - .collect(); - - let columns: Vec = headers - .iter() - .enumerate() - .map(|(i, h)| { - serde_json::json!({ - "tag": "column", - "name": format!("c{}", i), - "display_name": Self::strip_md_formatting(h), - "width": "auto" - }) - }) - .collect(); - - Some(serde_json::json!({ - "tag": "table", - "page_size": rows.len() + 1, - "columns": columns, - "rows": rows, - })) - } - - /// Split content by headings, converting headings to div elements. - fn split_headings(content: &str) -> Vec { - let patterns = MdPatterns::new(); - let mut protected = content.to_string(); - - // Protect code blocks by replacing them with placeholders - let mut code_blocks: Vec = Vec::new(); - for m in patterns.code_block_re.find_iter(content) { - code_blocks.push(m.as_str().to_string()); - protected = protected.replace(m.as_str(), &format!("\x00CODE{}\x00", code_blocks.len() - 1)); - } - - let mut elements: Vec = Vec::new(); - let mut last_end = 0; - - for m in patterns.heading_re.find_iter(&protected) { - let before = &protected[last_end..m.start()].trim(); - if !before.is_empty() { - elements.push(serde_json::json!({ + /// Build a Card JSON 2.0 interactive card with a single markdown element. + fn build_card_content(markdown: &str) -> String { + serde_json::json!({ + "schema": "2.0", + "body": { + "elements": [{ "tag": "markdown", - "content": before - })); + "content": markdown + }] + } + }) + .to_string() + } + + /// Max byte-size for markdown content in a single card. + /// Card payloads have a ~30 KB limit; leave margin for JSON envelope. + const CARD_MARKDOWN_MAX_BYTES: usize = 28_000; + + /// Split markdown content into chunks that fit within the card size limit. + /// Splits on line boundaries to avoid breaking markdown syntax. + fn split_markdown_chunks(text: &str) -> Vec { + if text.len() <= Self::CARD_MARKDOWN_MAX_BYTES { + return vec![text.to_string()]; + } + + let mut chunks: Vec = Vec::new(); + let mut start = 0; + + while start < text.len() { + if start + Self::CARD_MARKDOWN_MAX_BYTES >= text.len() { + chunks.push(text[start..].to_string()); + break; } - let heading_text = Self::strip_md_formatting(m.as_str().trim_start_matches('#').trim()); - let display_text = if heading_text.is_empty() { - String::new() + let end = start + Self::CARD_MARKDOWN_MAX_BYTES; + let search_region = &text[start..end]; + let split_at = search_region + .rfind('\n') + .map(|pos| start + pos + 1) + .unwrap_or(end); + + let split_at = if text.is_char_boundary(split_at) { + split_at } else { - format!("**{}**", heading_text) + (start..split_at) + .rev() + .find(|&i| text.is_char_boundary(i)) + .unwrap_or(start) }; - elements.push(serde_json::json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": display_text - } - })); - - last_end = m.end(); - } - - let remaining = protected[last_end..].trim(); - if !remaining.is_empty() { - // Restore code blocks - let mut final_content = remaining.to_string(); - for (i, cb) in code_blocks.iter().enumerate() { - final_content = final_content.replace(&format!("\x00CODE{}\x00", i), cb); - } - elements.push(serde_json::json!({ - "tag": "markdown", - "content": final_content - })); - } - - if elements.is_empty() { - elements.push(serde_json::json!({ - "tag": "markdown", - "content": content - })); - } - - elements - } - - /// Split card elements into groups with at most one table element each. - /// Feishu cards have a hard limit of one table per card (API error 11310). - fn split_elements_by_table_limit(elements: &[serde_json::Value]) -> Vec> { - if elements.is_empty() { - return vec![vec![]]; - } - - let mut groups: Vec> = Vec::new(); - let mut current: Vec = Vec::new(); - let mut table_count = 0; - - for el in elements { - if el.get("tag").and_then(|t| t.as_str()) == Some("table") { - if table_count >= 1 { - groups.push(current); - current = Vec::new(); - table_count = 0; - } - current.push(el.clone()); - table_count += 1; + if split_at <= start { + let forced = (end..=text.len()) + .find(|&i| text.is_char_boundary(i)) + .unwrap_or(text.len()); + chunks.push(text[start..forced].to_string()); + start = forced; } else { - current.push(el.clone()); + chunks.push(text[start..split_at].to_string()); + start = split_at; } } - if !current.is_empty() { - groups.push(current); - } - - groups - } - - /// Build content into card elements (div/markdown + table). - fn build_card_elements(content: &str) -> Vec { - let patterns = MdPatterns::new(); - let mut elements: Vec = Vec::new(); - let mut last_end = 0; - - // Find all tables in content - for m in patterns.table_re.find_iter(content) { - let before = &content[last_end..m.start()]; - if !before.trim().is_empty() { - elements.extend(Self::split_headings(before)); - } - - if let Some(table) = Self::parse_md_table(m.as_str()) { - elements.push(table); - } else { - elements.push(serde_json::json!({ - "tag": "markdown", - "content": m.as_str() - })); - } - - last_end = m.end(); - } - - let remaining = &content[last_end..]; - if !remaining.trim().is_empty() { - elements.extend(Self::split_headings(remaining)); - } - - if elements.is_empty() { - elements.push(serde_json::json!({ - "tag": "markdown", - "content": content - })); - } - - elements - } - - /// Convert markdown content to Feishu post message JSON. - /// Handles links [text](url) as tags; everything else as text tags. - 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 - } - }); - - post_body.to_string() + chunks } /// Send an interactive card message to Feishu. @@ -2097,7 +1756,7 @@ impl Channel for FeishuChannel { let receive_id = if msg.chat_id.starts_with("oc_") { &msg.chat_id } else { msg.reply_to.as_ref().unwrap_or(&msg.chat_id) }; let receive_id_type = if msg.chat_id.starts_with("oc_") { "chat_id" } else { "open_id" }; - // If no media, use smart format detection + // If no media, send as interactive card with raw markdown if msg.media.is_empty() { let content = msg.content.trim(); @@ -2107,42 +1766,19 @@ impl Channel for FeishuChannel { return Ok(()); } - let fmt = Self::detect_msg_format(content); - - match fmt { - MsgFormat::Text => { - // Short plain text – send as simple text message - let result = self.send_message_to_feishu(receive_id, receive_id_type, "text", content).await; + let chunks = Self::split_markdown_chunks(content); + for chunk in &chunks { + let card = Self::build_card_content(chunk); + if let Err(e) = self.send_interactive_card(receive_id, receive_id_type, &card).await { + tracing::warn!(error = %e, "Failed to send interactive card, falling back to text"); + let text_content = serde_json::json!({ "text": chunk }).to_string(); + let result = self.send_message_to_feishu(receive_id, receive_id_type, "text", &text_content).await; self.remove_reaction_from_metadata(&msg.metadata).await; return result; } - MsgFormat::Post => { - // Medium content with links – send as rich-text post - let post_body = Self::markdown_to_post(content); - let result = self.send_message_to_feishu(receive_id, receive_id_type, "post", &post_body).await; - self.remove_reaction_from_metadata(&msg.metadata).await; - return result; - } - MsgFormat::Interactive => { - // Complex / long content – send as interactive card - let elements = Self::build_card_elements(content); - for chunk in Self::split_elements_by_table_limit(&elements) { - let card = serde_json::json!({ - "config": { "wide_screen_mode": true }, - "elements": chunk - }); - if let Err(e) = self.send_interactive_card(receive_id, receive_id_type, &card.to_string()).await { - tracing::warn!(error = %e, "Failed to send interactive card, falling back to text"); - // Fallback to plain text - let result = self.send_message_to_feishu(receive_id, receive_id_type, "text", content).await; - self.remove_reaction_from_metadata(&msg.metadata).await; - return result; - } - } - self.remove_reaction_from_metadata(&msg.metadata).await; - return Ok(()); - } } + self.remove_reaction_from_metadata(&msg.metadata).await; + return Ok(()); } // Handle multimodal message - send with media