From 3d72f3dfa88fb979fecf4db9512c2dd6ab812e1f Mon Sep 17 00:00:00 2001 From: xiaoxixi Date: Sun, 12 Apr 2026 11:38:31 +0800 Subject: [PATCH] feat(feishu): enhance message sending with dynamic format detection and support for interactive cards --- src/channels/feishu.rs | 913 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 868 insertions(+), 45 deletions(-) diff --git a/src/channels/feishu.rs b/src/channels/feishu.rs index 5835dbe..3a0048c 100644 --- a/src/channels/feishu.rs +++ b/src/channels/feishu.rs @@ -6,6 +6,7 @@ use std::time::{Duration, Instant}; use async_trait::async_trait; use futures_util::{SinkExt, StreamExt}; use prost::{Message as ProstMessage, bytes::Bytes}; +use regex::Regex; use serde::Deserialize; use tokio::sync::{broadcast, RwLock}; @@ -23,8 +24,6 @@ const WS_HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(300); const TOKEN_REFRESH_SKEW: Duration = Duration::from_secs(120); /// Default tenant token TTL when `expire`/`expires_in` is absent. const DEFAULT_TOKEN_TTL: Duration = Duration::from_secs(7200); -/// Feishu API business code for expired/invalid tenant access token. -const INVALID_ACCESS_TOKEN_CODE: i32 = 99991663; /// Dedup cache TTL (30 minutes). const DEDUP_CACHE_TTL: Duration = Duration::from_secs(30 * 60); @@ -246,12 +245,6 @@ impl FeishuChannel { Ok(token) } - /// Invalidate cached token (called when API reports expired tenant token). - async fn invalidate_token(&self) { - let mut cached = self.tenant_token.write().await; - *cached = None; - } - /// Fetch a new tenant access token from Feishu. async fn fetch_new_token(&self) -> Result<(String, Duration), ChannelError> { let resp = self.http_client @@ -662,20 +655,31 @@ impl FeishuChannel { Ok(()) } - /// Send a text message to Feishu chat (implements Channel trait) - async fn send_message_to_feishu(&self, receive_id: &str, receive_id_type: &str, content: &str) -> Result<(), ChannelError> { + /// Send a message to Feishu chat with specified message type + 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 truncated = if content.len() > MAX_TEXT_LENGTH { - format!("{}...\n\n[Content truncated due to length limit]", &content[..MAX_TEXT_LENGTH]) - } else { - content.to_string() - }; - let text_content = serde_json::json!({ "text": truncated }).to_string(); + 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[..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[..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)) @@ -683,8 +687,8 @@ impl FeishuChannel { .header("Authorization", format!("Bearer {}", token)) .json(&serde_json::json!({ "receive_id": receive_id, - "msg_type": "text", - "content": text_content + "msg_type": msg_type, + "content": payload_content })) .send() .await @@ -803,28 +807,83 @@ impl FeishuChannel { content: &str, message_id: &str, ) -> Result<(String, Option), ChannelError> { - match msg_type { + let (text, media) = match msg_type { "text" => { let text = if let Ok(parsed) = serde_json::from_str::(content) { parsed.get("text").and_then(|v| v.as_str()).unwrap_or(content).to_string() } else { content.to_string() }; - Ok((text, None)) + (text, None) } "post" => { - let text = parse_post_content(content); - Ok((text, None)) + (parse_post_content(content), None) } "image" | "audio" | "file" | "media" => { if let Ok(content_json) = serde_json::from_str::(content) { - self.download_media(msg_type, &content_json, message_id).await + match self.download_media(msg_type, &content_json, message_id).await { + Ok((text, media)) => (text, media), + Err(_) => (format!("[{}: content unavailable]", msg_type), None), + } } else { - Ok((format!("[{}: content unavailable]", msg_type), None)) + (format!("[{}: content unavailable]", msg_type), None) } } - _ => Ok((content.to_string(), None)), - } + "share_chat" => { + // Shared chat/cannel messages + if let Ok(parsed) = serde_json::from_str::(content) { + let chat_id = parsed.get("chat_id").and_then(|v| v.as_str()).unwrap_or("unknown"); + (format!("[shared chat: {}]", chat_id), None) + } else { + ("[shared chat]".to_string(), None) + } + } + "share_user" => { + // Shared user messages + if let Ok(parsed) = serde_json::from_str::(content) { + let user_id = parsed.get("user_id").and_then(|v| v.as_str()).unwrap_or("unknown"); + (format!("[shared user: {}]", user_id), None) + } else { + ("[shared user]".to_string(), None) + } + } + "interactive" => { + // Interactive card messages - extract text content + match extract_interactive_content(content) { + Ok((text, media)) => (text, media), + Err(e) => { + tracing::warn!(error = %e, "Failed to extract interactive content"); + (content.to_string(), None) + } + } + } + "list" => { + // List/bullet messages + match parse_list_content(content) { + Ok((text, media)) => (text, media), + Err(_) => (content.to_string(), None), + } + } + "merge_forward" => { + ("[merged forward messages]".to_string(), None) + } + "share_calendar_event" => { + if let Ok(parsed) = serde_json::from_str::(content) { + let event_key = parsed.get("event_key").and_then(|v| v.as_str()).unwrap_or("unknown"); + (format!("[shared calendar event: {}]", event_key), None) + } else { + ("[shared calendar event]".to_string(), None) + } + } + "system" => { + ("[system message]".to_string(), None) + } + _ => (content.to_string(), None), + }; + + // Strip @_user_N placeholders from group chat @mentions + let clean_text = strip_at_placeholders(&text); + Ok((clean_text, media)) } /// Send acknowledgment for a message @@ -1023,28 +1082,752 @@ impl FeishuChannel { fn parse_post_content(content: &str) -> String { if let Ok(parsed) = serde_json::from_str::(content) { let mut texts = vec![]; - if let Some(post) = parsed.get("post") { - if let Some(content_arr) = post.get("content") { - if let Some(arr) = content_arr.as_array() { - for item in arr { - if let Some(arr2) = item.as_array() { - for inner in arr2 { - if let Some(text) = inner.get("text").and_then(|v| v.as_str()) { - texts.push(text.to_string()); + // Try localized format first: {"zh_cn": {"title": ..., "content": ...}} + // or direct format: {"post": {"zh_cn": {...}}} + let locale = parsed + .get("zh_cn") + .or_else(|| parsed.get("en_us")) + .or_else(|| parsed.get("post")) + .or_else(|| { + // Direct post without locale + parsed.get("post").and_then(|p| p.get("zh_cn")) + }); + + if let Some(locale_data) = locale { + // Extract title if present + if let Some(title) = locale_data.get("title").and_then(|t| t.as_str()) { + if !title.is_empty() { + texts.push(title.to_string()); + texts.push("\n\n".to_string()); + } + } + + // Extract content + if let Some(content_arr) = locale_data.get("content").and_then(|c| c.as_array()) { + for item in content_arr { + if let Some(arr2) = item.as_array() { + for inner in arr2 { + match inner.get("tag").and_then(|t| t.as_str()).unwrap_or("") { + "text" => { + if let Some(text) = inner.get("text").and_then(|v| v.as_str()) { + texts.push(text.to_string()); + } + } + "a" => { + // Links: extract text or href + let link_text = inner.get("text") + .and_then(|t| t.as_str()) + .filter(|s| !s.is_empty()) + .or_else(|| inner.get("href").and_then(|h| h.as_str())) + .unwrap_or(""); + texts.push(link_text.to_string()); + } + "at" => { + // @mentions + let name = inner.get("user_name") + .and_then(|n| n.as_str()) + .or_else(|| inner.get("user_id").and_then(|i| i.as_str())) + .unwrap_or("user"); + texts.push(format!("@{}", name)); + } + _ => { + // Other tags that may have text content + if let Some(text) = inner.get("text").and_then(|v| v.as_str()) { + texts.push(text.to_string()); + } } } } + texts.push("\n".to_string()); + } + } + } + } + + let result = texts.join(""); + if result.trim().is_empty() { + content.to_string() + } else { + result.trim().to_string() + } + } else { + content.to_string() + } +} + +/// Extract text content from interactive card messages +fn extract_interactive_content(content: &str) -> Result<(String, Option), ChannelError> { + let parsed = match serde_json::from_str::(content) { + Ok(p) => p, + Err(_) => return Ok((content.to_string(), None)), + }; + + let mut texts = Vec::new(); + + // Extract from elements array + if let Some(elements) = parsed.get("elements").and_then(|e| e.as_array()) { + for el in elements { + extract_element_content(el, &mut texts); + } + } + + // Extract from card object + if let Some(card) = parsed.get("card").and_then(|c| c.as_object()) { + if let Some(elements) = card.get("elements").and_then(|e| e.as_array()) { + for el in elements { + extract_element_content(el, &mut texts); + } + } + } + + // Extract from header + if let Some(header) = parsed.get("header").and_then(|h| h.as_object()) { + if let Some(title) = header.get("title").and_then(|t| t.as_object()) { + if let Some(text) = title.get("content").and_then(|c| c.as_str()) { + texts.push(format!("title: {}\n", text)); + } + } + } + + let result = texts.join("").trim().to_string(); + if result.is_empty() { + Ok((content.to_string(), None)) + } else { + Ok((result, None)) + } +} + +/// Extract content from a single card element +fn extract_element_content(element: &serde_json::Value, texts: &mut Vec) { + let tag = element.get("tag").and_then(|t| t.as_str()).unwrap_or(""); + + match tag { + "markdown" | "lark_md" => { + if let Some(content) = element.get("content").and_then(|c| c.as_str()) { + texts.push(content.to_string()); + texts.push("\n".to_string()); + } + } + "div" => { + if let Some(text_obj) = element.get("text").and_then(|t| t.as_object()) { + let content = text_obj.get("content") + .and_then(|c| c.as_str()) + .unwrap_or(""); + texts.push(content.to_string()); + } else if let Some(content) = element.get("text").and_then(|t| t.as_str()) { + texts.push(content.to_string()); + } + texts.push("\n".to_string()); + } + "a" => { + let href = element.get("href") + .and_then(|h| h.as_str()) + .unwrap_or(""); + let text = element.get("text") + .and_then(|t| t.as_str()) + .unwrap_or(""); + if !text.is_empty() { + texts.push(text.to_string()); + } else if !href.is_empty() { + texts.push(format!("link: {}", href)); + } + } + "img" => { + let alt = element.get("alt"); + let alt_text = alt.and_then(|a| a.as_str()) + .or_else(|| alt.and_then(|a| a.as_object()).and_then(|o| o.get("content")).and_then(|c| c.as_str())) + .unwrap_or("[image]"); + texts.push(format!("{}\n", alt_text)); + } + "note" => { + if let Some(elements) = element.get("elements").and_then(|e| e.as_array()) { + for el in elements { + extract_element_content(el, texts); + } + } + } + "column_set" => { + if let Some(columns) = element.get("columns").and_then(|c| c.as_array()) { + for col in columns { + if let Some(elements) = col.get("elements").and_then(|e| e.as_array()) { + for el in elements { + extract_element_content(el, texts); + } } } } } - if texts.is_empty() { - content.to_string() - } else { - texts.join("") + "table" => { + // Tables are complex, just indicate presence + texts.push("[table]\n".to_string()); } + _ => { + // Recursively check for nested elements + if let Some(elements) = element.get("elements").and_then(|e| e.as_array()) { + for el in elements { + extract_element_content(el, texts); + } + } + } + } +} + +/// Parse Feishu list/bullet message content into plain text +fn parse_list_content(content: &str) -> Result<(String, Option), ChannelError> { + let parsed = match serde_json::from_str::(content) { + Ok(p) => p, + Err(_) => return Ok((content.to_string(), None)), + }; + + let items = parsed.get("items") + .and_then(|i| i.as_array()) + .or_else(|| parsed.get("content").and_then(|c| c.as_array())); + + let Some(items) = items else { + return Ok((content.to_string(), None)); + }; + + let mut lines = Vec::new(); + collect_list_items(items, &mut lines, 0); + + let result = lines.join("\n").trim().to_string(); + if result.is_empty() { + Ok((content.to_string(), None)) } else { - content.to_string() + Ok((result, None)) + } +} + +/// Recursively collect list item text with indentation +fn collect_list_items(items: &[serde_json::Value], lines: &mut Vec, depth: usize) { + let indent = " ".repeat(depth); + + for item in items { + // Items can be arrays of inline elements or objects with content/children + let inline_elements = if let Some(arr) = item.as_array() { + arr.as_slice() + } else if let Some(obj) = item.as_object() { + obj.get("content") + .and_then(|c| c.as_array()) + .map(|a| a.as_slice()) + .unwrap_or(&[]) + } else { + continue; + }; + + let mut text = String::new(); + for el in inline_elements { + extract_inline_text(el, &mut text); + } + + let trimmed = text.trim(); + if !trimmed.is_empty() { + lines.push(format!("{}- {}", indent, trimmed)); + } + + // Handle nested children + if let Some(obj) = item.as_object() { + if let Some(children) = obj.get("children").and_then(|c| c.as_array()) { + collect_list_items(children, lines, depth + 1); + } + } else if let Some(children_arr) = item.as_array().and_then(|arr| { + arr.iter().find_map(|child| { + if child.as_object().and_then(|o| o.get("children")).is_some() { + Some(child) + } else { + None + } + }) + }) { + if let Some(children) = children_arr.as_object().and_then(|o| o.get("children")).and_then(|c| c.as_array()) { + collect_list_items(children, lines, depth + 1); + } + } + } +} + +/// Extract text from inline elements (text, link, at-mention) +fn extract_inline_text(el: &serde_json::Value, out: &mut String) { + match el.get("tag").and_then(|t| t.as_str()).unwrap_or("") { + "text" => { + if let Some(text) = el.get("text").and_then(|t| t.as_str()) { + out.push_str(text); + } + } + "a" => { + let text = el.get("text") + .and_then(|t| t.as_str()) + .filter(|s| !s.is_empty()) + .or_else(|| el.get("href").and_then(|h| h.as_str())) + .unwrap_or(""); + out.push_str(text); + } + "at" => { + let name = el.get("user_name") + .and_then(|n| n.as_str()) + .or_else(|| el.get("user_id").and_then(|i| i.as_str())) + .unwrap_or("user"); + out.push_str(&format!("@{}", name)); + } + _ => {} + } +} + +/// Remove @_user_N placeholder tokens injected by Feishu in group chats +fn strip_at_placeholders(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '@' { + let rest: String = chars.clone().collect(); + if let Some(after) = rest.strip_prefix("_user_") { + // Skip until we hit a non-alphanumeric character + let placeholder_len = after.find(|c: char| !c.is_alphanumeric()).unwrap_or(after.len()); + // Skip the placeholder + for _ in 0..placeholder_len { + chars.next(); + } + // Also skip the underscore after user_N if present + if chars.peek() == Some(&'_') { + chars.next(); + } + continue; + } + } + result.push(ch); + } + + 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 { + /// 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!({ + "tag": "markdown", + "content": before + })); + } + + let heading_text = Self::strip_md_formatting(m.as_str().trim_start_matches('#').trim()); + let display_text = if heading_text.is_empty() { + String::new() + } else { + format!("**{}**", heading_text) + }; + + 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; + } else { + current.push(el.clone()); + } + } + + 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() + } + + /// Send an interactive card message to Feishu. + async fn send_interactive_card( + &self, + receive_id: &str, + receive_id_type: &str, + card_content: &str, + ) -> Result<(), ChannelError> { + let token = self.get_tenant_access_token().await?; + + let resp = self.http_client + .post(format!("{}/im/v1/messages?receive_id_type={}", FEISHU_API_BASE, receive_id_type)) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", token)) + .json(&serde_json::json!({ + "receive_id": receive_id, + "msg_type": "interactive", + "content": card_content + })) + .send() + .await + .map_err(|e| ChannelError::ConnectionError(format!("Send interactive card HTTP error: {}", e)))?; + + #[derive(Deserialize)] + struct SendResp { + code: i32, + msg: String, + } + + let send_resp: SendResp = resp + .json() + .await + .map_err(|e| ChannelError::Other(format!("Parse send interactive card response error: {}", e)))?; + + if send_resp.code != 0 { + return Err(ChannelError::Other(format!( + "Send interactive card failed: code={} msg={}", + send_resp.code, send_resp.msg + ))); + } + + Ok(()) } } @@ -1126,12 +1909,52 @@ 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, send text only + // If no media, use smart format detection if msg.media.is_empty() { - let result = self.send_message_to_feishu(receive_id, receive_id_type, &msg.content).await; - // Remove pending reaction after sending (using metadata propagated from inbound) - self.remove_reaction_from_metadata(&msg.metadata).await; - return result; + let content = msg.content.trim(); + + // Empty content + if content.is_empty() { + self.remove_reaction_from_metadata(&msg.metadata).await; + 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; + 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(()); + } + } } // Handle multimodal message - send with media @@ -1192,7 +2015,7 @@ impl Channel for FeishuChannel { // If no content parts after processing, just send empty text if content_parts.is_empty() { - let result = self.send_message_to_feishu(receive_id, receive_id_type, "").await; + let result = self.send_message_to_feishu(receive_id, receive_id_type, "text", "").await; // Remove pending reaction after sending (using metadata propagated from inbound) self.remove_reaction_from_metadata(&msg.metadata).await; return result;