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); const WS_EVENT_QUEUE_CAPACITY: usize = 32; const WS_ACK_TIMEOUT: Duration = Duration::from_secs(2); const WS_FRAGMENT_TTL: Duration = Duration::from_secs(5 * 60); const MAX_WS_FRAGMENTS: usize = 256; const MAX_WS_EVENT_BYTES: usize = 8 * 1024 * 1024; const STABLE_CONNECTION_WINDOW: Duration = Duration::from_secs(30); const FEISHU_API_ATTEMPTS: usize = 3; const FEISHU_INVALID_TOKEN_CODE: i32 = 99_991_663; // ───────────────────────────────────────────────────────────────────────────── // 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>, } impl PbFrame { fn header_value(&self, key: &str) -> Option<&str> { self.headers .iter() .find(|header| header.key == key) .map(|header| header.value.as_str()) } } struct FragmentAssembly { parts: Vec>>, total_bytes: usize, created_at: Instant, } /// 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)] root_id: Option, #[serde(default)] thread_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, root_id: Option, thread_id: Option, chat_type: String, } #[derive(Clone, Debug, PartialEq, Eq)] struct FeishuSendTarget { receive_id: String, receive_id_type: &'static str, reply_to: Option, reply_in_thread: bool, } #[derive(Deserialize)] struct FeishuMessageResponse { code: i32, msg: String, data: Option, } #[derive(Deserialize)] struct FeishuMessageData { message_id: String, } impl FeishuSendTarget { fn from_message( chat_id: String, reply_to: Option, metadata: &HashMap, ) -> Self { let thread_id = metadata .get("feishu.thread_id") .filter(|value| !value.is_empty()); if reply_to.is_none() && let Some(thread_id) = thread_id { return Self { receive_id: thread_id.clone(), receive_id_type: "thread_id", reply_to: None, reply_in_thread: false, }; } let receive_id_type = if chat_id.starts_with("oc_") { "chat_id" } else { "open_id" }; Self { receive_id: chat_id, receive_id_type, reply_to, reply_in_thread: thread_id.is_some(), } } fn request(&self, msg_type: &str, content: &str) -> (String, serde_json::Value) { let uuid = uuid::Uuid::new_v4().to_string(); if let Some(message_id) = &self.reply_to { return ( format!("{}/im/v1/messages/{message_id}/reply", FEISHU_API_BASE), serde_json::json!({ "msg_type": msg_type, "content": content, "reply_in_thread": self.reply_in_thread, "uuid": uuid }), ); } ( format!( "{}/im/v1/messages?receive_id_type={}", FEISHU_API_BASE, self.receive_id_type ), serde_json::json!({ "receive_id": self.receive_id, "msg_type": msg_type, "content": content, "uuid": uuid }), ) } } 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(); let request_timeout = Duration::from_secs(config.request_timeout_secs.clamp(5, 120)); let http_client = reqwest::Client::builder() .timeout(request_timeout) .build() .map_err(|error| { ChannelError::ConfigError(format!("Failed to build Feishu HTTP client: {error}")) })?; Ok(Self { config, http_client, 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 invalidate_tenant_token(&self) { *self.tenant_token.write().await = None; } fn request_timeout(&self) -> Duration { Duration::from_secs(self.config.request_timeout_secs.clamp(5, 120)) } async fn send_authenticated( &self, operation: &str, build: F, ) -> Result where F: Fn(&reqwest::Client, &str) -> reqwest::RequestBuilder, { for attempt in 0..FEISHU_API_ATTEMPTS { let token = self.get_tenant_access_token().await?; match build(&self.http_client, &token).send().await { Ok(response) => { let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED { self.invalidate_tenant_token().await; } if status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error() || status == reqwest::StatusCode::UNAUTHORIZED { if attempt + 1 < FEISHU_API_ATTEMPTS { let delay = retry_after_delay(response.headers(), attempt); tokio::time::sleep(delay).await; continue; } return Err(ChannelError::ConnectionError(format!( "{operation} failed after {} attempts: HTTP {status}", attempt + 1 ))); } return Ok(response); } Err(error) => { if attempt + 1 == FEISHU_API_ATTEMPTS { return Err(ChannelError::ConnectionError(format!( "{operation} failed after {} attempts: {error}", attempt + 1 ))); } tokio::time::sleep(api_retry_delay(attempt)).await; } } } unreachable!("Feishu API attempt loop always returns") } async fn refresh_bot_open_id(&self) -> Result { let response = self .send_authenticated("fetch Feishu bot info", |client, token| { client .get(format!("{}/bot/v3/info", FEISHU_API_BASE)) .bearer_auth(token) }) .await?; 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()))?; // 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 .send_authenticated("download Feishu image", |client, token| { client.get(&url).bearer_auth(token) }) .await?; let status = resp.status(); #[cfg(debug_assertions)] tracing::debug!(status = %status, "Image download response status"); if !status.is_success() { let error_body = read_response_limited(resp, 64 * 1024, self.request_timeout(), "error response") .await .unwrap_or_default(); return Err(ChannelError::Other(format!( "Image download failed {}: {}", status, String::from_utf8_lossy(&error_body) ))); } 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 = read_response_limited( resp, self.config.max_image_bytes, self.request_timeout(), "image", ) .await?; #[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; ensure_media_capacity( media_dir, data.len() as u64, self.config.media_dir_max_bytes, ) .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()))?; // 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 .send_authenticated("download Feishu file", |client, token| { client.get(&url).bearer_auth(token) }) .await?; let status = resp.status(); if !status.is_success() { let error_body = read_response_limited(resp, 64 * 1024, self.request_timeout(), "error response") .await .unwrap_or_default(); return Err(ChannelError::Other(format!( "File download failed {}: {}", status, String::from_utf8_lossy(&error_body) ))); } let data = read_response_limited( resp, self.config.max_file_bytes, self.request_timeout(), file_type, ) .await?; let filename = content_json .get("file_name") .and_then(|v| v.as_str()) .map(sanitize_filename) .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; ensure_media_capacity( media_dir, data.len() as u64, self.config.media_dir_max_bytes, ) .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 { ensure_local_media_size(file_path, self.config.max_image_bytes, "image").await?; 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 { ensure_local_media_size(file_path, self.config.max_file_bytes, "file").await?; 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.trim(); if emoji.is_empty() { return Ok(None); } let resp = self .send_authenticated("add Feishu reaction", |client, token| { client .post(format!( "{}/im/v1/messages/{}/reactions", FEISHU_API_BASE, message_id )) .bearer_auth(token) .json(&serde_json::json!({ "reaction_type": { "emoji_type": emoji } })) }) .await?; #[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 resp = self .send_authenticated("remove Feishu reaction", |client, token| { client .delete(format!( "{}/im/v1/messages/{}/reactions/{}", FEISHU_API_BASE, message_id, reaction_id )) .bearer_auth(token) }) .await?; #[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 resp = self .send_authenticated("fetch Feishu parent message", |client, token| { client .get(format!("{}/im/v1/messages/{}", FEISHU_API_BASE, message_id)) .bearer_auth(token) }) .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[..text.floor_char_boundary(Self::REPLY_CONTEXT_MAX_LEN)] ) } else { text }; Some(format!("[Reply to: {}]", text)) } /// Deliver one native Feishu message using create or reply semantics. async fn send_raw_message( &self, target: &FeishuSendTarget, msg_type: &str, content: &str, ) -> Result { let mut active_target = target.clone(); let mut token_refreshes = 0; loop { let (url, body) = active_target.request(msg_type, content); let response = self .send_authenticated("send Feishu message", |client, token| { client.post(url.clone()).bearer_auth(token).json(&body) }) .await?; let result: FeishuMessageResponse = response.json().await.map_err(|error| { ChannelError::Other(format!("Parse send response error: {error}")) })?; if result.code == FEISHU_INVALID_TOKEN_CODE && token_refreshes == 0 { token_refreshes += 1; self.invalidate_tenant_token().await; continue; } if matches!(result.code, 230011 | 231003) && active_target.reply_to.is_some() && !active_target.reply_in_thread { tracing::warn!( code = result.code, "Feishu reply target is unavailable; falling back to a new chat message" ); active_target.reply_to = None; continue; } if result.code != 0 { return Err(ChannelError::Other(format!( "Send message failed: code={} msg={}", result.code, result.msg ))); } return Ok(result.data.map_or_else(String::new, |data| data.message_id)); } } /// 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) } /// Parse one complete event frame. Transport ACK and fragment handling happen earlier. 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(); let root_id = msg.root_id.clone(); let thread_id = msg.thread_id.clone().or_else(|| root_id.clone()); let chat_type = msg.chat_type.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, root_id, thread_id, chat_type, })) } /// 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)) } async fn process_event_frame(&self, bus: &Arc, frame: &PbFrame) { let parsed = match self.handle_frame(frame).await { Ok(Some(parsed)) => parsed, Ok(None) => return, Err(error) => { tracing::warn!(error = %error, "Failed to parse Feishu frame"); return; } }; let message_id = parsed.message_id.clone(); let reaction_id = match self.add_reaction(&message_id).await { Ok(Some(reaction_id)) => Some(reaction_id), Ok(None) => None, Err(error) => { tracing::debug!(error = %error, message_id = %message_id, "Failed to add reaction"); None } }; let mut private_context = HashMap::new(); private_context.insert("feishu.message_id".to_string(), message_id.clone()); private_context.insert("feishu.chat_type".to_string(), parsed.chat_type); if let Some(parent_id) = parsed.parent_id.as_ref() { private_context.insert("feishu.parent_id".to_string(), parent_id.clone()); } if let Some(root_id) = parsed.root_id { private_context.insert("feishu.root_id".to_string(), root_id); } if let Some(thread_id) = parsed.thread_id { private_context.insert("feishu.thread_id".to_string(), thread_id); } if let Some(reaction_id) = reaction_id { private_context.insert("feishu.reaction_id".to_string(), reaction_id); } let msg = crate::bus::InboundMessage { channel: "feishu".to_string(), sender_id: parsed.open_id.clone(), chat_id: parsed.chat_id.clone(), content: parsed.content, received_at: crate::bus::message::current_timestamp(), media: parsed.media, channel_context: crate::bus::ChannelContext { reply_to: Some(message_id), private: private_context, }, }; if let Err(error) = self.handle_and_publish(bus, &msg).await { tracing::error!( error = %error, open_id = %parsed.open_id, chat_id = %parsed.chat_id, "Failed to publish Feishu message to bus" ); } } /// 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 { let (wss_url, client_config) = tokio::select! { result = self.get_ws_endpoint(&self.http_client) => result?, _ = shutdown.cancelled() => return Ok(Duration::ZERO), }; let service_id = Self::extract_service_id(&wss_url); tracing::info!(service_id, "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(Duration::ZERO), }; let connected_at = Instant::now(); *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(connected_at.elapsed()), }; let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(WS_EVENT_QUEUE_CAPACITY); let worker_channel = self.clone(); let worker_bus = bus.clone(); let mut event_worker = tokio::spawn(async move { while let Some(frame) = event_rx.recv().await { worker_channel .process_event_frame(&worker_bus, &frame) .await; } }); 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(); let mut fragment_cache: HashMap = HashMap::new(); // 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()) { if frame.method != 1 { continue; } match tokio::time::timeout( WS_ACK_TIMEOUT, Self::send_ack(&frame, &mut write), ) .await { Ok(Ok(())) => {} Ok(Err(error)) => { tracing::warn!(error = %error, "Failed to ACK Feishu DATA frame"); break; } Err(_) => { tracing::warn!("Timed out ACKing Feishu DATA frame"); break; } } match reassemble_data_frame(&frame, &mut fragment_cache) { Ok(Some(complete_frame)) => { match event_tx.try_send(complete_frame) { Ok(()) => {} Err(tokio::sync::mpsc::error::TrySendError::Full(frame)) => { tracing::warn!( capacity = WS_EVENT_QUEUE_CAPACITY, "Feishu event queue is full; processing one event on the connection task" ); self.process_event_frame(&bus, &frame).await; } Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { tracing::warn!("Feishu event worker stopped unexpectedly"); break; } } } Ok(None) => {} Err(error) => { tracing::warn!(error = %error, "Rejected invalid Feishu fragment sequence"); } } } } 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); fragment_cache.retain(|_, assembly| { now.duration_since(assembly.created_at) < WS_FRAGMENT_TTL }); } _ = shutdown.cancelled() => { tracing::info!("Feishu channel shutdown signal received"); break; } } } *self.connected.write().await = false; drop(event_tx); if tokio::time::timeout(CHANNEL_STOP_GRACE, &mut event_worker) .await .is_err() { event_worker.abort(); let _ = event_worker.await; } Ok(connected_at.elapsed()) } } fn reassemble_data_frame( frame: &PbFrame, cache: &mut HashMap, ) -> Result, ChannelError> { let sum = frame .header_value("sum") .and_then(|value| value.parse::().ok()) .unwrap_or(1) .max(1); if sum == 1 { if frame .payload .as_ref() .is_some_and(|payload| payload.len() > MAX_WS_EVENT_BYTES) { return Err(ChannelError::Other(format!( "Feishu event exceeds {MAX_WS_EVENT_BYTES} byte limit" ))); } return Ok(Some(frame.clone())); } if sum > MAX_WS_FRAGMENTS { return Err(ChannelError::Other(format!( "Feishu event declares too many fragments: {sum}" ))); } let message_id = frame.header_value("message_id").unwrap_or_default(); if message_id.is_empty() { return Err(ChannelError::Other( "Fragmented Feishu event has no message_id".to_string(), )); } let seq = frame .header_value("seq") .and_then(|value| value.parse::().ok()) .unwrap_or(0); if seq >= sum { return Err(ChannelError::Other(format!( "Feishu fragment index {seq} is outside declared count {sum}" ))); } let assembly = cache .entry(message_id.to_string()) .or_insert_with(|| FragmentAssembly { parts: vec![None; sum], total_bytes: 0, created_at: Instant::now(), }); if assembly.parts.len() != sum { *assembly = FragmentAssembly { parts: vec![None; sum], total_bytes: 0, created_at: Instant::now(), }; } let payload = frame.payload.clone().unwrap_or_default(); let previous_len = assembly.parts[seq].as_ref().map_or(0, Vec::len); assembly.total_bytes = assembly.total_bytes - previous_len + payload.len(); if assembly.total_bytes > MAX_WS_EVENT_BYTES { cache.remove(message_id); return Err(ChannelError::Other(format!( "Feishu fragmented event exceeds {MAX_WS_EVENT_BYTES} byte limit" ))); } assembly.parts[seq] = Some(payload); if assembly.parts.iter().any(Option::is_none) { return Ok(None); } let complete_payload = assembly .parts .iter() .flat_map(|part| part.as_deref().unwrap_or_default()) .copied() .collect(); cache.remove(message_id); let mut complete = frame.clone(); complete.payload = Some(complete_payload); Ok(Some(complete)) } fn reconnect_delay(attempt: u32) -> Duration { let exponent = attempt.saturating_sub(1).min(6); Duration::from_secs(1_u64 << exponent) } fn api_retry_delay(attempt: usize) -> Duration { Duration::from_millis(250_u64.saturating_mul(1_u64 << attempt.min(4))) } fn retry_after_delay(headers: &reqwest::header::HeaderMap, attempt: usize) -> Duration { headers .get(reqwest::header::RETRY_AFTER) .and_then(|value| value.to_str().ok()) .and_then(|value| value.parse::().ok()) .map(|seconds| Duration::from_secs(seconds.min(5))) .unwrap_or_else(|| api_retry_delay(attempt)) } async fn read_response_limited( mut response: reqwest::Response, max_bytes: u64, timeout: Duration, media_type: &str, ) -> Result, ChannelError> { if response .content_length() .is_some_and(|length| length > max_bytes) { return Err(ChannelError::Other(format!( "Feishu {media_type} exceeds {max_bytes} byte limit" ))); } tokio::time::timeout(timeout, async move { let mut data = Vec::new(); while let Some(chunk) = response.chunk().await.map_err(|error| { ChannelError::ConnectionError(format!("Failed to read {media_type} data: {error}")) })? { if (data.len() as u64).saturating_add(chunk.len() as u64) > max_bytes { return Err(ChannelError::Other(format!( "Feishu {media_type} exceeds {max_bytes} byte limit" ))); } data.extend_from_slice(&chunk); } Ok(data) }) .await .map_err(|_| ChannelError::ConnectionError(format!("Feishu {media_type} download timed out")))? } async fn ensure_media_capacity( media_dir: &Path, incoming_bytes: u64, max_bytes: u64, ) -> Result<(), ChannelError> { let mut total = 0_u64; let mut entries = tokio::fs::read_dir(media_dir) .await .map_err(|error| ChannelError::Other(format!("Failed to inspect media dir: {error}")))?; while let Some(entry) = entries .next_entry() .await .map_err(|error| ChannelError::Other(format!("Failed to inspect media dir: {error}")))? { if let Ok(metadata) = entry.metadata().await && metadata.is_file() { total = total.saturating_add(metadata.len()); } } if total.saturating_add(incoming_bytes) > max_bytes { return Err(ChannelError::Other(format!( "Feishu media directory would exceed {max_bytes} byte limit" ))); } Ok(()) } async fn ensure_local_media_size( file_path: &str, max_bytes: u64, media_type: &str, ) -> Result<(), ChannelError> { let size = tokio::fs::metadata(file_path) .await .map_err(|error| ChannelError::Other(format!("Failed to inspect {media_type}: {error}")))? .len(); if size > max_bytes { return Err(ChannelError::Other(format!( "Outbound {media_type} is {size} bytes, exceeding {max_bytes} byte limit" ))); } Ok(()) } fn sanitize_filename(filename: &str) -> String { let basename = Path::new(filename) .file_name() .and_then(|value| value.to_str()) .unwrap_or("file.bin"); let sanitized: String = basename .chars() .map(|character| { if character.is_control() || matches!(character, '/' | '\\' | ':' | '\0') { '_' } else { character } }) .collect(); let sanitized = sanitized.trim_matches(['.', ' ']); if sanitized.is_empty() { "file.bin".to_string() } else { let boundary = sanitized.floor_char_boundary(sanitized.len().min(255)); sanitized[..boundary].to_string() } } 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, target: &FeishuSendTarget, card_content: &str, ) -> Result { let message_id = self .send_raw_message(target, "interactive", card_content) .await?; if message_id.is_empty() { Err(ChannelError::Other( "Feishu send response has no message_id".into(), )) } else { Ok(message_id) } } async fn update_interactive_card( &self, message_id: &str, card_content: &str, ) -> Result<(), ChannelError> { let card: serde_json::Value = serde_json::from_str(card_content) .map_err(|error| ChannelError::Other(format!("Invalid card JSON: {error}")))?; let response = self .send_authenticated("update Feishu card", |client, token| { client .patch(format!("{}/im/v1/messages/{}", FEISHU_API_BASE, message_id)) .bearer_auth(token) .json(&serde_json::json!({ "card": card })) }) .await?; #[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, target: FeishuSendTarget, 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.target, &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::Cancelled => "已停止", 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 retry_attempt = 0_u32; loop { if !*channel.running.read().await { break; } match channel.run_ws_loop(bus.clone(), shutdown.clone()).await { Ok(connected_for) => { if connected_for >= STABLE_CONNECTION_WINDOW { retry_attempt = 0; } else { retry_attempt = retry_attempt.saturating_add(1).min(7); } tracing::info!( connected_secs = connected_for.as_secs(), "Feishu WebSocket disconnected" ); } Err(e) => { retry_attempt = retry_attempt.saturating_add(1).min(6); tracing::error!(attempt = retry_attempt, error = %e, "Feishu WebSocket error"); } } if !*channel.running.read().await || shutdown.is_cancelled() { break; } let retry_delay = reconnect_delay(retry_attempt); tracing::info!( delay_secs = retry_delay.as_secs(), "Feishu channel reconnect scheduled" ); tokio::select! { _ = tokio::time::sleep(retry_delay) => {} _ = 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 send_target = FeishuSendTarget::from_message(target.chat_id, target.reply_to, &target.metadata); Ok(Box::new(FeishuTurnSink::new(Box::new(FeishuTurnBackend { channel: self.clone(), target: send_target, metadata: target.metadata, })))) } async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> { let target = FeishuSendTarget::from_message( msg.chat_id.clone(), msg.reply_to.clone(), &msg.metadata, ); // 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(&target, &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_raw_message(&target, "text", &text_content) .await .map(|_| ()); 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 // 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_raw_message(&target, 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() }; self.send_raw_message(&target, msg_type, &content).await?; // 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, max_image_bytes: 10 * 1024 * 1024, max_file_bytes: 25 * 1024 * 1024, media_dir_max_bytes: 512 * 1024 * 1024, request_timeout_secs: 30, }, 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"); } #[tokio::test] async fn inbound_context_replies_to_current_message_and_preserves_thread_metadata() { let mut channel = test_channel(); channel.config.reaction_emoji.clear(); *channel.bot_open_id.write().await = Some("ou_bot".to_string()); let mut frame = inbound_frame( "om_current", "ou_user", "group", "@_user_1 hello", serde_json::json!([ {"key": "@_user_1", "id": {"open_id": "ou_bot"}, "name": "PicoBot"} ]), ); let mut payload: serde_json::Value = serde_json::from_slice(frame.payload.as_deref().unwrap()).unwrap(); payload["event"]["message"]["root_id"] = serde_json::json!("om_root"); payload["event"]["message"]["thread_id"] = serde_json::json!("omt_thread"); frame.payload = Some(payload.to_string().into_bytes()); let bus = Arc::new(MessageBus::new(4)); channel.process_event_frame(&bus, &frame).await; let inbound = bus.consume_inbound().await.unwrap(); assert_eq!( inbound.channel_context.reply_to.as_deref(), Some("om_current") ); assert_eq!( inbound .channel_context .private .get("feishu.thread_id") .map(String::as_str), Some("omt_thread") ); assert_eq!( inbound .channel_context .private .get("feishu.root_id") .map(String::as_str), Some("om_root") ); } #[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, root_id: None, thread_id: None, mentions: vec![], }; assert!(message_mentions_bot(&message, Some("ou_bot"))); assert!(!message_mentions_bot(&message, Some("ou_other"))); } fn fragment_frame(message_id: &str, seq: usize, sum: usize, payload: &[u8]) -> PbFrame { PbFrame { seq_id: seq as u64, log_id: 1, service: 1, method: 1, headers: vec![ PbHeader { key: "message_id".to_string(), value: message_id.to_string(), }, PbHeader { key: "seq".to_string(), value: seq.to_string(), }, PbHeader { key: "sum".to_string(), value: sum.to_string(), }, ], payload: Some(payload.to_vec()), } } #[test] fn fragmented_frames_are_reassembled_in_sequence_order() { let mut cache = HashMap::new(); assert!( reassemble_data_frame(&fragment_frame("event", 1, 3, b"world"), &mut cache) .unwrap() .is_none() ); assert!( reassemble_data_frame(&fragment_frame("event", 0, 3, b"hello "), &mut cache) .unwrap() .is_none() ); let complete = reassemble_data_frame(&fragment_frame("event", 2, 3, b"!"), &mut cache) .unwrap() .expect("last fragment should complete the event"); assert_eq!( complete.payload.as_deref(), Some(b"hello world!".as_slice()) ); assert!(cache.is_empty()); } #[test] fn fragmented_frames_reject_invalid_shape_and_bound_total_size() { let mut cache = HashMap::new(); assert!( reassemble_data_frame( &fragment_frame("event", MAX_WS_FRAGMENTS, MAX_WS_FRAGMENTS, b"x"), &mut cache, ) .is_err() ); assert!( reassemble_data_frame( &fragment_frame("event", 0, 2, &vec![0; MAX_WS_EVENT_BYTES + 1]), &mut cache, ) .is_err() ); assert!(cache.is_empty()); } #[test] fn reconnect_backoff_is_bounded_and_resets_after_success() { assert_eq!(reconnect_delay(0), Duration::from_secs(1)); assert_eq!(reconnect_delay(1), Duration::from_secs(1)); assert_eq!(reconnect_delay(2), Duration::from_secs(2)); assert_eq!(reconnect_delay(7), Duration::from_secs(64)); assert_eq!(reconnect_delay(100), Duration::from_secs(64)); } #[test] fn send_target_uses_reply_endpoint_and_thread_flag() { let metadata = HashMap::from([("feishu.thread_id".to_string(), "omt_thread".to_string())]); let target = FeishuSendTarget::from_message( "oc_chat".to_string(), Some("om_current".to_string()), &metadata, ); let (url, body) = target.request("interactive", "{}"); assert!(url.ends_with("/im/v1/messages/om_current/reply")); assert_eq!(body["reply_in_thread"], true); assert_eq!(body["msg_type"], "interactive"); assert!(body.get("receive_id").is_none()); } #[test] fn proactive_thread_target_creates_inside_thread() { let metadata = HashMap::from([("feishu.thread_id".to_string(), "omt_thread".to_string())]); let target = FeishuSendTarget::from_message("oc_chat".to_string(), None, &metadata); let (url, body) = target.request("text", "{}"); assert!(url.ends_with("/im/v1/messages?receive_id_type=thread_id")); assert_eq!(body["receive_id"], "omt_thread"); } #[test] fn inbound_filename_is_reduced_to_a_safe_basename() { assert_eq!(sanitize_filename("../../secret.txt"), "secret.txt"); assert_eq!(sanitize_filename(".."), "file.bin"); assert_eq!(sanitize_filename("report:\0.txt"), "report__.txt"); } #[tokio::test] async fn media_capacity_and_file_size_are_checked_before_writing_or_uploading() { let dir = tempfile::tempdir().unwrap(); let existing = dir.path().join("existing.bin"); tokio::fs::write(&existing, vec![0_u8; 8]).await.unwrap(); ensure_media_capacity(dir.path(), 2, 10).await.unwrap(); assert!(ensure_media_capacity(dir.path(), 3, 10).await.is_err()); ensure_local_media_size(existing.to_str().unwrap(), 8, "file") .await .unwrap(); assert!( ensure_local_media_size(existing.to_str().unwrap(), 7, "file") .await .is_err() ); } #[tokio::test] async fn authenticated_requests_retry_transient_http_failures() { use std::sync::atomic::{AtomicUsize, Ordering}; let attempts = Arc::new(AtomicUsize::new(0)); let route_attempts = attempts.clone(); let app = axum::Router::new().route( "/test", axum::routing::get(move || { let route_attempts = route_attempts.clone(); async move { if route_attempts.fetch_add(1, Ordering::SeqCst) < 2 { axum::http::StatusCode::SERVICE_UNAVAILABLE } else { axum::http::StatusCode::OK } } }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); let channel = test_channel(); *channel.tenant_token.write().await = Some(CachedTenantToken { value: "test-token".to_string(), refresh_after: Instant::now() + Duration::from_secs(60), }); let url = format!("http://{address}/test"); let response = channel .send_authenticated("test request", |client, token| { client.get(&url).bearer_auth(token) }) .await .unwrap(); assert_eq!(response.status(), reqwest::StatusCode::OK); assert_eq!(attempts.load(Ordering::SeqCst), 3); server.abort(); } #[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]看完继续说" ); } }