use std::collections::HashMap; use std::path::Path; use std::sync::Arc; use std::time::{Duration, Instant}; use async_trait::async_trait; use futures_util::{SinkExt, StreamExt}; use prost::{Message as ProstMessage, bytes::Bytes}; use serde::Deserialize; use tokio::sync::{Mutex, RwLock}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use crate::bus::{MediaItem, MessageBus, OutboundMessage}; use crate::channels::base::{Channel, ChannelError, LivePolicy, TurnSink, TurnTarget}; use crate::config::FeishuChannelConfig; use crate::session::{ToolStatus, TurnBlock, TurnSnapshot, TurnStatus}; const FEISHU_API_BASE: &str = "https://open.feishu.cn/open-apis"; const FEISHU_WS_BASE: &str = "https://open.feishu.cn"; /// Heartbeat timeout for WS connection — must be larger than ping_interval (default 120 s). /// If no binary frame (pong or event) is received within this window, reconnect. const WS_HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(300); const CHANNEL_STOP_GRACE: Duration = if cfg!(test) { Duration::from_millis(100) } else { Duration::from_secs(5) }; /// Refresh tenant token this many seconds before the announced expiry. const TOKEN_REFRESH_SKEW: Duration = Duration::from_secs(120); /// Default tenant token TTL when `expire`/`expires_in` is absent. const DEFAULT_TOKEN_TTL: Duration = Duration::from_secs(7200); /// Dedup cache TTL (30 minutes). const DEDUP_CACHE_TTL: Duration = Duration::from_secs(30 * 60); // ───────────────────────────────────────────────────────────────────────────── // Protobuf types for Feishu WebSocket protocol (pbbp2.proto) // ───────────────────────────────────────────────────────────────────────────── #[derive(Clone, PartialEq, prost::Message)] struct PbHeader { #[prost(string, tag = "1")] pub key: String, #[prost(string, tag = "2")] pub value: String, } /// Feishu WS frame. /// method=0 → CONTROL (ping/pong) method=1 → DATA (events) #[derive(Clone, PartialEq, prost::Message)] struct PbFrame { #[prost(uint64, tag = "1")] pub seq_id: u64, #[prost(uint64, tag = "2")] pub log_id: u64, #[prost(int32, tag = "3")] pub service: i32, #[prost(int32, tag = "4")] pub method: i32, #[prost(message, repeated, tag = "5")] pub headers: Vec, #[prost(bytes = "vec", optional, tag = "8")] pub payload: Option>, } /// POST /callback/ws/endpoint response #[derive(Deserialize)] struct WsEndpointResp { code: i32, msg: Option, data: Option, } #[derive(Deserialize)] struct WsEndpoint { #[serde(rename = "URL")] url: String, #[serde(default)] client_config: Option, } #[derive(Deserialize, Default)] struct WsClientConfig { #[serde(rename = "PingInterval")] ping_interval: Option, } /// Lark event envelope (method=1 / type=event payload) #[derive(Deserialize)] struct LarkEvent { header: LarkEventHeader, event: serde_json::Value, } #[derive(Deserialize)] struct LarkEventHeader { event_type: String, event_id: String, } impl std::fmt::Debug for LarkEventHeader { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LarkEventHeader") .field("event_type", &self.event_type) .field("event_id", &self.event_id) .finish() } } #[derive(Deserialize)] struct MsgReceivePayload { sender: LarkSender, message: LarkMessage, } #[derive(Deserialize)] struct LarkSender { sender_id: LarkSenderId, #[serde(default)] sender_type: String, } #[derive(Deserialize, Default)] struct LarkSenderId { open_id: Option, } #[derive(Deserialize)] struct LarkMessage { message_id: String, chat_id: String, #[serde(default)] chat_type: String, message_type: String, #[serde(default)] content: String, #[serde(default)] parent_id: Option, #[serde(default)] mentions: Vec, } // ───────────────────────────────────────────────────────────────────────────── /// Cached tenant token with proactive refresh metadata. #[derive(Clone)] struct CachedTenantToken { value: String, refresh_after: Instant, } #[derive(Clone)] pub struct FeishuChannel { config: FeishuChannelConfig, http_client: reqwest::Client, running: Arc>, shutdown: Arc>>, run_task: Arc>>>, connected: Arc>, /// Cached tenant access token with proactive refresh. tenant_token: Arc>>, /// Dedup cache: WS message_ids seen in the last ~30 min. seen_message_ids: Arc>>, /// Bot identity used to enforce group-chat @mention admission. bot_open_id: Arc>>, } /// Parsed message data from a Feishu frame struct ParsedMessage { message_id: String, open_id: String, chat_id: String, content: String, media: Vec, /// ID of the message this message is replying to (if any). /// Used to fetch quoted message content for display. parent_id: Option, } impl FeishuChannel { pub fn new( mut config: FeishuChannelConfig, workspace_dir: &Path, ) -> Result { // Override media_dir to use workspace_dir/media/feishu let media_dir = workspace_dir.join("media").join("feishu"); config.media_dir = media_dir.to_string_lossy().to_string(); Ok(Self { config, http_client: reqwest::Client::new(), running: Arc::new(RwLock::new(false)), shutdown: Arc::new(RwLock::new(None)), run_task: Arc::new(Mutex::new(None)), connected: Arc::new(RwLock::new(false)), tenant_token: Arc::new(RwLock::new(None)), seen_message_ids: Arc::new(RwLock::new(HashMap::new())), bot_open_id: Arc::new(RwLock::new(None)), }) } async fn refresh_bot_open_id(&self) -> Result { let token = self.get_tenant_access_token().await?; let response = self .http_client .get(format!("{}/bot/v3/info", FEISHU_API_BASE)) .bearer_auth(token) .send() .await .map_err(|error| { ChannelError::ConnectionError(format!("Bot info HTTP error: {error}")) })?; let status = response.status(); let body: serde_json::Value = response.json().await.map_err(|error| { ChannelError::Other(format!("Failed to parse bot info response: {error}")) })?; if !status.is_success() || body.get("code").and_then(|value| value.as_i64()) != Some(0) { return Err(ChannelError::Other(format!( "Bot info request failed: status={status}, code={}", body.get("code") .and_then(|value| value.as_i64()) .unwrap_or(-1) ))); } let open_id = body .pointer("/bot/open_id") .or_else(|| body.pointer("/data/bot/open_id")) .and_then(|value| value.as_str()) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| ChannelError::Other("Bot info response has no open_id".to_string()))? .to_string(); *self.bot_open_id.write().await = Some(open_id.clone()); Ok(open_id) } /// Get WebSocket endpoint URL from Feishu API async fn get_ws_endpoint( &self, client: &reqwest::Client, ) -> Result<(String, WsClientConfig), ChannelError> { let resp = client .post(format!("{}/callback/ws/endpoint", FEISHU_WS_BASE)) .header("locale", "zh") .json(&serde_json::json!({ "AppID": self.config.app_id, "AppSecret": self.config.app_secret, })) .send() .await .map_err(|e| ChannelError::ConnectionError(format!("HTTP error: {}", e)))?; let endpoint_resp: WsEndpointResp = resp.json().await.map_err(|e| { ChannelError::ConnectionError(format!("Failed to parse endpoint response: {}", e)) })?; if endpoint_resp.code != 0 { return Err(ChannelError::ConnectionError(format!( "WS endpoint failed: code={} msg={}", endpoint_resp.code, endpoint_resp.msg.as_deref().unwrap_or("unknown") ))); } let ep = endpoint_resp .data .ok_or_else(|| ChannelError::ConnectionError("Empty endpoint data".to_string()))?; let client_config = ep.client_config.unwrap_or_default(); Ok((ep.url, client_config)) } /// Get tenant access token (cached with proactive refresh). async fn get_tenant_access_token(&self) -> Result { // 1. Check cache { let cached = self.tenant_token.read().await; if let Some(ref token) = *cached && Instant::now() < token.refresh_after { return Ok(token.value.clone()); } } // 2. Fetch new token let (token, ttl) = self.fetch_new_token().await?; // 3. Cache with proactive refresh time (提前 120 秒) let refresh_after = Instant::now() + ttl.saturating_sub(TOKEN_REFRESH_SKEW); { let mut cached = self.tenant_token.write().await; *cached = Some(CachedTenantToken { value: token.clone(), refresh_after, }); } Ok(token) } /// Fetch a new tenant access token from Feishu. async fn fetch_new_token(&self) -> Result<(String, Duration), ChannelError> { let resp = self .http_client .post(format!( "{}/auth/v3/tenant_access_token/internal", FEISHU_API_BASE )) .header("Content-Type", "application/json") .json(&serde_json::json!({ "app_id": self.config.app_id, "app_secret": self.config.app_secret, })) .send() .await .map_err(|e| ChannelError::ConnectionError(format!("HTTP error: {}", e)))?; #[derive(Deserialize)] struct TokenResponse { code: i32, tenant_access_token: Option, expire: Option, } let token_resp: TokenResponse = resp .json() .await .map_err(|e| ChannelError::Other(format!("Failed to parse token response: {}", e)))?; if token_resp.code != 0 { return Err(ChannelError::Other("Auth failed".to_string())); } let token = token_resp .tenant_access_token .ok_or_else(|| ChannelError::Other("No token in response".to_string()))?; let ttl = token_resp .expire .and_then(|v| u64::try_from(v).ok()) .map(Duration::from_secs) .unwrap_or(DEFAULT_TOKEN_TTL); Ok((token, ttl)) } /// Check if message_id has been seen (dedup), and mark it as seen if not. /// Returns true if the message was already processed. /// Note: GC of stale entries is handled in the heartbeat timeout_check loop. async fn is_message_seen(&self, message_id: &str) -> bool { let mut seen = self.seen_message_ids.write().await; let now = Instant::now(); if seen.contains_key(message_id) { true } else { seen.insert(message_id.to_string(), now); false } } /// Download media and save locally, return (description, media_item) async fn download_media( &self, msg_type: &str, content_json: &serde_json::Value, message_id: &str, ) -> Result<(String, Option), ChannelError> { let media_dir = Path::new(&self.config.media_dir); tokio::fs::create_dir_all(media_dir) .await .map_err(|e| ChannelError::Other(format!("Failed to create media dir: {}", e)))?; match msg_type { "image" => { self.download_image(content_json, message_id, media_dir) .await } "audio" | "file" | "media" => { self.download_file(content_json, message_id, media_dir, msg_type) .await } _ => Ok((format!("[unsupported media type: {}]", msg_type), None)), } } /// Download image from Feishu async fn download_image( &self, content_json: &serde_json::Value, message_id: &str, media_dir: &Path, ) -> Result<(String, Option), ChannelError> { let image_key = content_json .get("image_key") .and_then(|v| v.as_str()) .ok_or_else(|| ChannelError::Other("No image_key in message".to_string()))?; let token = self.get_tenant_access_token().await?; // Use message resource API for downloading message images let url = format!( "{}/im/v1/messages/{}/resources/{}?type=image", FEISHU_API_BASE, message_id, image_key ); #[cfg(debug_assertions)] tracing::debug!(url = %url, image_key = %image_key, message_id = %message_id, "Downloading image from Feishu via message resource API"); let resp = self .http_client .get(&url) .header("Authorization", format!("Bearer {}", token)) .send() .await .map_err(|e| { ChannelError::ConnectionError(format!("Download image HTTP error: {}", e)) })?; let status = resp.status(); #[cfg(debug_assertions)] tracing::debug!(status = %status, "Image download response status"); if !status.is_success() { let error_text = resp.text().await.unwrap_or_default(); return Err(ChannelError::Other(format!( "Image download failed {}: {}", status, error_text ))); } let content_type = resp .headers() .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or("image/jpeg") .to_string(); let ext = resolve_image_ext(&content_type); let data = resp .bytes() .await .map_err(|e| ChannelError::Other(format!("Failed to read image data: {}", e)))? .to_vec(); #[cfg(debug_assertions)] tracing::debug!(data_len = %data.len(), content_type = %content_type, "Downloaded image data"); let filename = format!( "{}_{}.{}", message_id, &image_key[..8.min(image_key.len())], ext ); let file_path = resolve_unique_path(media_dir, &filename).await; tokio::fs::write(&file_path, &data) .await .map_err(|e| ChannelError::Other(format!("Failed to write image: {}", e)))?; let media_item = MediaItem::new(file_path.to_string_lossy().to_string(), "image"); tracing::info!(message_id = %message_id, filename = %filename, "Downloaded image"); Ok((String::new(), Some(media_item))) } /// Download file/audio from Feishu async fn download_file( &self, content_json: &serde_json::Value, message_id: &str, media_dir: &Path, file_type: &str, ) -> Result<(String, Option), ChannelError> { let file_key = content_json .get("file_key") .and_then(|v| v.as_str()) .ok_or_else(|| ChannelError::Other("No file_key in message".to_string()))?; let token = self.get_tenant_access_token().await?; // Use message resource API for downloading message files let url = format!( "{}/im/v1/messages/{}/resources/{}?type=file", FEISHU_API_BASE, message_id, file_key ); #[cfg(debug_assertions)] tracing::debug!(url = %url, file_key = %file_key, message_id = %message_id, "Downloading file from Feishu via message resource API"); let resp = self .http_client .get(&url) .header("Authorization", format!("Bearer {}", token)) .send() .await .map_err(|e| { ChannelError::ConnectionError(format!("Download file HTTP error: {}", e)) })?; let status = resp.status(); if !status.is_success() { let error_text = resp.text().await.unwrap_or_default(); return Err(ChannelError::Other(format!( "File download failed {}: {}", status, error_text ))); } let data = resp .bytes() .await .map_err(|e| ChannelError::Other(format!("Failed to read file data: {}", e)))? .to_vec(); let filename = content_json .get("file_name") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .unwrap_or_else(|| { let ext = resolve_file_ext(content_json); if ext.is_empty() { format!("{}_{}", message_id, &file_key[..8.min(file_key.len())]) } else { format!( "{}_{}.{}", message_id, &file_key[..8.min(file_key.len())], ext ) } }); let file_path = resolve_unique_path(media_dir, &filename).await; tokio::fs::write(&file_path, &data) .await .map_err(|e| ChannelError::Other(format!("Failed to write file: {}", e)))?; let media_item = MediaItem::new(file_path.to_string_lossy().to_string(), file_type); tracing::info!(message_id = %message_id, filename = %filename, file_type = %file_type, "Downloaded file"); Ok((String::new(), Some(media_item))) } /// Upload image to Feishu and return the image_key async fn upload_image(&self, file_path: &str) -> Result { let token = self.get_tenant_access_token().await?; let mime = mime_guess::from_path(file_path) .first_or_octet_stream() .to_string(); let file_name = std::path::Path::new(file_path) .file_name() .and_then(|n| n.to_str()) .unwrap_or("image.jpg"); let file_data = tokio::fs::read(file_path) .await .map_err(|e| ChannelError::Other(format!("Failed to read file: {}", e)))?; let part = reqwest::multipart::Part::bytes(file_data) .file_name(file_name.to_string()) .mime_str(&mime) .map_err(|e| ChannelError::Other(format!("Invalid mime type: {}", e)))?; let form = reqwest::multipart::Form::new() .text("image_type", "message".to_string()) .part("image", part); let resp = self .http_client .post(format!("{}/im/v1/images", FEISHU_API_BASE)) .header("Authorization", format!("Bearer {}", token)) .multipart(form) .send() .await .map_err(|e| { ChannelError::ConnectionError(format!("Upload image HTTP error: {}", e)) })?; let status = resp.status(); let body_text = resp .text() .await .map_err(|e| ChannelError::Other(format!("Failed to read upload response: {}", e)))?; tracing::debug!(status = %status, body = %body_text, "Feishu upload image"); #[derive(Deserialize)] struct UploadResp { code: i32, msg: Option, data: Option, } #[derive(Deserialize)] struct UploadData { image_key: String, } let result: UploadResp = serde_json::from_str(&body_text).map_err(|e| { ChannelError::Other(format!( "Parse upload response error: {} | body: {}", e, &body_text )) })?; if result.code != 0 { return Err(ChannelError::Other(format!( "Upload image failed: code={} msg={}", result.code, result.msg.as_deref().unwrap_or("unknown") ))); } result .data .map(|d| d.image_key) .ok_or_else(|| ChannelError::Other("No image_key in response".to_string())) } /// Upload file to Feishu and return the file_key async fn upload_file(&self, file_path: &str) -> Result { let token = self.get_tenant_access_token().await?; let file_name = std::path::Path::new(file_path) .file_name() .and_then(|n| n.to_str()) .unwrap_or("file.bin"); let extension = std::path::Path::new(file_path) .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase(); let file_type = match extension.as_str() { "opus" => "opus", "mp4" | "mov" | "avi" | "mkv" => "mp4", "pdf" => "pdf", "doc" | "docx" => "doc", "xls" | "xlsx" => "xls", "ppt" | "pptx" => "ppt", _ => "stream", }; let file_data = tokio::fs::read(file_path) .await .map_err(|e| ChannelError::Other(format!("Failed to read file: {}", e)))?; let part = reqwest::multipart::Part::bytes(file_data) .file_name(file_name.to_string()) .mime_str("application/octet-stream") .map_err(|e| ChannelError::Other(format!("Invalid mime type: {}", e)))?; let form = reqwest::multipart::Form::new() .text("file_type", file_type.to_string()) .text("file_name", file_name.to_string()) .part("file", part); let resp = self .http_client .post(format!("{}/im/v1/files", FEISHU_API_BASE)) .header("Authorization", format!("Bearer {}", token)) .multipart(form) .send() .await .map_err(|e| ChannelError::ConnectionError(format!("Upload file HTTP error: {}", e)))?; let status = resp.status(); let body_text = resp .text() .await .map_err(|e| ChannelError::Other(format!("Failed to read upload response: {}", e)))?; tracing::debug!(status = %status, body = %body_text, "Feishu upload file"); #[derive(Deserialize)] struct UploadResp { code: i32, msg: Option, data: Option, } #[derive(Deserialize)] struct UploadData { file_key: String, } let result: UploadResp = serde_json::from_str(&body_text).map_err(|e| { ChannelError::Other(format!( "Parse upload response error: {} | body: {}", e, &body_text )) })?; if result.code != 0 { return Err(ChannelError::Other(format!( "Upload file failed: code={} msg={}", result.code, result.msg.as_deref().unwrap_or("unknown") ))); } result .data .map(|d| d.file_key) .ok_or_else(|| ChannelError::Other("No file_key in response".to_string())) } /// Add a reaction emoji to a message and store the reaction_id for later removal. /// Returns the reaction_id if successful, None otherwise. async fn add_reaction(&self, message_id: &str) -> Result, ChannelError> { let emoji = self.config.reaction_emoji.as_str(); let token = self.get_tenant_access_token().await?; let resp = self .http_client .post(format!( "{}/im/v1/messages/{}/reactions", FEISHU_API_BASE, message_id )) .header("Authorization", format!("Bearer {}", token)) .json(&serde_json::json!({ "reaction_type": { "emoji_type": emoji } })) .send() .await .map_err(|e| { ChannelError::ConnectionError(format!("Add reaction HTTP error: {}", e)) })?; #[derive(Deserialize)] struct ReactionResp { code: i32, msg: Option, data: Option, } #[derive(Deserialize)] struct ReactionData { reaction_id: Option, } let result: ReactionResp = resp .json() .await .map_err(|e| ChannelError::Other(format!("Parse reaction response error: {}", e)))?; if result.code != 0 { tracing::warn!( "Failed to add reaction to message {}: code={} msg={}", message_id, result.code, result.msg.as_deref().unwrap_or("unknown") ); return Ok(None); } let reaction_id = result.data.and_then(|d| d.reaction_id); Ok(reaction_id) } /// Remove reaction using feishu metadata propagated through OutboundMessage. /// Reads feishu.message_id and feishu.reaction_id from metadata. async fn remove_reaction_from_metadata( &self, metadata: &std::collections::HashMap, ) { let (message_id, reaction_id) = match ( metadata.get("feishu.message_id"), metadata.get("feishu.reaction_id"), ) { (Some(msg_id), Some(rid)) => (msg_id.clone(), rid.clone()), _ => return, }; if let Err(e) = self.remove_reaction(&message_id, &reaction_id).await { tracing::debug!(error = %e, message_id = %message_id, "Failed to remove reaction"); } } /// Remove a reaction emoji from a message. async fn remove_reaction( &self, message_id: &str, reaction_id: &str, ) -> Result<(), ChannelError> { let token = self.get_tenant_access_token().await?; let resp = self .http_client .delete(format!( "{}/im/v1/messages/{}/reactions/{}", FEISHU_API_BASE, message_id, reaction_id )) .header("Authorization", format!("Bearer {}", token)) .send() .await .map_err(|e| { ChannelError::ConnectionError(format!("Remove reaction HTTP error: {}", e)) })?; #[derive(Deserialize)] struct ReactionResp { code: i32, msg: Option, } let result: ReactionResp = resp.json().await.map_err(|e| { ChannelError::Other(format!("Parse remove reaction response error: {}", e)) })?; if result.code != 0 { tracing::debug!( "Failed to remove reaction {} from message {}: code={} msg={}", reaction_id, message_id, result.code, result.msg.as_deref().unwrap_or("unknown") ); } 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 and content. /// Content is passed as-is (already a JSON string for file/media, or plain text for fallback). 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 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": msg_type, "content": content })) .send() .await .map_err(|e| { ChannelError::ConnectionError(format!("Send message 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 response error: {}", e)))?; if send_resp.code != 0 { return Err(ChannelError::Other(format!( "Send message failed: code={} msg={}", send_resp.code, send_resp.msg ))); } Ok(()) } /// Extract service_id from WebSocket URL query params fn extract_service_id(url: &str) -> i32 { url.split('?') .nth(1) .and_then(|qs| { qs.split('&') .find(|kv| kv.starts_with("service_id=")) .and_then(|kv| kv.split('=').nth(1)) .and_then(|v| v.parse::().ok()) }) .unwrap_or(0) } /// Handle incoming binary PbFrame - returns Some(ParsedMessage) if we need to ack async fn handle_frame(&self, frame: &PbFrame) -> Result, ChannelError> { // method 0 = CONTROL (ping/pong) if frame.method == 0 { return Ok(None); } // method 1 = DATA (events) if frame.method != 1 { return Ok(None); } let payload = frame .payload .as_deref() .ok_or_else(|| ChannelError::Other("No payload in frame".to_string()))?; #[cfg(debug_assertions)] tracing::debug!(payload_len = %payload.len(), "Received frame payload"); let event: LarkEvent = serde_json::from_slice(payload) .map_err(|e| ChannelError::Other(format!("Parse event error: {}", e)))?; let event_type = event.header.event_type.as_str(); #[cfg(debug_assertions)] tracing::debug!(event_type = %event_type, "Received event type"); if event_type != "im.message.receive_v1" { return Ok(None); } let payload_data: MsgReceivePayload = serde_json::from_value(event.event.clone()) .map_err(|e| ChannelError::Other(format!("Parse payload error: {}", e)))?; // Never let bot/app traffic trigger another model turn. if matches!(payload_data.sender.sender_type.as_str(), "bot" | "app") { return Ok(None); } let open_id = payload_data .sender .sender_id .open_id .ok_or_else(|| ChannelError::Other("No open_id".to_string()))?; if !self.is_allowed(&open_id) { tracing::warn!(sender = %open_id, "Rejected unauthorized Feishu sender"); return Ok(None); } let message_id = payload_data.message.message_id.clone(); let msg = payload_data.message; if msg.chat_type == "group" && self.config.require_mention { let bot_open_id = self.bot_open_id.read().await.clone(); if !message_mentions_bot(&msg, bot_open_id.as_deref()) { #[cfg(debug_assertions)] tracing::debug!(message_id = %message_id, "Ignoring group message without bot mention"); return Ok(None); } } // Deduplicate only after admission so rejected traffic does not consume cache capacity. if self.is_message_seen(&message_id).await { #[cfg(debug_assertions)] tracing::debug!(message_id = %message_id, "Duplicate message, skipping"); return Ok(None); } #[cfg(debug_assertions)] tracing::debug!(message_id = %message_id, "Received Feishu message"); 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 (mut content, media) = self .parse_and_download_message(msg_type, &raw_content, &message_id) .await?; content = normalize_mentions( &content, &msg.mentions, self.bot_open_id.read().await.as_deref(), ); // Fetch and prepend quoted message content if this is a reply if let Some(ref pid) = parent_id && let Some(reply_ctx) = self.get_message_content(pid).await { content = format!("{}\n{}", reply_ctx, content); } #[cfg(debug_assertions)] for m in &media { tracing::debug!(media_type = %m.media_type, media_path = %m.path, "Media downloaded successfully"); } Ok(Some(ParsedMessage { message_id, open_id, chat_id, content, media, parent_id, })) } /// Parse message content and download media if needed async fn parse_and_download_message( &self, msg_type: &str, content: &str, message_id: &str, ) -> Result<(String, Vec), ChannelError> { let (text, media) = match msg_type { "text" => { let text = if let Ok(parsed) = serde_json::from_str::(content) { parsed .get("text") .and_then(|v| v.as_str()) .unwrap_or(content) .to_string() } else { content.to_string() }; (text, Vec::new()) } "post" => { let text = parse_post_content(content); let mut media = Vec::new(); for image_key in collect_post_image_keys(content) { let content_json = serde_json::json!({ "image_key": image_key }); match self .download_media("image", &content_json, message_id) .await { Ok((_text, Some(item))) => media.push(item), Ok((_text, None)) => {} Err(e) => { tracing::warn!(error = %e, "Failed to download image from Feishu post message"); } } } (text, media) } "image" | "audio" | "file" | "media" => { if let Ok(content_json) = serde_json::from_str::(content) { match self .download_media(msg_type, &content_json, message_id) .await { Ok((text, Some(media))) => (text, vec![media]), Ok((text, None)) => (text, Vec::new()), Err(_) => (format!("[{}: content unavailable]", msg_type), Vec::new()), } } else { (format!("[{}: content unavailable]", msg_type), Vec::new()) } } "share_chat" => { // Shared chat/cannel messages if let Ok(parsed) = serde_json::from_str::(content) { let chat_id = parsed .get("chat_id") .and_then(|v| v.as_str()) .unwrap_or("unknown"); (format!("[shared chat: {}]", chat_id), Vec::new()) } else { ("[shared chat]".to_string(), Vec::new()) } } "share_user" => { // Shared user messages if let Ok(parsed) = serde_json::from_str::(content) { let user_id = parsed .get("user_id") .and_then(|v| v.as_str()) .unwrap_or("unknown"); (format!("[shared user: {}]", user_id), Vec::new()) } else { ("[shared user]".to_string(), Vec::new()) } } "interactive" => { // Interactive card messages - extract text content match extract_interactive_content(content) { Ok((text, Some(media))) => (text, vec![media]), Ok((text, None)) => (text, Vec::new()), Err(e) => { tracing::warn!(error = %e, "Failed to extract interactive content"); (content.to_string(), Vec::new()) } } } "list" => { // List/bullet messages match parse_list_content(content) { Ok((text, Some(media))) => (text, vec![media]), Ok((text, None)) => (text, Vec::new()), Err(_) => (content.to_string(), Vec::new()), } } "merge_forward" => ("[merged forward messages]".to_string(), Vec::new()), "share_calendar_event" => { if let Ok(parsed) = serde_json::from_str::(content) { let event_key = parsed .get("event_key") .and_then(|v| v.as_str()) .unwrap_or("unknown"); ( format!("[shared calendar event: {}]", event_key), Vec::new(), ) } else { ("[shared calendar event]".to_string(), Vec::new()) } } "system" => ("[system message]".to_string(), Vec::new()), _ => (content.to_string(), Vec::new()), }; Ok((text, media)) } /// Send acknowledgment for a message async fn send_ack( frame: &PbFrame, write: &mut futures_util::stream::SplitSink< tokio_tungstenite::WebSocketStream< tokio_tungstenite::MaybeTlsStream, >, tokio_tungstenite::tungstenite::Message, >, ) -> Result<(), ChannelError> { let mut ack = frame.clone(); ack.payload = Some(br#"{"code":200,"headers":{},"data":[]}"#.to_vec()); ack.headers.push(PbHeader { key: "biz_rt".into(), value: "0".into(), }); write .send(tokio_tungstenite::tungstenite::Message::Binary( ack.encode_to_vec().into(), )) .await .map_err(|e| ChannelError::Other(format!("Failed to send ack: {}", e)))?; Ok(()) } async fn run_ws_loop( &self, bus: Arc, shutdown: CancellationToken, ) -> Result<(), ChannelError> { let (wss_url, client_config) = tokio::select! { result = self.get_ws_endpoint(&self.http_client) => result?, _ = shutdown.cancelled() => return Ok(()), }; let service_id = Self::extract_service_id(&wss_url); tracing::info!(url = %wss_url, "Connecting to Feishu WebSocket"); let (ws_stream, _) = tokio::select! { result = tokio_tungstenite::connect_async(&wss_url) => result.map_err(|e| { ChannelError::ConnectionError(format!("WebSocket connection failed: {}", e)) })?, _ = shutdown.cancelled() => return Ok(()), }; *self.connected.write().await = true; tracing::info!("Feishu WebSocket connected"); let (mut write, mut read) = ws_stream.split(); // Send initial ping let ping_frame = PbFrame { seq_id: 1, log_id: 0, service: service_id, method: 0, headers: vec![PbHeader { key: "type".into(), value: "ping".into(), }], payload: None, }; tokio::select! { result = write.send(tokio_tungstenite::tungstenite::Message::Binary( ping_frame.encode_to_vec().into(), )) => result.map_err(|e| { ChannelError::ConnectionError(format!("Failed to send initial ping: {}", e)) })?, _ = shutdown.cancelled() => return Ok(()), }; let ping_interval = client_config.ping_interval.unwrap_or(120).max(10); let mut ping_interval_tok = tokio::time::interval(tokio::time::Duration::from_secs(ping_interval)); let mut timeout_check = tokio::time::interval(tokio::time::Duration::from_secs(10)); let mut seq: u64 = 1; let mut last_recv = Instant::now(); // Consume the immediate tick ping_interval_tok.tick().await; timeout_check.tick().await; loop { tokio::select! { msg = read.next() => { match msg { Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(data))) => { last_recv = Instant::now(); let bytes: Bytes = data; if let Ok(frame) = PbFrame::decode(bytes.as_ref()) { match self.handle_frame(&frame).await { Ok(Some(parsed)) => { // Send ACK immediately (Feishu requires within 3 s) if let Err(e) = Self::send_ack(&frame, &mut write).await { tracing::error!(error = %e, "Failed to send ACK to Feishu"); } // Add reaction emoji (await so we get the reaction_id for later removal) let message_id = parsed.message_id.clone(); let reaction_id = match self.add_reaction(&message_id).await { Ok(Some(rid)) => Some(rid), Ok(None) => None, Err(e) => { tracing::debug!(error = %e, message_id = %message_id, "Failed to add reaction"); None } }; let mut private_context = std::collections::HashMap::new(); private_context.insert("feishu.message_id".to_string(), message_id.clone()); if let Some(ref rid) = reaction_id { private_context.insert("feishu.reaction_id".to_string(), rid.clone()); } #[cfg(debug_assertions)] tracing::debug!(open_id = %parsed.open_id, chat_id = %parsed.chat_id, content_len = %parsed.content.len(), media_count = %parsed.media.len(), "Publishing message to bus"); let msg = crate::bus::InboundMessage { channel: "feishu".to_string(), sender_id: parsed.open_id.clone(), chat_id: parsed.chat_id.clone(), content: parsed.content.clone(), received_at: crate::bus::message::current_timestamp(), media: parsed.media.clone(), channel_context: crate::bus::ChannelContext { reply_to: parsed.parent_id.clone(), private: private_context, }, }; if let Err(e) = self.handle_and_publish(&bus, &msg).await { tracing::error!(error = %e, open_id = %parsed.open_id, chat_id = %parsed.chat_id, "Failed to publish Feishu message to bus"); } else { #[cfg(debug_assertions)] tracing::debug!(open_id = %parsed.open_id, chat_id = %parsed.chat_id, "Message published to bus successfully"); } } Ok(None) => {} Err(e) => { tracing::warn!(error = %e, "Failed to parse Feishu frame"); } } } } Some(Ok(tokio_tungstenite::tungstenite::Message::Ping(data))) => { last_recv = Instant::now(); let pong = PbFrame { seq_id: seq.wrapping_add(1), log_id: 0, service: service_id, method: 0, headers: vec![PbHeader { key: "type".into(), value: "pong".into(), }], payload: Some(data.to_vec()), }; let _ = write.send(tokio_tungstenite::tungstenite::Message::Binary(pong.encode_to_vec().into())).await; } Some(Ok(tokio_tungstenite::tungstenite::Message::Pong(_))) => { last_recv = Instant::now(); } Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) | None => { #[cfg(debug_assertions)] tracing::debug!("Feishu WebSocket closed"); break; } Some(Err(e)) => { tracing::warn!(error = %e, "Feishu WebSocket error"); break; } _ => {} } } _ = ping_interval_tok.tick() => { seq = seq.wrapping_add(1); let ping = PbFrame { seq_id: seq, log_id: 0, service: service_id, method: 0, headers: vec![PbHeader { key: "type".into(), value: "ping".into(), }], payload: None, }; if write.send(tokio_tungstenite::tungstenite::Message::Binary(ping.encode_to_vec().into())).await.is_err() { tracing::warn!("Feishu ping failed, reconnecting"); break; } } _ = timeout_check.tick() => { if last_recv.elapsed() > WS_HEARTBEAT_TIMEOUT { tracing::warn!("Feishu WebSocket heartbeat timeout, reconnecting"); break; } // GC dedup cache: remove entries older than TTL (matches zeroclaw pattern) let now = Instant::now(); let mut seen = self.seen_message_ids.write().await; seen.retain(|_, ts| now.duration_since(*ts) < DEDUP_CACHE_TTL); } _ = shutdown.cancelled() => { tracing::info!("Feishu channel shutdown signal received"); break; } } } *self.connected.write().await = false; Ok(()) } } fn parse_post_content(content: &str) -> 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()); } } "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)); } "img" => { out.push("[image]".to_string()); } "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()); } } } } /// 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() && 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() } fn collect_post_image_keys(content: &str) -> Vec { fn visit(value: &serde_json::Value, keys: &mut Vec) { match value { serde_json::Value::Object(map) => { if let Some(image_key) = map.get("image_key").and_then(|v| v.as_str()) && !keys.iter().any(|k| k == image_key) { keys.push(image_key.to_string()); } for child in map.values() { visit(child, keys); } } serde_json::Value::Array(items) => { for item in items { visit(item, keys); } } _ => {} } } let Ok(parsed) = serde_json::from_str::(content) else { return Vec::new(); }; let mut keys = Vec::new(); visit(&parsed, &mut keys); keys } /// Extract text content from interactive card messages fn extract_interactive_content(content: &str) -> Result<(String, Option), ChannelError> { let parsed = match serde_json::from_str::(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()) && 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()) && let Some(title) = header.get("title").and_then(|t| t.as_object()) && 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) { 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), ChannelError> { let parsed = match serde_json::from_str::(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, 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(|child| child.as_object().and_then(|o| o.get("children")).is_some()) }) && 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)); } _ => {} } } fn mention_open_id(mention: &serde_json::Value) -> Option<&str> { mention .pointer("/id/open_id") .or_else(|| mention.get("open_id")) .and_then(|value| value.as_str()) } fn message_mentions_bot(message: &LarkMessage, bot_open_id: Option<&str>) -> bool { let Some(bot_open_id) = bot_open_id.filter(|value| !value.is_empty()) else { return false; }; if message .mentions .iter() .any(|mention| mention_open_id(mention) == Some(bot_open_id)) { return true; } fn post_contains_bot(value: &serde_json::Value, bot_open_id: &str) -> bool { match value { serde_json::Value::Object(map) => { let is_bot_mention = map.get("tag").and_then(|value| value.as_str()) == Some("at") && map .get("user_id") .or_else(|| map.get("open_id")) .and_then(|value| value.as_str()) == Some(bot_open_id); is_bot_mention || map .values() .any(|value| post_contains_bot(value, bot_open_id)) } serde_json::Value::Array(values) => values .iter() .any(|value| post_contains_bot(value, bot_open_id)), _ => false, } } serde_json::from_str::(&message.content) .is_ok_and(|content| post_contains_bot(&content, bot_open_id)) } /// Remove the bot's own placeholder while preserving human mentions as readable names. fn normalize_mentions( text: &str, mentions: &[serde_json::Value], bot_open_id: Option<&str>, ) -> String { let mut normalized = text.to_string(); for mention in mentions { let Some(key) = mention.get("key").and_then(|value| value.as_str()) else { continue; }; let replacement = if mention_open_id(mention) == bot_open_id { String::new() } else { mention .get("name") .and_then(|value| value.as_str()) .filter(|value| !value.is_empty()) .map(|name| format!("@{name}")) .unwrap_or_else(|| key.to_string()) }; normalized = normalized.replace(key, &replacement); } normalized.trim().to_string() } fn resolve_image_ext(content_type: &str) -> &str { match content_type { "image/png" => "png", "image/gif" => "gif", "image/webp" => "webp", "image/bmp" => "bmp", _ => "jpg", } } fn resolve_file_ext(content_json: &serde_json::Value) -> String { if let Some(name) = content_json.get("file_name").and_then(|v| v.as_str()) && let Some(ext) = std::path::Path::new(name) .extension() .and_then(|e| e.to_str()) { return ext.to_string(); } String::new() } async fn resolve_unique_path(dir: &Path, filename: &str) -> std::path::PathBuf { let candidate = dir.join(filename); if !tokio::fs::try_exists(&candidate).await.unwrap_or(false) { return candidate; } let stem = std::path::Path::new(filename) .file_stem() .and_then(|s| s.to_str()) .unwrap_or(filename); let ext = std::path::Path::new(filename) .extension() .and_then(|s| s.to_str()) .unwrap_or(""); let mut n = 1; loop { let candidate = if ext.is_empty() { dir.join(format!("{}({})", stem, n)) } else { dir.join(format!("{}({}).{}", stem, n, ext)) }; if !tokio::fs::try_exists(&candidate).await.unwrap_or(false) { return candidate; } n += 1; } } impl FeishuChannel { /// Build a Card JSON 2.0 interactive card with a single markdown element. fn build_card_content(markdown: &str) -> String { serde_json::json!({ "schema": "2.0", "body": { "elements": [{ "tag": "markdown", "content": markdown }] } }) .to_string() } /// Max byte-size for markdown content in a single card. /// Card payloads have a ~30 KB limit; leave margin for JSON envelope. const CARD_MARKDOWN_MAX_BYTES: usize = 28_000; /// Split markdown content into chunks that fit within the card size limit. /// Splits on line boundaries to avoid breaking markdown syntax. fn split_markdown_chunks(text: &str) -> Vec { if text.len() <= Self::CARD_MARKDOWN_MAX_BYTES { return vec![text.to_string()]; } let mut chunks: Vec = Vec::new(); let mut start = 0; while start < text.len() { if start + Self::CARD_MARKDOWN_MAX_BYTES >= text.len() { chunks.push(text[start..].to_string()); break; } let end = text.floor_char_boundary(start + Self::CARD_MARKDOWN_MAX_BYTES); let search_region = &text[start..end]; let split_at = search_region .rfind('\n') .map(|pos| start + pos + 1) .unwrap_or(end); let split_at = if text.is_char_boundary(split_at) { split_at } else { (start..split_at) .rev() .find(|&i| text.is_char_boundary(i)) .unwrap_or(start) }; if split_at <= start { let forced = (end..=text.len()) .find(|&i| text.is_char_boundary(i)) .unwrap_or(text.len()); chunks.push(text[start..forced].to_string()); start = forced; } else { chunks.push(text[start..split_at].to_string()); start = split_at; } } chunks } /// Send an interactive card message to Feishu. async fn send_interactive_card( &self, receive_id: &str, receive_id_type: &str, card_content: &str, ) -> Result { 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, data: Option, } #[derive(Deserialize)] struct SendData { message_id: 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 ))); } send_resp .data .map(|data| data.message_id) .filter(|message_id| !message_id.is_empty()) .ok_or_else(|| ChannelError::Other("Feishu send response has no message_id".into())) } async fn update_interactive_card( &self, message_id: &str, card_content: &str, ) -> Result<(), ChannelError> { let token = self.get_tenant_access_token().await?; let card: serde_json::Value = serde_json::from_str(card_content) .map_err(|error| ChannelError::Other(format!("Invalid card JSON: {error}")))?; let response = self .http_client .patch(format!("{}/im/v1/messages/{}", FEISHU_API_BASE, message_id)) .header("Content-Type", "application/json") .header("Authorization", format!("Bearer {}", token)) .json(&serde_json::json!({ "card": card })) .send() .await .map_err(|error| { ChannelError::ConnectionError(format!("Update card HTTP error: {error}")) })?; #[derive(Deserialize)] struct UpdateResp { code: i32, msg: String, } let result: UpdateResp = response.json().await.map_err(|error| { ChannelError::Other(format!("Parse update card response error: {error}")) })?; if result.code != 0 { return Err(ChannelError::Other(format!( "Update card failed: code={} msg={}", result.code, result.msg ))); } Ok(()) } } #[async_trait] trait FeishuTurnApi: Send { async fn create_card(&mut self, markdown: &str) -> Result; async fn update_card(&mut self, message_id: &str, markdown: &str) -> Result<(), ChannelError>; async fn cleanup(&mut self); } struct FeishuTurnBackend { channel: FeishuChannel, receive_id: String, receive_id_type: &'static str, metadata: HashMap, } #[async_trait] impl FeishuTurnApi for FeishuTurnBackend { async fn create_card(&mut self, markdown: &str) -> Result { let card = FeishuChannel::build_card_content(markdown); self.channel .send_interactive_card(&self.receive_id, self.receive_id_type, &card) .await } async fn update_card(&mut self, message_id: &str, markdown: &str) -> Result<(), ChannelError> { let card = FeishuChannel::build_card_content(markdown); self.channel .update_interactive_card(message_id, &card) .await } async fn cleanup(&mut self) { self.channel .remove_reaction_from_metadata(&self.metadata) .await; } } struct FeishuTurnSink { api: Box, message_id: Option, cleaned_up: bool, } impl FeishuTurnSink { fn new(api: Box) -> Self { Self { api, message_id: None, cleaned_up: false, } } async fn cleanup(&mut self) { if !self.cleaned_up { self.api.cleanup().await; self.cleaned_up = true; } } async fn send_chunks(&mut self, chunks: &[String]) -> Result<(), ChannelError> { for chunk in chunks { self.api.create_card(chunk).await?; } Ok(()) } async fn finish_snapshot(&mut self, snapshot: &TurnSnapshot) -> Result<(), ChannelError> { let markdown = render_feishu_turn(snapshot); let chunks = if markdown.is_empty() { Vec::new() } else { FeishuChannel::split_markdown_chunks(&markdown) }; let result = if chunks.is_empty() { Ok(()) } else if let Some(message_id) = self.message_id.clone() { match self.api.update_card(&message_id, &chunks[0]).await { Ok(()) => self.send_chunks(&chunks[1..]).await, Err(error) => { tracing::warn!(error = %error, "Final Feishu card update failed; sending complete fallback"); self.send_chunks(&chunks).await } } } else { self.send_chunks(&chunks).await }; self.cleanup().await; result } } #[async_trait] impl TurnSink for FeishuTurnSink { async fn update(&mut self, snapshot: &TurnSnapshot) -> Result<(), ChannelError> { let markdown = render_feishu_turn(snapshot); if markdown.is_empty() { return Ok(()); } let live_markdown = truncate_feishu_live_markdown(&markdown); if let Some(message_id) = self.message_id.clone() { self.api.update_card(&message_id, &live_markdown).await } else { self.message_id = Some(self.api.create_card(&live_markdown).await?); Ok(()) } } async fn finish(&mut self, snapshot: &TurnSnapshot) -> Result<(), ChannelError> { self.finish_snapshot(snapshot).await } async fn abort(&mut self, snapshot: &TurnSnapshot) -> Result<(), ChannelError> { self.finish_snapshot(snapshot).await } } fn render_feishu_turn(snapshot: &TurnSnapshot) -> String { let mut sections = Vec::new(); for block in &snapshot.blocks { match block { TurnBlock::Reasoning { text, .. } if !text.trim().is_empty() => { sections.push(format!( "> **思考过程**\n> {}", text.trim().replace('\n', "\n> ") )); } TurnBlock::Assistant { text, .. } if !text.trim().is_empty() => { sections.push(text.trim().to_string()); } TurnBlock::Tool { name, status, preview, .. } => { let status = match status { ToolStatus::Running => "执行中", ToolStatus::Completed => "已完成", ToolStatus::Failed => "失败", }; let mut section = format!("> 🔧 **{name}** · {status}"); if let Some(preview) = preview.as_deref().filter(|value| !value.trim().is_empty()) { section.push_str("\n> "); section.push_str(&preview.trim().replace('\n', "\n> ")); } sections.push(section); } _ => {} } } if sections.is_empty() { if snapshot.status == TurnStatus::Failed { sections.push(format!( "⚠️ 回复失败:{}", snapshot.error.as_deref().unwrap_or("未知错误") )); } else if snapshot.status == TurnStatus::Cancelled { sections.push("已停止生成。".to_string()); } else { return String::new(); } } let status = match snapshot.status { TurnStatus::Running => Some(match snapshot.phase { crate::session::TurnPhase::Queued => "排队中", crate::session::TurnPhase::Reasoning => "思考中", crate::session::TurnPhase::Responding => "生成中", crate::session::TurnPhase::Acting => "调用工具中", crate::session::TurnPhase::Finalizing => "收尾中", }), TurnStatus::Cancelled => Some("已停止"), TurnStatus::Failed => Some("失败"), TurnStatus::Completed => None, }; if let Some(status) = status { sections.push(format!("_{status}_")); } sections.join("\n\n") } fn truncate_feishu_live_markdown(markdown: &str) -> String { if markdown.len() <= FeishuChannel::CARD_MARKDOWN_MAX_BYTES { return markdown.to_string(); } const SUFFIX: &str = "\n\n_内容仍在生成,已暂时截断…_"; let limit = FeishuChannel::CARD_MARKDOWN_MAX_BYTES.saturating_sub(SUFFIX.len()); let boundary = markdown.floor_char_boundary(limit); format!("{}{SUFFIX}", &markdown[..boundary]) } #[async_trait] impl Channel for FeishuChannel { fn name(&self) -> &str { "feishu" } fn is_allowed(&self, sender_id: &str) -> bool { self.config.allow_from.iter().any(|allowed| { let allowed = allowed.trim(); allowed == "*" || allowed == sender_id }) } /// Handle an inbound message: check for slash commands first, then publish to bus async fn handle_and_publish( &self, bus: &Arc, msg: &crate::bus::InboundMessage, ) -> Result<(), ChannelError> { // All messages (including slash commands) go through the normal inbound flow // SessionManager handles session creation/reuse internally bus.publish_inbound(msg.clone()).await?; Ok(()) } async fn start(&self, bus: Arc) -> Result<(), ChannelError> { if self.config.app_id.is_empty() || self.config.app_secret.is_empty() { return Err(ChannelError::ConfigError( "Feishu app_id or app_secret is not configured".to_string(), )); } if self.config.require_mention { match self.refresh_bot_open_id().await { Ok(open_id) => { tracing::info!(bot_open_id = %open_id, "Resolved Feishu bot identity") } Err(error) => tracing::warn!( error = %error, "Failed to resolve Feishu bot identity; group messages will be ignored" ), } } let mut run_task = self.run_task.lock().await; if run_task.as_ref().is_some_and(|task| !task.is_finished()) { return Ok(()); } if let Some(finished) = run_task.take() { let _ = finished.await; } *self.running.write().await = true; let shutdown = CancellationToken::new(); *self.shutdown.write().await = Some(shutdown.clone()); let channel = self.clone(); let bus = bus.clone(); *run_task = Some(tokio::spawn(async move { let mut consecutive_failures = 0; let max_failures = 3; loop { if !*channel.running.read().await { break; } match channel.run_ws_loop(bus.clone(), shutdown.clone()).await { Ok(_) => { tracing::info!("Feishu WebSocket disconnected"); } Err(e) => { consecutive_failures += 1; tracing::error!(attempt = consecutive_failures, error = %e, "Feishu WebSocket error"); if consecutive_failures >= max_failures { tracing::error!("Feishu channel: max failures reached, stopping"); break; } } } if !*channel.running.read().await || shutdown.is_cancelled() { break; } tracing::info!("Feishu channel retrying in 5s..."); tokio::select! { _ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => {} _ = shutdown.cancelled() => break, } } *channel.running.write().await = false; tracing::info!("Feishu channel stopped"); })); tracing::info!("Feishu channel started"); Ok(()) } async fn stop(&self) -> Result<(), ChannelError> { *self.running.write().await = false; *self.connected.write().await = false; if let Some(shutdown) = self.shutdown.write().await.take() { shutdown.cancel(); } let task = { self.run_task.lock().await.take() }; if let Some(mut task) = task { match tokio::time::timeout(CHANNEL_STOP_GRACE, &mut task).await { Ok(result) => result.map_err(|error| { ChannelError::Other(format!("Feishu channel task failed to join: {error}")) })?, Err(_) => { tracing::warn!( grace_ms = CHANNEL_STOP_GRACE.as_millis(), "Feishu channel did not stop in time; aborting connection task" ); task.abort(); let _ = task.await; } } } Ok(()) } fn is_running(&self) -> bool { self.running.try_read().map(|r| *r).unwrap_or(false) } fn live_policy(&self) -> LivePolicy { if self.config.live_updates { LivePolicy::Snapshot { min_interval: Duration::from_millis( self.config.live_update_interval_ms.clamp(250, 5_000), ), } } else { LivePolicy::FinalOnly } } fn presentation_policy(&self) -> crate::delivery::PresentationPolicy { crate::delivery::PresentationPolicy::external(self.config.live_updates) } async fn open_turn(&self, target: TurnTarget) -> Result, ChannelError> { let (receive_id, receive_id_type) = if target.chat_id.starts_with("oc_") { (target.chat_id, "chat_id") } else { (target.reply_to.unwrap_or(target.chat_id), "open_id") }; Ok(Box::new(FeishuTurnSink::new(Box::new(FeishuTurnBackend { channel: self.clone(), receive_id, receive_id_type, metadata: target.metadata, })))) } async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> { 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" }; // If no media, send as interactive card with raw markdown if msg.media.is_empty() { let content = msg.content.trim(); // Empty content if content.is_empty() { self.remove_reaction_from_metadata(&msg.metadata).await; return Ok(()); } let chunks = Self::split_markdown_chunks(content); for chunk in &chunks { let card = Self::build_card_content(chunk); if let Err(e) = self .send_interactive_card(receive_id, receive_id_type, &card) .await { tracing::warn!(error = %e, "Failed to send interactive card, falling back to text"); let text_content = serde_json::json!({ "text": chunk }).to_string(); let result = self .send_message_to_feishu(receive_id, receive_id_type, "text", &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 let token = self.get_tenant_access_token().await?; // Separate images (can embed in post) from files (sent as separate messages) let mut image_items = Vec::new(); let mut file_items = Vec::new(); for media_item in &msg.media { match media_item.media_type.as_str() { "image" => image_items.push(media_item), "audio" | "video" | "file" => file_items.push(media_item), _ => { tracing::warn!(media_type = %media_item.media_type, "Unsupported media type for sending"); } } } // Upload and send files as separate messages (one per file) for item in &file_items { match self.upload_file(&item.path).await { Ok(file_key) => { let file_msg_type = match item.media_type.as_str() { "audio" => "audio", "video" => "media", _ => "file", }; let file_content = serde_json::json!({"file_key": file_key}).to_string(); if let Err(e) = self .send_message_to_feishu( receive_id, receive_id_type, file_msg_type, &file_content, ) .await { tracing::warn!(error = %e, msg_type = file_msg_type, "Failed to send file message"); } } Err(e) => { tracing::warn!(error = %e, path = %item.path, "Failed to upload file"); } } } // Build content parts for post (text + images) let mut content_parts = Vec::new(); if !msg.content.is_empty() { const MAX_TEXT_LENGTH: usize = 60_000; let truncated_text = if msg.content.len() > MAX_TEXT_LENGTH { format!( "{}...\n\n[Content truncated due to length limit]", &msg.content[..msg.content.ceil_char_boundary(MAX_TEXT_LENGTH)] ) } else { msg.content.clone() }; content_parts.push(serde_json::json!({ "tag": "text", "text": truncated_text })); } for item in &image_items { match self.upload_image(&item.path).await { Ok(image_key) => { content_parts.push(serde_json::json!({ "tag": "img", "image_key": image_key })); } Err(e) => { tracing::warn!(error = %e, path = %item.path, "Failed to upload image"); } } } // If no post content after processing (no text, no images), skip if content_parts.is_empty() { self.remove_reaction_from_metadata(&msg.metadata).await; return Ok(()); } // Determine message type and build content let msg_type = if msg.content.is_empty() && image_items.len() == 1 { "image" } else { "post" }; let content = if msg_type == "image" { // Image-only: content is just {"image_key": "..."} let image_key = content_parts[0]["image_key"].as_str().unwrap_or(""); serde_json::json!({"image_key": image_key}).to_string() } else { // Post with media: zh_cn wrapped post structure let post_content: Vec> = content_parts.into_iter().map(|part| vec![part]).collect(); serde_json::json!({ "zh_cn": { "title": "", "content": post_content } }) .to_string() }; 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": msg_type, "content": content })) .send() .await .map_err(|e| { ChannelError::ConnectionError(format!("Send multimodal message HTTP error: {}", e)) })?; let send_status = resp.status(); let send_body = resp .text() .await .map_err(|e| ChannelError::Other(format!("Failed to read send response: {}", e)))?; tracing::debug!(status = %send_status, body = %send_body, msg_type = %msg_type, "Feishu send message"); #[derive(Deserialize)] struct SendResp { code: i32, msg: String, } let send_resp: SendResp = serde_json::from_str(&send_body).map_err(|e| { ChannelError::Other(format!( "Parse send response error: {} | body: {}", e, &send_body )) })?; if send_resp.code != 0 { return Err(ChannelError::Other(format!( "Send multimodal message failed: code={} msg={}", send_resp.code, send_resp.msg ))); } // Remove pending reaction after successfully sending self.remove_reaction_from_metadata(&msg.metadata).await; Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::agent::TurnEvent; use crate::delivery::{PresentationPolicy, project_snapshot}; #[derive(Default)] struct MockTurnState { created: Vec, updated: Vec<(String, String)>, cleanups: usize, fail_updates: usize, } struct MockTurnApi { state: Arc>, } #[async_trait] impl FeishuTurnApi for MockTurnApi { async fn create_card(&mut self, markdown: &str) -> Result { let mut state = self.state.lock().await; state.created.push(markdown.to_string()); Ok(format!("card-{}", state.created.len())) } async fn update_card( &mut self, message_id: &str, markdown: &str, ) -> Result<(), ChannelError> { let mut state = self.state.lock().await; if state.fail_updates > 0 { state.fail_updates -= 1; return Err(ChannelError::Other("card can no longer be edited".into())); } state .updated .push((message_id.to_string(), markdown.to_string())); Ok(()) } async fn cleanup(&mut self) { self.state.lock().await.cleanups += 1; } } fn mock_sink(state: Arc>) -> FeishuTurnSink { FeishuTurnSink::new(Box::new(MockTurnApi { state })) } fn test_channel() -> FeishuChannel { FeishuChannel::new( FeishuChannelConfig { enabled: true, app_id: "test-app".to_string(), app_secret: "test-secret".to_string(), allow_from: vec!["*".to_string()], require_mention: true, agent: String::new(), media_dir: String::new(), reaction_emoji: "THUMBSUP".to_string(), live_updates: false, live_update_interval_ms: 500, }, Path::new("/tmp"), ) .expect("test channel should be valid") } fn inbound_frame( message_id: &str, sender_id: &str, chat_type: &str, text: &str, mentions: serde_json::Value, ) -> PbFrame { PbFrame { seq_id: 1, log_id: 1, service: 1, method: 1, headers: vec![], payload: Some( serde_json::json!({ "header": { "event_type": "im.message.receive_v1", "event_id": format!("event-{message_id}") }, "event": { "sender": { "sender_id": { "open_id": sender_id }, "sender_type": "user" }, "message": { "message_id": message_id, "chat_id": "oc_test", "chat_type": chat_type, "message_type": "text", "content": serde_json::json!({ "text": text }).to_string(), "mentions": mentions } } }) .to_string() .into_bytes(), ), } } #[tokio::test] async fn inbound_admission_enforces_allowlist_before_parsing() { let mut channel = test_channel(); channel.config.allow_from = vec!["ou_allowed".to_string()]; let frame = inbound_frame( "om_denied", "ou_denied", "p2p", "hello", serde_json::json!([]), ); assert!(channel.handle_frame(&frame).await.unwrap().is_none()); assert!(channel.seen_message_ids.read().await.is_empty()); } #[tokio::test] async fn group_message_requires_bot_mention_and_removes_only_self_mention() { let channel = test_channel(); *channel.bot_open_id.write().await = Some("ou_bot".to_string()); let ignored = inbound_frame( "om_ignored", "ou_user", "group", "hello", serde_json::json!([]), ); assert!(channel.handle_frame(&ignored).await.unwrap().is_none()); let admitted = inbound_frame( "om_admitted", "ou_user", "group", "@_user_1 ask @_user_2", serde_json::json!([ {"key": "@_user_1", "id": {"open_id": "ou_bot"}, "name": "PicoBot"}, {"key": "@_user_2", "id": {"open_id": "ou_peer"}, "name": "Alice"} ]), ); let parsed = channel .handle_frame(&admitted) .await .unwrap() .expect("mentioned group message should be admitted"); assert_eq!(parsed.content, "ask @Alice"); } #[test] fn post_mentions_can_gate_group_messages_when_top_level_mentions_are_absent() { let message = LarkMessage { message_id: "om_post".to_string(), chat_id: "oc_test".to_string(), chat_type: "group".to_string(), message_type: "post".to_string(), content: serde_json::json!({ "zh_cn": {"content": [[{"tag": "at", "user_id": "ou_bot"}]]} }) .to_string(), parent_id: None, mentions: vec![], }; assert!(message_mentions_bot(&message, Some("ou_bot"))); assert!(!message_mentions_bot(&message, Some("ou_other"))); } #[tokio::test] async fn turn_sink_creates_once_updates_same_card_and_cleans_up_at_finish() { let state = Arc::new(Mutex::new(MockTurnState::default())); let mut sink = mock_sink(state.clone()); let (controller, emitter, _) = crate::session::TurnController::start("feishu:chat:dialog", "message"); emitter .emit(TurnEvent::TextDelta { iteration: 0, delta: "hello".into(), }) .unwrap(); sink.update(&project_snapshot( &controller.snapshot(), PresentationPolicy::external(true), )) .await .unwrap(); emitter .emit(TurnEvent::TextDelta { iteration: 0, delta: " world".into(), }) .unwrap(); sink.update(&project_snapshot( &controller.snapshot(), PresentationPolicy::external(true), )) .await .unwrap(); controller.complete(None); sink.finish(&project_snapshot( &controller.snapshot(), PresentationPolicy::external(true), )) .await .unwrap(); let state = state.lock().await; assert_eq!(state.created.len(), 1); assert_eq!(state.updated.len(), 2); assert!(state.updated.iter().all(|(id, _)| id == "card-1")); assert!(state.updated.last().unwrap().1.contains("hello world")); assert_eq!(state.cleanups, 1); } #[tokio::test] async fn final_update_failure_sends_complete_fallback_and_cleanup_is_idempotent() { let state = Arc::new(Mutex::new(MockTurnState::default())); let mut sink = mock_sink(state.clone()); let (controller, emitter, _) = crate::session::TurnController::start("feishu:chat:dialog", "message"); emitter .emit(TurnEvent::TextDelta { iteration: 0, delta: "partial".into(), }) .unwrap(); sink.update(&controller.snapshot()).await.unwrap(); state.lock().await.fail_updates = 1; emitter .emit(TurnEvent::TextDelta { iteration: 0, delta: " final".into(), }) .unwrap(); controller.complete(None); sink.finish(&controller.snapshot()).await.unwrap(); sink.finish(&controller.snapshot()).await.unwrap(); let state = state.lock().await; assert_eq!(state.created.len(), 2); assert!(state.created[1].contains("partial final")); assert_eq!(state.cleanups, 1); } #[tokio::test] async fn final_only_sink_sends_no_fragments_and_abort_without_text_is_visible() { let state = Arc::new(Mutex::new(MockTurnState::default())); let mut sink = mock_sink(state.clone()); let (controller, _emitter, _) = crate::session::TurnController::start("feishu:chat:dialog", "message"); controller.fail("provider unavailable"); sink.abort(&controller.snapshot()).await.unwrap(); let state = state.lock().await; assert_eq!(state.created.len(), 1); assert!(state.created[0].contains("provider unavailable")); assert_eq!(state.updated.len(), 0); assert_eq!(state.cleanups, 1); } #[test] fn external_projection_removes_reasoning_before_feishu_rendering() { let (controller, emitter, _) = crate::session::TurnController::start("feishu:chat:dialog", "message"); emitter .emit(TurnEvent::ReasoningDelta { iteration: 0, delta: "private".into(), }) .unwrap(); emitter .emit(TurnEvent::TextDelta { iteration: 0, delta: "public".into(), }) .unwrap(); let projected = project_snapshot(&controller.snapshot(), PresentationPolicy::external(true)); let markdown = render_feishu_turn(&projected); assert!(markdown.contains("public")); assert!(!markdown.contains("private")); } #[test] fn live_card_truncation_preserves_utf8_and_payload_limit() { let markdown = "你".repeat(FeishuChannel::CARD_MARKDOWN_MAX_BYTES); let truncated = truncate_feishu_live_markdown(&markdown); assert!(truncated.len() <= FeishuChannel::CARD_MARKDOWN_MAX_BYTES); assert!(truncated.ends_with("_内容仍在生成,已暂时截断…_")); } #[test] fn final_card_chunking_preserves_long_utf8_content() { let markdown = "你".repeat(FeishuChannel::CARD_MARKDOWN_MAX_BYTES); let chunks = FeishuChannel::split_markdown_chunks(&markdown); assert!(chunks.len() > 1); assert!( chunks .iter() .all(|chunk| chunk.len() <= FeishuChannel::CARD_MARKDOWN_MAX_BYTES) ); assert_eq!(chunks.concat(), markdown); } #[test] fn live_policy_uses_configured_bounded_interval() { let mut channel = test_channel(); assert_eq!(channel.live_policy(), LivePolicy::FinalOnly); channel.config.live_updates = true; channel.config.live_update_interval_ms = 10; assert_eq!( channel.live_policy(), LivePolicy::Snapshot { min_interval: Duration::from_millis(250) } ); } #[tokio::test] async fn stop_aborts_connection_task_that_ignores_cancellation() { let channel = test_channel(); let shutdown = CancellationToken::new(); *channel.running.write().await = true; *channel.connected.write().await = true; *channel.shutdown.write().await = Some(shutdown.clone()); *channel.run_task.lock().await = Some(tokio::spawn(std::future::pending())); tokio::time::timeout(Duration::from_secs(1), channel.stop()) .await .expect("stop must have a hard deadline") .expect("stop should succeed after aborting the stuck task"); assert!(shutdown.is_cancelled()); assert!(!channel.is_running()); assert!(!*channel.connected.read().await); assert!(channel.run_task.lock().await.is_none()); } #[test] fn collect_post_image_keys_finds_nested_images() { let content = serde_json::json!({ "zh_cn": { "title": "", "content": [[ {"tag": "img", "image_key": "img_v3_001"}, {"tag": "text", "text": "这是哪里?"}, {"tag": "img", "image_key": "img_v3_002"}, {"tag": "img", "image_key": "img_v3_001"} ]] } }) .to_string(); assert_eq!( collect_post_image_keys(&content), vec!["img_v3_001".to_string(), "img_v3_002".to_string()] ); } #[test] fn parse_post_content_preserves_image_positions() { let content = serde_json::json!({ "zh_cn": { "title": "", "content": [[ {"tag": "text", "text": "这是一张图:"}, {"tag": "img", "image_key": "img_v3_001"}, {"tag": "text", "text": "看完继续说"} ]] } }) .to_string(); assert_eq!( parse_post_content(&content), "这是一张图:[image]看完继续说" ); } }