feat(feishu): add reply context handling for messages and improve content fetching
This commit is contained in:
parent
fb0a9e06aa
commit
dcf04279a7
@ -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 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) = 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<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() {
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user