feat(feishu): add reply context handling for messages and improve content fetching

This commit is contained in:
xiaoxixi 2026-04-12 13:31:55 +08:00
parent fb0a9e06aa
commit dcf04279a7

View File

@ -166,6 +166,9 @@ struct ParsedMessage {
chat_id: String,
content: String,
media: Option<MediaItem>,
/// ID of the message this message is replying to (if any).
/// Used to fetch quoted message content for display.
parent_id: Option<String>,
}
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<String> {
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<String>,
data: Option<MessageData>,
}
#[derive(Deserialize)]
struct MessageData {
items: Option<Vec<MessageItem>>,
}
#[derive(Deserialize)]
struct MessageItem {
msg_type: String,
body: Option<MessageBody>,
}
#[derive(Deserialize)]
struct MessageBody {
content: Option<String>,
}
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::<serde_json::Value>(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::<serde_json::Value>(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 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("") {
/// Extract text from a single post element (text, link, at-mention).
fn extract_element(el: &serde_json::Value, out: &mut Vec<String>) {
match el.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());
if let Some(text) = el.get("text").and_then(|t| t.as_str()) {
out.push(text.to_string());
}
}
"a" => {
// Links: extract text or href
let link_text = inner.get("text")
let link_text = el.get("text")
.and_then(|t| t.as_str())
.filter(|s| !s.is_empty())
.or_else(|| inner.get("href").and_then(|h| h.as_str()))
.or_else(|| el.get("href").and_then(|h| h.as_str()))
.unwrap_or("");
texts.push(link_text.to_string());
out.push(link_text.to_string());
}
"at" => {
// @mentions
let name = inner.get("user_name")
let name = el.get("user_name")
.and_then(|n| n.as_str())
.or_else(|| inner.get("user_id").and_then(|i| i.as_str()))
.or_else(|| el.get("user_id").and_then(|i| i.as_str()))
.unwrap_or("user");
texts.push(format!("@{}", name));
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));
}
_ => {
// 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 let Some(text) = el.get("text").and_then(|t| t.as_str()) {
out.push(text.to_string());
}
}
}
}
/// Parse a single block {title, content: [[...]]} and append text to out.
fn parse_block(block: &serde_json::Value, out: &mut Vec<String>) {
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::<serde_json::Value>(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() {
content.to_string()
} else {
result.trim().to_string()
if !result.trim().is_empty() {
return result.trim().to_string();
}
} else {
content.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