PicoBot/src/channels/feishu.rs

3019 lines
105 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<PbHeader>,
#[prost(bytes = "vec", optional, tag = "8")]
pub payload: Option<Vec<u8>>,
}
/// POST /callback/ws/endpoint response
#[derive(Deserialize)]
struct WsEndpointResp {
code: i32,
msg: Option<String>,
data: Option<WsEndpoint>,
}
#[derive(Deserialize)]
struct WsEndpoint {
#[serde(rename = "URL")]
url: String,
#[serde(default)]
client_config: Option<WsClientConfig>,
}
#[derive(Deserialize, Default)]
struct WsClientConfig {
#[serde(rename = "PingInterval")]
ping_interval: Option<u64>,
}
/// 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<String>,
}
#[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<String>,
#[serde(default)]
mentions: Vec<serde_json::Value>,
}
// ─────────────────────────────────────────────────────────────────────────────
/// 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<RwLock<bool>>,
shutdown: Arc<RwLock<Option<CancellationToken>>>,
run_task: Arc<Mutex<Option<JoinHandle<()>>>>,
connected: Arc<RwLock<bool>>,
/// Cached tenant access token with proactive refresh.
tenant_token: Arc<RwLock<Option<CachedTenantToken>>>,
/// Dedup cache: WS message_ids seen in the last ~30 min.
seen_message_ids: Arc<RwLock<HashMap<String, Instant>>>,
/// Bot identity used to enforce group-chat @mention admission.
bot_open_id: Arc<RwLock<Option<String>>>,
}
/// Parsed message data from a Feishu frame
struct ParsedMessage {
message_id: String,
open_id: String,
chat_id: String,
content: String,
media: Vec<MediaItem>,
/// ID of the message this message is replying to (if any).
/// Used to fetch quoted message content for display.
parent_id: Option<String>,
}
impl FeishuChannel {
pub fn new(
mut config: FeishuChannelConfig,
workspace_dir: &Path,
) -> Result<Self, ChannelError> {
// 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<String, ChannelError> {
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<String, ChannelError> {
// 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<String>,
expire: Option<i64>,
}
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<MediaItem>), 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<MediaItem>), 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<MediaItem>), 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<String, ChannelError> {
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<String>,
data: Option<UploadData>,
}
#[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<String, ChannelError> {
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<String>,
data: Option<UploadData>,
}
#[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<Option<String>, 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<String>,
data: Option<ReactionData>,
}
#[derive(Deserialize)]
struct ReactionData {
reaction_id: Option<String>,
}
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<String, String>,
) {
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<String>,
}
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<String> {
let token = match self.get_tenant_access_token().await {
Ok(t) => t,
Err(e) => {
tracing::debug!(error = %e, message_id = %message_id, "Feishu: failed to get token for fetching parent message");
return None;
}
};
let resp = self
.http_client
.get(format!("{}/im/v1/messages/{}", FEISHU_API_BASE, message_id))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.ok()?;
#[derive(Deserialize)]
struct MessageResp {
code: i32,
msg: Option<String>,
data: Option<MessageData>,
}
#[derive(Deserialize)]
struct MessageData {
items: Option<Vec<MessageItem>>,
}
#[derive(Deserialize)]
struct MessageItem {
msg_type: String,
body: Option<MessageBody>,
}
#[derive(Deserialize)]
struct MessageBody {
content: Option<String>,
}
let result: MessageResp = match resp.json().await {
Ok(r) => r,
Err(e) => {
tracing::debug!(error = %e, message_id = %message_id, "Feishu: failed to parse parent message response");
return None;
}
};
if result.code != 0 {
tracing::debug!(
message_id = %message_id,
code = %result.code,
msg = ?result.msg,
"Feishu: failed to fetch parent message"
);
return None;
}
let items = result.data?.items?;
let msg_obj = items.first()?;
let raw_content = msg_obj.body.as_ref()?.content.as_ref()?;
let msg_type = msg_obj.msg_type.as_str();
let text = match msg_type {
"text" => serde_json::from_str::<serde_json::Value>(raw_content)
.ok()?
.get("text")?
.as_str()?
.to_string(),
"post" => parse_post_content(raw_content),
_ => String::new(),
};
if text.is_empty() {
return None;
}
let text = if text.len() > Self::REPLY_CONTEXT_MAX_LEN {
format!("{}...", &text[..Self::REPLY_CONTEXT_MAX_LEN])
} else {
text
};
Some(format!("[Reply to: {}]", text))
}
/// Send a message to Feishu chat with specified message type 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::<i32>().ok())
})
.unwrap_or(0)
}
/// Handle incoming binary PbFrame - returns Some(ParsedMessage) if we need to ack
async fn handle_frame(&self, frame: &PbFrame) -> Result<Option<ParsedMessage>, 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<MediaItem>), ChannelError> {
let (text, media) = match msg_type {
"text" => {
let text = if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(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::<serde_json::Value>(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::<serde_json::Value>(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::<serde_json::Value>(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::<serde_json::Value>(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::net::TcpStream>,
>,
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<MessageBus>,
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<String>) {
match el.get("tag").and_then(|t| t.as_str()).unwrap_or("") {
"text" => {
if let Some(text) = el.get("text").and_then(|t| t.as_str()) {
out.push(text.to_string());
}
}
"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<String>) {
let title = block
.get("title")
.and_then(|t| t.as_str())
.filter(|s| !s.is_empty());
if let Some(t) = title {
out.push(t.to_string());
out.push("\n\n".to_string());
}
if let Some(content_arr) = block.get("content").and_then(|c| c.as_array()) {
for row in content_arr {
if let Some(row_arr) = row.as_array() {
for el in row_arr {
extract_element(el, out);
}
out.push("\n".to_string());
}
}
}
}
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) else {
return content.to_string();
};
let mut texts = Vec::new();
// Unwrap optional {"post": ...} envelope (nanobot: root = root["post"])
let root = if parsed.get("post").and_then(|p| p.as_object()).is_some() {
parsed.get("post").unwrap()
} else {
&parsed
};
// Try direct format: {"title": ..., "content": [[...]]}
if root.get("content").and_then(|c| c.as_array()).is_some() {
parse_block(root, &mut texts);
let result = texts.join("");
if !result.trim().is_empty() {
return result.trim().to_string();
}
texts.clear();
}
// Try localized: {"zh_cn": {"title": ..., "content": [...]}}
for key in ["zh_cn", "en_us", "ja_jp"] {
if let Some(locale_data) = root.get(key).and_then(|l| l.as_object()) {
parse_block(&serde_json::json!(locale_data), &mut texts);
let result = texts.join("");
if !result.trim().is_empty() {
return result.trim().to_string();
}
texts.clear();
}
}
// Fall back: try any dict child
if let Some(root_obj) = root.as_object() {
for (_key, val) in root_obj {
if let Some(obj) = val.as_object()
&& 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<String> {
fn visit(value: &serde_json::Value, keys: &mut Vec<String>) {
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::<serde_json::Value>(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<MediaItem>), ChannelError> {
let parsed = match serde_json::from_str::<serde_json::Value>(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<String>) {
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<MediaItem>), ChannelError> {
let parsed = match serde_json::from_str::<serde_json::Value>(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<String>, 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::<serde_json::Value>(&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<String> {
if text.len() <= Self::CARD_MARKDOWN_MAX_BYTES {
return vec![text.to_string()];
}
let mut chunks: Vec<String> = 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<String, 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": "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<SendData>,
}
#[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<String, ChannelError>;
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<String, String>,
}
#[async_trait]
impl FeishuTurnApi for FeishuTurnBackend {
async fn create_card(&mut self, markdown: &str) -> Result<String, ChannelError> {
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<dyn FeishuTurnApi>,
message_id: Option<String>,
cleaned_up: bool,
}
impl FeishuTurnSink {
fn new(api: Box<dyn FeishuTurnApi>) -> 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<MessageBus>,
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<MessageBus>) -> 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<Box<dyn TurnSink>, 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<Vec<serde_json::Value>> =
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<String>,
updated: Vec<(String, String)>,
cleanups: usize,
fail_updates: usize,
}
struct MockTurnApi {
state: Arc<Mutex<MockTurnState>>,
}
#[async_trait]
impl FeishuTurnApi for MockTurnApi {
async fn create_card(&mut self, markdown: &str) -> Result<String, ChannelError> {
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<Mutex<MockTurnState>>) -> 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]看完继续说"
);
}
}