fix(feishu): bound media and retry API requests

This commit is contained in:
xiaoxixi 2026-07-19 16:02:01 +08:00
parent 7e53fa658b
commit 23e0b3e7a6
7 changed files with 427 additions and 170 deletions

View File

@ -349,6 +349,10 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
| `channels.feishu.live_updates` | `false` | | `channels.feishu.live_updates` | `false` |
| `channels.feishu.live_update_interval_ms` | `500` | | `channels.feishu.live_update_interval_ms` | `500` |
| `channels.feishu.require_mention` | `true` | | `channels.feishu.require_mention` | `true` |
| `channels.feishu.max_image_bytes` | `10485760` |
| `channels.feishu.max_file_bytes` | `26214400` |
| `channels.feishu.media_dir_max_bytes` | `536870912` |
| `channels.feishu.request_timeout_secs` | `30` |
更完整的配置字段说明见 [resources/skills/about-picobot/references/config.md](resources/skills/about-picobot/references/config.md)。 更完整的配置字段说明见 [resources/skills/about-picobot/references/config.md](resources/skills/about-picobot/references/config.md)。

View File

@ -141,6 +141,7 @@ sequenceDiagram
- `cli_chat` 将同一 `turn_updated` 快照发给 TUI 和 WebUI。客户端只保留当前 session 中 revision 更新的 `active_turn`,终态随后由持久化历史校准。 - `cli_chat` 将同一 `turn_updated` 快照发给 TUI 和 WebUI。客户端只保留当前 session 中 revision 更新的 `active_turn`,终态随后由持久化历史校准。
- 飞书对每个 DATA 帧先在 2 秒硬期限内 ACK再进行有界分片重组并把完整事件交给容量 32 的连接内处理队列;媒体下载和引用查询不占用正常的 WebSocket 读循环。队列饱和时当前事件在连接任务中同步处理而不丢弃。连接异常采用有上限的指数退避持续重连,不因累计故障永久停止。 - 飞书对每个 DATA 帧先在 2 秒硬期限内 ACK再进行有界分片重组并把完整事件交给容量 32 的连接内处理队列;媒体下载和引用查询不占用正常的 WebSocket 读循环。队列饱和时当前事件在连接任务中同步处理而不丢弃。连接异常采用有上限的指数退避持续重连,不因累计故障永久停止。
- 飞书在协议解析阶段按 `allow_from` 拒绝未授权用户;群聊默认必须明确 @ 运行时解析出的机器人身份,身份解析失败时安全地忽略群消息。飞书把当前消息 ID 作为 `reply_to`,并在私有 metadata 中携带 root/thread 信息Sink 使用原生 reply API 及 `reply_in_thread` 保持客户端引用和话题位置。飞书默认 `FinalOnly`;开启 `live_updates`第一个可见快照创建卡片后续编辑同一卡片终态编辑失败则发送完整结果兜底。reaction 清理在 finish、abort 和 Gateway shutdown 中幂等执行。 - 飞书在协议解析阶段按 `allow_from` 拒绝未授权用户;群聊默认必须明确 @ 运行时解析出的机器人身份,身份解析失败时安全地忽略群消息。飞书把当前消息 ID 作为 `reply_to`,并在私有 metadata 中携带 root/thread 信息Sink 使用原生 reply API 及 `reply_in_thread` 保持客户端引用和话题位置。飞书默认 `FinalOnly`;开启 `live_updates`第一个可见快照创建卡片后续编辑同一卡片终态编辑失败则发送完整结果兜底。reaction 清理在 finish、abort 和 Gateway shutdown 中幂等执行。
- 飞书入站响应体按类型流式执行字节上限和超时检查,写盘前校验媒体目录总容量,客户端文件名先收敛为安全 basename出站上传也先检查本地文件大小。消息发送和资源下载对网络错误、429、5xx 和 401 进行有界重试401 或飞书失效 token 业务码会使租户 token 缓存失效后重新获取。
- DeliveryCoordinator 与 OutboundDispatcher 共享 `(channel, chat_id)` 写锁,避免活动 Turn 终态与独立消息并发写入同一目标。 - DeliveryCoordinator 与 OutboundDispatcher 共享 `(channel, chat_id)` 写锁,避免活动 Turn 终态与独立消息并发写入同一目标。
### 出站投递 ### 出站投递

View File

@ -63,7 +63,13 @@
"require_mention": true, "require_mention": true,
"agent": "default", "agent": "default",
"media_dir": "~/.picobot/media/feishu", "media_dir": "~/.picobot/media/feishu",
"reaction_emoji": "Typing" "reaction_emoji": "Typing",
"live_updates": false,
"live_update_interval_ms": 500,
"max_image_bytes": 10485760,
"max_file_bytes": 26214400,
"media_dir_max_bytes": 536870912,
"request_timeout_secs": 30
} }
}, },
"memory": { "memory": {

View File

@ -100,6 +100,10 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
| `reaction_emoji` | string | "Typing" | 回复意向表达的表情 | | `reaction_emoji` | string | "Typing" | 回复意向表达的表情 |
| `live_updates` | bool | false | 是否用单张卡片实时编辑活动 Turn关闭时只发送终态 | | `live_updates` | bool | false | 是否用单张卡片实时编辑活动 Turn关闭时只发送终态 |
| `live_update_interval_ms` | int | 500 | 卡片更新最小间隔,运行时限制在 2505000ms | | `live_update_interval_ms` | int | 500 | 卡片更新最小间隔,运行时限制在 2505000ms |
| `max_image_bytes` | int | 10485760 | 单个入站/出站图片的最大字节数 |
| `max_file_bytes` | int | 26214400 | 单个入站/出站文件、音频或视频的最大字节数 |
| `media_dir_max_bytes` | int | 536870912 | 飞书媒体目录容量上限;达到上限后拒绝新下载,不自动删除旧文件 |
| `request_timeout_secs` | int | 30 | 单次飞书 HTTP 请求及响应体读取的硬超时,运行时限制在 5120 秒 |
飞书属于外部渠道:无论是否开启实时卡片,都不会接收模型 reasoning工具只显示紧凑状态。配置修改需重启 Gateway 生效。 飞书属于外部渠道:无论是否开启实时卡片,都不会接收模型 reasoning工具只显示紧凑状态。配置修改需重启 Gateway 生效。

View File

@ -73,7 +73,11 @@
"media_dir": "~/.picobot/media/feishu", "media_dir": "~/.picobot/media/feishu",
"reaction_emoji": "Typing", "reaction_emoji": "Typing",
"live_updates": false, "live_updates": false,
"live_update_interval_ms": 500 "live_update_interval_ms": 500,
"max_image_bytes": 10485760,
"max_file_bytes": 26214400,
"media_dir_max_bytes": 536870912,
"request_timeout_secs": 30
} }
}, },
"memory": { "memory": {

View File

@ -39,6 +39,8 @@ const WS_FRAGMENT_TTL: Duration = Duration::from_secs(5 * 60);
const MAX_WS_FRAGMENTS: usize = 256; const MAX_WS_FRAGMENTS: usize = 256;
const MAX_WS_EVENT_BYTES: usize = 8 * 1024 * 1024; const MAX_WS_EVENT_BYTES: usize = 8 * 1024 * 1024;
const STABLE_CONNECTION_WINDOW: Duration = Duration::from_secs(30); 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) // Protobuf types for Feishu WebSocket protocol (pbbp2.proto)
@ -214,6 +216,18 @@ struct FeishuSendTarget {
reply_in_thread: bool, reply_in_thread: bool,
} }
#[derive(Deserialize)]
struct FeishuMessageResponse {
code: i32,
msg: String,
data: Option<FeishuMessageData>,
}
#[derive(Deserialize)]
struct FeishuMessageData {
message_id: String,
}
impl FeishuSendTarget { impl FeishuSendTarget {
fn from_message( fn from_message(
chat_id: String, chat_id: String,
@ -283,9 +297,17 @@ impl FeishuChannel {
let media_dir = workspace_dir.join("media").join("feishu"); let media_dir = workspace_dir.join("media").join("feishu");
config.media_dir = media_dir.to_string_lossy().to_string(); 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 { Ok(Self {
config, config,
http_client: reqwest::Client::new(), http_client,
running: Arc::new(RwLock::new(false)), running: Arc::new(RwLock::new(false)),
shutdown: Arc::new(RwLock::new(None)), shutdown: Arc::new(RwLock::new(None)),
run_task: Arc::new(Mutex::new(None)), run_task: Arc::new(Mutex::new(None)),
@ -296,17 +318,68 @@ impl FeishuChannel {
}) })
} }
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<F>(
&self,
operation: &str,
build: F,
) -> Result<reqwest::Response, ChannelError>
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<String, ChannelError> { async fn refresh_bot_open_id(&self) -> Result<String, ChannelError> {
let token = self.get_tenant_access_token().await?;
let response = self let response = self
.http_client .send_authenticated("fetch Feishu bot info", |client, token| {
.get(format!("{}/bot/v3/info", FEISHU_API_BASE)) client
.bearer_auth(token) .get(format!("{}/bot/v3/info", FEISHU_API_BASE))
.send() .bearer_auth(token)
.await })
.map_err(|error| { .await?;
ChannelError::ConnectionError(format!("Bot info HTTP error: {error}"))
})?;
let status = response.status(); let status = response.status();
let body: serde_json::Value = response.json().await.map_err(|error| { let body: serde_json::Value = response.json().await.map_err(|error| {
ChannelError::Other(format!("Failed to parse bot info response: {error}")) ChannelError::Other(format!("Failed to parse bot info response: {error}"))
@ -493,8 +566,6 @@ impl FeishuChannel {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| ChannelError::Other("No image_key in message".to_string()))?; .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 // Use message resource API for downloading message images
let url = format!( let url = format!(
"{}/im/v1/messages/{}/resources/{}?type=image", "{}/im/v1/messages/{}/resources/{}?type=image",
@ -505,24 +576,24 @@ impl FeishuChannel {
tracing::debug!(url = %url, image_key = %image_key, message_id = %message_id, "Downloading image from Feishu via message resource API"); tracing::debug!(url = %url, image_key = %image_key, message_id = %message_id, "Downloading image from Feishu via message resource API");
let resp = self let resp = self
.http_client .send_authenticated("download Feishu image", |client, token| {
.get(&url) client.get(&url).bearer_auth(token)
.header("Authorization", format!("Bearer {}", token)) })
.send() .await?;
.await
.map_err(|e| {
ChannelError::ConnectionError(format!("Download image HTTP error: {}", e))
})?;
let status = resp.status(); let status = resp.status();
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
tracing::debug!(status = %status, "Image download response status"); tracing::debug!(status = %status, "Image download response status");
if !status.is_success() { if !status.is_success() {
let error_text = resp.text().await.unwrap_or_default(); let error_body =
read_response_limited(resp, 64 * 1024, self.request_timeout(), "error response")
.await
.unwrap_or_default();
return Err(ChannelError::Other(format!( return Err(ChannelError::Other(format!(
"Image download failed {}: {}", "Image download failed {}: {}",
status, error_text status,
String::from_utf8_lossy(&error_body)
))); )));
} }
@ -535,11 +606,13 @@ impl FeishuChannel {
let ext = resolve_image_ext(&content_type); let ext = resolve_image_ext(&content_type);
let data = resp let data = read_response_limited(
.bytes() resp,
.await self.config.max_image_bytes,
.map_err(|e| ChannelError::Other(format!("Failed to read image data: {}", e)))? self.request_timeout(),
.to_vec(); "image",
)
.await?;
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
tracing::debug!(data_len = %data.len(), content_type = %content_type, "Downloaded image data"); tracing::debug!(data_len = %data.len(), content_type = %content_type, "Downloaded image data");
@ -552,6 +625,13 @@ impl FeishuChannel {
); );
let file_path = resolve_unique_path(media_dir, &filename).await; 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) tokio::fs::write(&file_path, &data)
.await .await
.map_err(|e| ChannelError::Other(format!("Failed to write image: {}", e)))?; .map_err(|e| ChannelError::Other(format!("Failed to write image: {}", e)))?;
@ -576,8 +656,6 @@ impl FeishuChannel {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| ChannelError::Other("No file_key in message".to_string()))?; .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 // Use message resource API for downloading message files
let url = format!( let url = format!(
"{}/im/v1/messages/{}/resources/{}?type=file", "{}/im/v1/messages/{}/resources/{}?type=file",
@ -588,34 +666,36 @@ impl FeishuChannel {
tracing::debug!(url = %url, file_key = %file_key, message_id = %message_id, "Downloading file from Feishu via message resource API"); tracing::debug!(url = %url, file_key = %file_key, message_id = %message_id, "Downloading file from Feishu via message resource API");
let resp = self let resp = self
.http_client .send_authenticated("download Feishu file", |client, token| {
.get(&url) client.get(&url).bearer_auth(token)
.header("Authorization", format!("Bearer {}", token)) })
.send() .await?;
.await
.map_err(|e| {
ChannelError::ConnectionError(format!("Download file HTTP error: {}", e))
})?;
let status = resp.status(); let status = resp.status();
if !status.is_success() { if !status.is_success() {
let error_text = resp.text().await.unwrap_or_default(); let error_body =
read_response_limited(resp, 64 * 1024, self.request_timeout(), "error response")
.await
.unwrap_or_default();
return Err(ChannelError::Other(format!( return Err(ChannelError::Other(format!(
"File download failed {}: {}", "File download failed {}: {}",
status, error_text status,
String::from_utf8_lossy(&error_body)
))); )));
} }
let data = resp let data = read_response_limited(
.bytes() resp,
.await self.config.max_file_bytes,
.map_err(|e| ChannelError::Other(format!("Failed to read file data: {}", e)))? self.request_timeout(),
.to_vec(); file_type,
)
.await?;
let filename = content_json let filename = content_json
.get("file_name") .get("file_name")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()) .map(sanitize_filename)
.unwrap_or_else(|| { .unwrap_or_else(|| {
let ext = resolve_file_ext(content_json); let ext = resolve_file_ext(content_json);
if ext.is_empty() { if ext.is_empty() {
@ -631,6 +711,13 @@ impl FeishuChannel {
}); });
let file_path = resolve_unique_path(media_dir, &filename).await; 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) tokio::fs::write(&file_path, &data)
.await .await
.map_err(|e| ChannelError::Other(format!("Failed to write file: {}", e)))?; .map_err(|e| ChannelError::Other(format!("Failed to write file: {}", e)))?;
@ -644,6 +731,7 @@ impl FeishuChannel {
/// Upload image to Feishu and return the image_key /// Upload image to Feishu and return the image_key
async fn upload_image(&self, file_path: &str) -> Result<String, ChannelError> { async fn upload_image(&self, file_path: &str) -> Result<String, ChannelError> {
ensure_local_media_size(file_path, self.config.max_image_bytes, "image").await?;
let token = self.get_tenant_access_token().await?; let token = self.get_tenant_access_token().await?;
let mime = mime_guess::from_path(file_path) let mime = mime_guess::from_path(file_path)
@ -720,6 +808,7 @@ impl FeishuChannel {
/// Upload file to Feishu and return the file_key /// Upload file to Feishu and return the file_key
async fn upload_file(&self, file_path: &str) -> Result<String, ChannelError> { async fn upload_file(&self, file_path: &str) -> Result<String, ChannelError> {
ensure_local_media_size(file_path, self.config.max_file_bytes, "file").await?;
let token = self.get_tenant_access_token().await?; let token = self.get_tenant_access_token().await?;
let file_name = std::path::Path::new(file_path) let file_name = std::path::Path::new(file_path)
@ -812,23 +901,19 @@ impl FeishuChannel {
if emoji.is_empty() { if emoji.is_empty() {
return Ok(None); return Ok(None);
} }
let token = self.get_tenant_access_token().await?;
let resp = self let resp = self
.http_client .send_authenticated("add Feishu reaction", |client, token| {
.post(format!( client
"{}/im/v1/messages/{}/reactions", .post(format!(
FEISHU_API_BASE, message_id "{}/im/v1/messages/{}/reactions",
)) FEISHU_API_BASE, message_id
.header("Authorization", format!("Bearer {}", token)) ))
.json(&serde_json::json!({ .bearer_auth(token)
"reaction_type": { "emoji_type": emoji } .json(&serde_json::json!({
})) "reaction_type": { "emoji_type": emoji }
.send() }))
.await })
.map_err(|e| { .await?;
ChannelError::ConnectionError(format!("Add reaction HTTP error: {}", e))
})?;
#[derive(Deserialize)] #[derive(Deserialize)]
struct ReactionResp { struct ReactionResp {
@ -884,20 +969,16 @@ impl FeishuChannel {
message_id: &str, message_id: &str,
reaction_id: &str, reaction_id: &str,
) -> Result<(), ChannelError> { ) -> Result<(), ChannelError> {
let token = self.get_tenant_access_token().await?;
let resp = self let resp = self
.http_client .send_authenticated("remove Feishu reaction", |client, token| {
.delete(format!( client
"{}/im/v1/messages/{}/reactions/{}", .delete(format!(
FEISHU_API_BASE, message_id, reaction_id "{}/im/v1/messages/{}/reactions/{}",
)) FEISHU_API_BASE, message_id, reaction_id
.header("Authorization", format!("Bearer {}", token)) ))
.send() .bearer_auth(token)
.await })
.map_err(|e| { .await?;
ChannelError::ConnectionError(format!("Remove reaction HTTP error: {}", e))
})?;
#[derive(Deserialize)] #[derive(Deserialize)]
struct ReactionResp { struct ReactionResp {
@ -926,19 +1007,12 @@ impl FeishuChannel {
/// Fetch the text content of a Feishu message by ID. /// Fetch the text content of a Feishu message by ID.
/// Returns a "[Reply to: ...]" context string, or None on failure. /// Returns a "[Reply to: ...]" context string, or None on failure.
async fn get_message_content(&self, message_id: &str) -> Option<String> { 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 let resp = self
.http_client .send_authenticated("fetch Feishu parent message", |client, token| {
.get(format!("{}/im/v1/messages/{}", FEISHU_API_BASE, message_id)) client
.header("Authorization", format!("Bearer {}", token)) .get(format!("{}/im/v1/messages/{}", FEISHU_API_BASE, message_id))
.send() .bearer_auth(token)
})
.await .await
.ok()?; .ok()?;
@ -1019,83 +1093,43 @@ impl FeishuChannel {
msg_type: &str, msg_type: &str,
content: &str, content: &str,
) -> Result<String, ChannelError> { ) -> Result<String, ChannelError> {
let token = self.get_tenant_access_token().await?; let mut active_target = target.clone();
let (url, body) = target.request(msg_type, content); let mut token_refreshes = 0;
loop {
let resp = self let (url, body) = active_target.request(msg_type, content);
.http_client
.post(url)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", token))
.json(&body)
.send()
.await
.map_err(|e| {
ChannelError::ConnectionError(format!("Send message 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 response error: {}", e)))?;
if matches!(send_resp.code, 230011 | 231003)
&& target.reply_to.is_some()
&& !target.reply_in_thread
{
tracing::warn!(
code = send_resp.code,
"Feishu reply target is unavailable; falling back to a new chat message"
);
let mut create_target = target.clone();
create_target.reply_to = None;
let (url, body) = create_target.request(msg_type, content);
let response = self let response = self
.http_client .send_authenticated("send Feishu message", |client, token| {
.post(url) client.post(url.clone()).bearer_auth(token).json(&body)
.bearer_auth(self.get_tenant_access_token().await?) })
.json(&body) .await?;
.send() let result: FeishuMessageResponse = response.json().await.map_err(|error| {
.await ChannelError::Other(format!("Parse send response error: {error}"))
.map_err(|error| {
ChannelError::ConnectionError(format!(
"Fallback send message HTTP error: {error}"
))
})?;
let fallback: SendResp = response.json().await.map_err(|error| {
ChannelError::Other(format!("Parse fallback send response error: {error}"))
})?; })?;
if fallback.code != 0 {
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!( return Err(ChannelError::Other(format!(
"Fallback send failed: code={} msg={}", "Send message failed: code={} msg={}",
fallback.code, fallback.msg result.code, result.msg
))); )));
} }
return Ok(fallback return Ok(result.data.map_or_else(String::new, |data| data.message_id));
.data
.map_or_else(String::new, |data| data.message_id));
} }
if send_resp.code != 0 {
return Err(ChannelError::Other(format!(
"Send message failed: code={} msg={}",
send_resp.code, send_resp.msg
)));
}
Ok(send_resp
.data
.map_or_else(String::new, |data| data.message_id))
} }
/// Extract service_id from WebSocket URL query params /// Extract service_id from WebSocket URL query params
@ -1719,6 +1753,120 @@ fn reconnect_delay(attempt: u32) -> Duration {
Duration::from_secs(1_u64 << exponent) 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::<u64>().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<Vec<u8>, 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 { fn parse_post_content(content: &str) -> String {
/// Extract text from a single post element (text, link, at-mention). /// Extract text from a single post element (text, link, at-mention).
fn extract_element(el: &serde_json::Value, out: &mut Vec<String>) { fn extract_element(el: &serde_json::Value, out: &mut Vec<String>) {
@ -2296,20 +2444,16 @@ impl FeishuChannel {
message_id: &str, message_id: &str,
card_content: &str, card_content: &str,
) -> Result<(), ChannelError> { ) -> Result<(), ChannelError> {
let token = self.get_tenant_access_token().await?;
let card: serde_json::Value = serde_json::from_str(card_content) let card: serde_json::Value = serde_json::from_str(card_content)
.map_err(|error| ChannelError::Other(format!("Invalid card JSON: {error}")))?; .map_err(|error| ChannelError::Other(format!("Invalid card JSON: {error}")))?;
let response = self let response = self
.http_client .send_authenticated("update Feishu card", |client, token| {
.patch(format!("{}/im/v1/messages/{}", FEISHU_API_BASE, message_id)) client
.header("Content-Type", "application/json") .patch(format!("{}/im/v1/messages/{}", FEISHU_API_BASE, message_id))
.header("Authorization", format!("Bearer {}", token)) .bearer_auth(token)
.json(&serde_json::json!({ "card": card })) .json(&serde_json::json!({ "card": card }))
.send() })
.await .await?;
.map_err(|error| {
ChannelError::ConnectionError(format!("Update card HTTP error: {error}"))
})?;
#[derive(Deserialize)] #[derive(Deserialize)]
struct UpdateResp { struct UpdateResp {
@ -2895,6 +3039,10 @@ mod tests {
reaction_emoji: "THUMBSUP".to_string(), reaction_emoji: "THUMBSUP".to_string(),
live_updates: false, live_updates: false,
live_update_interval_ms: 500, 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"), Path::new("/tmp"),
) )
@ -3159,6 +3307,72 @@ mod tests {
assert_eq!(body["receive_id"], "omt_thread"); 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] #[tokio::test]
async fn turn_sink_creates_once_updates_same_card_and_cleans_up_at_finish() { async fn turn_sink_creates_once_updates_same_card_and_cleans_up_at_finish() {
let state = Arc::new(Mutex::new(MockTurnState::default())); let state = Arc::new(Mutex::new(MockTurnState::default()));

View File

@ -86,6 +86,14 @@ pub struct FeishuChannelConfig {
pub live_updates: bool, pub live_updates: bool,
#[serde(default = "default_feishu_live_update_interval_ms")] #[serde(default = "default_feishu_live_update_interval_ms")]
pub live_update_interval_ms: u64, pub live_update_interval_ms: u64,
#[serde(default = "default_feishu_max_image_bytes")]
pub max_image_bytes: u64,
#[serde(default = "default_feishu_max_file_bytes")]
pub max_file_bytes: u64,
#[serde(default = "default_feishu_media_dir_max_bytes")]
pub media_dir_max_bytes: u64,
#[serde(default = "default_feishu_request_timeout_secs")]
pub request_timeout_secs: u64,
} }
fn default_allow_from() -> Vec<String> { fn default_allow_from() -> Vec<String> {
@ -107,6 +115,22 @@ fn default_feishu_live_update_interval_ms() -> u64 {
500 500
} }
fn default_feishu_max_image_bytes() -> u64 {
10 * 1024 * 1024
}
fn default_feishu_max_file_bytes() -> u64 {
25 * 1024 * 1024
}
fn default_feishu_media_dir_max_bytes() -> u64 {
512 * 1024 * 1024
}
fn default_feishu_request_timeout_secs() -> u64 {
30
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProviderConfig { pub struct ProviderConfig {
#[serde(rename = "type")] #[serde(rename = "type")]