feat(feishu): enhance message sending with dynamic format detection and support for interactive cards
This commit is contained in:
parent
394b5fdd6a
commit
3d72f3dfa8
@ -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 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()
|
||||
};
|
||||
|
||||
let text_content = serde_json::json!({ "text": truncated }).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,29 +807,84 @@ impl FeishuChannel {
|
||||
content: &str,
|
||||
message_id: &str,
|
||||
) -> Result<(String, Option<MediaItem>), ChannelError> {
|
||||
match msg_type {
|
||||
let (text, media) = match msg_type {
|
||||
"text" => {
|
||||
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()
|
||||
} 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::<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 {
|
||||
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
|
||||
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 {
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(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 {
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
if texts.is_empty() {
|
||||
}
|
||||
|
||||
let result = texts.join("");
|
||||
if result.trim().is_empty() {
|
||||
content.to_string()
|
||||
} else {
|
||||
texts.join("")
|
||||
result.trim().to_string()
|
||||
}
|
||||
} else {
|
||||
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]
|
||||
impl Channel for FeishuChannel {
|
||||
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_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)
|
||||
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
|
||||
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 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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user