diff --git a/src/channels/feishu.rs b/src/channels/feishu.rs index 3a0048c..ed6c4a2 100644 --- a/src/channels/feishu.rs +++ b/src/channels/feishu.rs @@ -166,6 +166,9 @@ struct ParsedMessage { chat_id: String, content: String, media: Option, + /// ID of the message this message is replying to (if any). + /// Used to fetch quoted message content for display. + parent_id: Option, } impl FeishuChannel { @@ -655,6 +658,97 @@ impl FeishuChannel { Ok(()) } + const REPLY_CONTEXT_MAX_LEN: usize = 500; + + /// Fetch the text content of a Feishu message by ID. + /// Returns a "[Reply to: ...]" context string, or None on failure. + async fn get_message_content(&self, message_id: &str) -> Option { + let token = match self.get_tenant_access_token().await { + Ok(t) => t, + Err(e) => { + tracing::debug!(error = %e, message_id = %message_id, "Feishu: failed to get token for fetching parent message"); + return None; + } + }; + + let resp = self.http_client + .get(format!("{}/im/v1/messages/{}", FEISHU_API_BASE, message_id)) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .ok()?; + + #[derive(Deserialize)] + struct MessageResp { + code: i32, + msg: Option, + data: Option, + } + #[derive(Deserialize)] + struct MessageData { + items: Option>, + } + #[derive(Deserialize)] + struct MessageItem { + msg_type: String, + body: Option, + } + #[derive(Deserialize)] + struct MessageBody { + content: Option, + } + + let result: MessageResp = match resp.json().await { + Ok(r) => r, + Err(e) => { + tracing::debug!(error = %e, message_id = %message_id, "Feishu: failed to parse parent message response"); + return None; + } + }; + + if result.code != 0 { + tracing::debug!( + message_id = %message_id, + code = %result.code, + msg = ?result.msg, + "Feishu: failed to fetch parent message" + ); + return None; + } + + let items = result.data?.items?; + let msg_obj = items.first()?; + + let raw_content = msg_obj.body.as_ref()?.content.as_ref()?; + let msg_type = msg_obj.msg_type.as_str(); + + let text = match msg_type { + "text" => { + serde_json::from_str::(raw_content) + .ok()? + .get("text")? + .as_str()? + .to_string() + } + "post" => { + parse_post_content(raw_content) + } + _ => String::new(), + }; + + if text.is_empty() { + return None; + } + + let text = if text.len() > Self::REPLY_CONTEXT_MAX_LEN { + format!("{}...", &text[..Self::REPLY_CONTEXT_MAX_LEN]) + } else { + text + }; + + Some(format!("[Reply to: {}]", text)) + } + /// 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?; @@ -780,11 +874,19 @@ impl FeishuChannel { let chat_id = msg.chat_id.clone(); let msg_type = msg.message_type.as_str(); let raw_content = msg.content.clone(); + let parent_id = msg.parent_id.clone(); #[cfg(debug_assertions)] tracing::debug!(msg_type = %msg_type, chat_id = %chat_id, open_id = %open_id, "Parsing message content"); - let (content, media) = self.parse_and_download_message(msg_type, &raw_content, &message_id).await?; + let (mut content, media) = self.parse_and_download_message(msg_type, &raw_content, &message_id).await?; + + // Fetch and prepend quoted message content if this is a reply + if let Some(ref pid) = parent_id { + if let Some(reply_ctx) = self.get_message_content(pid).await { + content = format!("{}\n{}", reply_ctx, content); + } + } #[cfg(debug_assertions)] if let Some(ref m) = media { @@ -797,6 +899,7 @@ impl FeishuChannel { chat_id, content, media, + parent_id, })) } @@ -973,6 +1076,9 @@ impl FeishuChannel { if let Some(ref rid) = reaction_id { forwarded_metadata.insert("feishu.reaction_id".to_string(), rid.clone()); } + if let Some(ref pid) = parsed.parent_id { + forwarded_metadata.insert("feishu.parent_id".to_string(), pid.clone()); + } // Publish to bus asynchronously let channel = self.clone(); @@ -1080,79 +1186,114 @@ impl FeishuChannel { } fn parse_post_content(content: &str) -> String { - if let Ok(parsed) = serde_json::from_str::(content) { - let mut texts = vec![]; - // 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 text from a single post element (text, link, at-mention). + fn extract_element(el: &serde_json::Value, out: &mut Vec) { + 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(text.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()); - } + "a" => { + let link_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(link_text.to_string()); + } + "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(format!("@{}", name)); + } + "code_block" => { + let lang = el.get("language").and_then(|l| l.as_str()).unwrap_or(""); + let code_text = el.get("text").and_then(|t| t.as_str()).unwrap_or(""); + out.push(format!("\n```{}\n{}\n```\n", lang, code_text)); + } + _ => { + if let Some(text) = el.get("text").and_then(|t| t.as_str()) { + out.push(text.to_string()); } } } - - let result = texts.join(""); - if result.trim().is_empty() { - content.to_string() - } else { - result.trim().to_string() - } - } else { - content.to_string() } + + /// Parse a single block {title, content: [[...]]} and append text to out. + fn parse_block(block: &serde_json::Value, out: &mut Vec) { + let title = block.get("title").and_then(|t| t.as_str()).filter(|s| !s.is_empty()); + if let Some(t) = title { + out.push(t.to_string()); + out.push("\n\n".to_string()); + } + + if let Some(content_arr) = block.get("content").and_then(|c| c.as_array()) { + for row in content_arr { + if let Some(row_arr) = row.as_array() { + for el in row_arr { + extract_element(el, out); + } + out.push("\n".to_string()); + } + } + } + } + + let Ok(parsed) = serde_json::from_str::(content) else { + return content.to_string(); + }; + + let mut texts = Vec::new(); + + // Unwrap optional {"post": ...} envelope (nanobot: root = root["post"]) + let root = if parsed.get("post").and_then(|p| p.as_object()).is_some() { + parsed.get("post").unwrap() + } else { + &parsed + }; + + // Try direct format: {"title": ..., "content": [[...]]} + if root.get("content").and_then(|c| c.as_array()).is_some() { + parse_block(root, &mut texts); + let result = texts.join(""); + if !result.trim().is_empty() { + return result.trim().to_string(); + } + texts.clear(); + } + + // Try localized: {"zh_cn": {"title": ..., "content": [...]}} + for key in ["zh_cn", "en_us", "ja_jp"] { + if let Some(locale_data) = root.get(key).and_then(|l| l.as_object()) { + parse_block(&serde_json::json!(locale_data), &mut texts); + let result = texts.join(""); + if !result.trim().is_empty() { + return result.trim().to_string(); + } + texts.clear(); + } + } + + // Fall back: try any dict child + if let Some(root_obj) = root.as_object() { + for (_key, val) in root_obj { + if let Some(obj) = val.as_object() { + if obj.get("content").and_then(|c| c.as_array()).is_some() { + parse_block(val, &mut texts); + let result = texts.join(""); + if !result.trim().is_empty() { + return result.trim().to_string(); + } + texts.clear(); + } + } + } + } + + content.to_string() } /// Extract text content from interactive card messages