feat(feishu): enhance message sending with dynamic format detection and support for interactive cards

This commit is contained in:
xiaoxixi 2026-04-12 11:38:31 +08:00
parent 394b5fdd6a
commit 3d72f3dfa8

View File

@ -6,6 +6,7 @@ use std::time::{Duration, Instant};
use async_trait::async_trait; use async_trait::async_trait;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use prost::{Message as ProstMessage, bytes::Bytes}; use prost::{Message as ProstMessage, bytes::Bytes};
use regex::Regex;
use serde::Deserialize; use serde::Deserialize;
use tokio::sync::{broadcast, RwLock}; 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); const TOKEN_REFRESH_SKEW: Duration = Duration::from_secs(120);
/// Default tenant token TTL when `expire`/`expires_in` is absent. /// Default tenant token TTL when `expire`/`expires_in` is absent.
const DEFAULT_TOKEN_TTL: Duration = Duration::from_secs(7200); 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). /// Dedup cache TTL (30 minutes).
const DEDUP_CACHE_TTL: Duration = Duration::from_secs(30 * 60); const DEDUP_CACHE_TTL: Duration = Duration::from_secs(30 * 60);
@ -246,12 +245,6 @@ impl FeishuChannel {
Ok(token) 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. /// Fetch a new tenant access token from Feishu.
async fn fetch_new_token(&self) -> Result<(String, Duration), ChannelError> { async fn fetch_new_token(&self) -> Result<(String, Duration), ChannelError> {
let resp = self.http_client let resp = self.http_client
@ -662,20 +655,31 @@ impl FeishuChannel {
Ok(()) Ok(())
} }
/// Send a text message to Feishu chat (implements Channel trait) /// Send a message to Feishu chat with specified message type
async fn send_message_to_feishu(&self, receive_id: &str, receive_id_type: &str, content: &str) -> Result<(), ChannelError> { 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?; let token = self.get_tenant_access_token().await?;
// Feishu text messages have content limits (~64KB). // Feishu text messages have content limits (~64KB).
// Truncate if content is too long to avoid API error 230001. // Truncate if content is too long to avoid API error 230001.
const MAX_TEXT_LENGTH: usize = 60_000; const MAX_TEXT_LENGTH: usize = 60_000;
let payload_content = if msg_type == "text" {
let truncated = if content.len() > MAX_TEXT_LENGTH { let truncated = if content.len() > MAX_TEXT_LENGTH {
format!("{}...\n\n[Content truncated due to length limit]", &content[..MAX_TEXT_LENGTH]) format!("{}...\n\n[Content truncated due to length limit]", &content[..MAX_TEXT_LENGTH])
} else { } else {
content.to_string() content.to_string()
}; };
serde_json::json!({ "text": truncated }).to_string()
let text_content = 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 let resp = self.http_client
.post(format!("{}/im/v1/messages?receive_id_type={}", FEISHU_API_BASE, receive_id_type)) .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)) .header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ .json(&serde_json::json!({
"receive_id": receive_id, "receive_id": receive_id,
"msg_type": "text", "msg_type": msg_type,
"content": text_content "content": payload_content
})) }))
.send() .send()
.await .await
@ -803,29 +807,84 @@ impl FeishuChannel {
content: &str, content: &str,
message_id: &str, message_id: &str,
) -> Result<(String, Option<MediaItem>), ChannelError> { ) -> Result<(String, Option<MediaItem>), ChannelError> {
match msg_type { let (text, media) = match msg_type {
"text" => { "text" => {
let text = if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) { let text = if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) {
parsed.get("text").and_then(|v| v.as_str()).unwrap_or(content).to_string() parsed.get("text").and_then(|v| v.as_str()).unwrap_or(content).to_string()
} else { } else {
content.to_string() content.to_string()
}; };
Ok((text, None)) (text, None)
} }
"post" => { "post" => {
let text = parse_post_content(content); (parse_post_content(content), None)
Ok((text, None))
} }
"image" | "audio" | "file" | "media" => { "image" | "audio" | "file" | "media" => {
if let Ok(content_json) = serde_json::from_str::<serde_json::Value>(content) { if let Ok(content_json) = serde_json::from_str::<serde_json::Value>(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 { } 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::<serde_json::Value>(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::<serde_json::Value>(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::<serde_json::Value>(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 /// Send acknowledgment for a message
async fn send_ack(frame: &PbFrame, write: &mut futures_util::stream::SplitSink<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>, tokio_tungstenite::tungstenite::Message>) -> Result<(), ChannelError> { async fn send_ack(frame: &PbFrame, write: &mut futures_util::stream::SplitSink<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>, tokio_tungstenite::tungstenite::Message>) -> Result<(), ChannelError> {
@ -1023,31 +1082,755 @@ impl FeishuChannel {
fn parse_post_content(content: &str) -> String { fn parse_post_content(content: &str) -> String {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) { if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) {
let mut texts = vec![]; let mut texts = vec![];
if let Some(post) = parsed.get("post") { // Try localized format first: {"zh_cn": {"title": ..., "content": ...}}
if let Some(content_arr) = post.get("content") { // or direct format: {"post": {"zh_cn": {...}}}
if let Some(arr) = content_arr.as_array() { let locale = parsed
for item in arr { .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() { if let Some(arr2) = item.as_array() {
for inner in arr2 { 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()) { if let Some(text) = inner.get("text").and_then(|v| v.as_str()) {
texts.push(text.to_string()); texts.push(text.to_string());
} }
} }
} }
} }
texts.push("\n".to_string());
} }
} }
} }
if texts.is_empty() { }
let result = texts.join("");
if result.trim().is_empty() {
content.to_string() content.to_string()
} else { } else {
texts.join("") result.trim().to_string()
} }
} else { } else {
content.to_string() content.to_string()
} }
} }
/// Extract text content from interactive card messages
fn extract_interactive_content(content: &str) -> Result<(String, Option<MediaItem>), ChannelError> {
let parsed = match serde_json::from_str::<serde_json::Value>(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<String>) {
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);
}
}
}
}
}
"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<MediaItem>), ChannelError> {
let parsed = match serde_json::from_str::<serde_json::Value>(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 {
Ok((result, None))
}
}
/// Recursively collect list item text with indentation
fn collect_list_items(items: &[serde_json::Value], lines: &mut Vec<String>, 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 <a> 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<serde_json::Value> {
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<String> {
line.trim_start_matches('|')
.trim_end_matches('|')
.split('|')
.map(|c| c.trim().to_string())
.collect()
}
let headers = split(lines[0]);
let rows: Vec<std::collections::HashMap<String, String>> = lines[2..]
.iter()
.map(|line| {
let cells: Vec<String> = 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<serde_json::Value> = 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<serde_json::Value> {
let patterns = MdPatterns::new();
let mut protected = content.to_string();
// Protect code blocks by replacing them with placeholders
let mut code_blocks: Vec<String> = 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<serde_json::Value> = 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<Vec<serde_json::Value>> {
if elements.is_empty() {
return vec![vec![]];
}
let mut groups: Vec<Vec<serde_json::Value>> = Vec::new();
let mut current: Vec<serde_json::Value> = 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<serde_json::Value> {
let patterns = MdPatterns::new();
let mut elements: Vec<serde_json::Value> = 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 <a> 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<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!({
"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(())
}
}
#[async_trait] #[async_trait]
impl Channel for FeishuChannel { impl Channel for FeishuChannel {
fn name(&self) -> &str { fn name(&self) -> &str {
@ -1126,13 +1909,53 @@ 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 = 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" }; 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() { if msg.media.is_empty() {
let result = self.send_message_to_feishu(receive_id, receive_id_type, &msg.content).await; let content = msg.content.trim();
// Remove pending reaction after sending (using metadata propagated from inbound)
// 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; self.remove_reaction_from_metadata(&msg.metadata).await;
return result; 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 // Handle multimodal message - send with media
let token = self.get_tenant_access_token().await?; let token = self.get_tenant_access_token().await?;
@ -1192,7 +2015,7 @@ impl Channel for FeishuChannel {
// If no content parts after processing, just send empty text // If no content parts after processing, just send empty text
if content_parts.is_empty() { 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) // Remove pending reaction after sending (using metadata propagated from inbound)
self.remove_reaction_from_metadata(&msg.metadata).await; self.remove_reaction_from_metadata(&msg.metadata).await;
return result; return result;