feat(feishu): 支持引用回复消息 - 使用飞书 reply API (POST /im/v1/messages/{id}/reply) - 仅 AssistantResponse 使用回复接口,工具消息走普通发送 - 提取公共逻辑到 prepare_payload / check_send_response - 新增 reply_to_feishu_message / dispatch_send 方法

This commit is contained in:
ooodc 2026-06-14 14:15:21 +08:00
parent 3630e62e18
commit 09dd15f557

View File

@ -866,32 +866,7 @@ impl FeishuChannel {
) -> Result<(), ChannelError> { ) -> Result<(), ChannelError> {
let token = self.get_tenant_access_token().await?; let token = self.get_tenant_access_token().await?;
let payload_content = if msg_type == "text" { let payload_content = self.prepare_payload(msg_type, content);
let truncated = if char_count(content) > self.config.max_message_chars {
format!(
"{}\n\n[Content truncated due to length limit]",
truncate_with_ellipsis(content, self.config.max_message_chars)
)
} else {
content.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 char_count(content) > self.config.max_message_chars {
// Fallback to truncated text for post as well
serde_json::json!({
"text": format!(
"{}\n\n[Content truncated due to length limit]",
truncate_with_ellipsis(content, self.config.max_message_chars)
)
})
.to_string()
} else {
content.to_string()
}
};
let resp = self let resp = self
.http_client .http_client
@ -912,6 +887,70 @@ impl FeishuChannel {
ChannelError::ConnectionError(format!("Send message HTTP error: {}", e)) ChannelError::ConnectionError(format!("Send message HTTP error: {}", e))
})?; })?;
Self::check_send_response(resp).await
}
/// Reply to a specific message using POST /im/v1/messages/{message_id}/reply
async fn reply_to_feishu_message(
&self,
reply_to_message_id: &str,
msg_type: &str,
content: &str,
) -> Result<(), ChannelError> {
let token = self.get_tenant_access_token().await?;
let payload_content = self.prepare_payload(msg_type, content);
let resp = self
.http_client
.post(format!(
"{}/im/v1/messages/{}/reply",
FEISHU_API_BASE, reply_to_message_id
))
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({
"msg_type": msg_type,
"content": payload_content
}))
.send()
.await
.map_err(|e| {
ChannelError::ConnectionError(format!("Reply message HTTP error: {}", e))
})?;
Self::check_send_response(resp).await
}
/// Prepare message payload with length truncation
fn prepare_payload(&self, msg_type: &str, content: &str) -> String {
if msg_type == "text" {
let truncated = if char_count(content) > self.config.max_message_chars {
format!(
"{}\n\n[Content truncated due to length limit]",
truncate_with_ellipsis(content, self.config.max_message_chars)
)
} else {
content.to_string()
};
serde_json::json!({ "text": truncated }).to_string()
} else {
if char_count(content) > self.config.max_message_chars {
serde_json::json!({
"text": format!(
"{}\n\n[Content truncated due to length limit]",
truncate_with_ellipsis(content, self.config.max_message_chars)
)
})
.to_string()
} else {
content.to_string()
}
}
}
/// Check the response from Feishu send/reply API
async fn check_send_response(resp: reqwest::Response) -> Result<(), ChannelError> {
#[derive(Deserialize)] #[derive(Deserialize)]
struct SendResp { struct SendResp {
code: i32, code: i32,
@ -933,6 +972,22 @@ impl FeishuChannel {
Ok(()) Ok(())
} }
/// Dispatch: use reply API when reply_to is set, otherwise use send API
async fn dispatch_send(
&self,
receive_id: &str,
receive_id_type: &str,
msg_type: &str,
content: &str,
reply_to: Option<&str>,
) -> Result<(), ChannelError> {
if let Some(parent_id) = reply_to {
self.reply_to_feishu_message(parent_id, msg_type, content).await
} else {
self.send_message_to_feishu(receive_id, receive_id_type, msg_type, content).await
}
}
/// Extract service_id from WebSocket URL query params /// Extract service_id from WebSocket URL query params
fn extract_service_id(url: &str) -> i32 { fn extract_service_id(url: &str) -> i32 {
url.split('?') url.split('?')
@ -2412,6 +2467,13 @@ impl Channel for FeishuChannel {
return Ok(()); return Ok(());
} }
// Only use reply API for assistant content messages
let reply_to = if matches!(msg.event_kind, OutboundEventKind::AssistantResponse) {
msg.metadata.get("feishu.parent_id").map(|s| s.as_str())
} else {
None
};
let receive_id = if msg.chat_id.starts_with("oc_") { let receive_id = if msg.chat_id.starts_with("oc_") {
&msg.chat_id &msg.chat_id
} else { } else {
@ -2443,7 +2505,7 @@ impl Channel for FeishuChannel {
MsgFormat::Text => { MsgFormat::Text => {
// Short plain text send as simple text message // Short plain text send as simple text message
let result = self let result = self
.send_message_to_feishu(receive_id, receive_id_type, "text", content) .dispatch_send(receive_id, receive_id_type, "text", content, reply_to)
.await; .await;
remove_reaction.await; remove_reaction.await;
return result; return result;
@ -2452,7 +2514,7 @@ impl Channel for FeishuChannel {
// Medium content with links send as rich-text post // Medium content with links send as rich-text post
let post_body = Self::markdown_to_post(content); let post_body = Self::markdown_to_post(content);
let result = self let result = self
.send_message_to_feishu(receive_id, receive_id_type, "post", &post_body) .dispatch_send(receive_id, receive_id_type, "post", &post_body, reply_to)
.await; .await;
remove_reaction.await; remove_reaction.await;
return result; return result;
@ -2472,11 +2534,12 @@ impl Channel for FeishuChannel {
tracing::warn!(error = %e, "Failed to send interactive card, falling back to text"); tracing::warn!(error = %e, "Failed to send interactive card, falling back to text");
// Fallback to plain text // Fallback to plain text
let result = self let result = self
.send_message_to_feishu( .dispatch_send(
receive_id, receive_id,
receive_id_type, receive_id_type,
"text", "text",
content, content,
reply_to,
) )
.await; .await;
remove_reaction.await; remove_reaction.await;
@ -2490,7 +2553,7 @@ impl Channel for FeishuChannel {
} }
if !msg.content.trim().is_empty() { if !msg.content.trim().is_empty() {
self.send_message_to_feishu(receive_id, receive_id_type, "text", msg.content.trim()) self.dispatch_send(receive_id, receive_id_type, "text", msg.content.trim(), reply_to)
.await?; .await?;
} }
@ -2500,21 +2563,23 @@ impl Channel for FeishuChannel {
let result = match media_item.media_type.as_str() { let result = match media_item.media_type.as_str() {
"image" => { "image" => {
let image_key = self.upload_image(path).await?; let image_key = self.upload_image(path).await?;
self.send_message_to_feishu( self.dispatch_send(
receive_id, receive_id,
receive_id_type, receive_id_type,
"image", "image",
&serde_json::json!({ "image_key": image_key }).to_string(), &serde_json::json!({ "image_key": image_key }).to_string(),
reply_to,
) )
.await .await
} }
"audio" | "file" | "video" => { "audio" | "file" | "video" => {
let file_key = self.upload_file(path).await?; let file_key = self.upload_file(path).await?;
self.send_message_to_feishu( self.dispatch_send(
receive_id, receive_id,
receive_id_type, receive_id_type,
"file", "file",
&serde_json::json!({ "file_key": file_key }).to_string(), &serde_json::json!({ "file_key": file_key }).to_string(),
reply_to,
) )
.await .await
} }