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 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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user