新增: 飞书文件消息入站与补充注入链完善

- 文件消息入站:20MB 上限落盘引用,模型经 read_file 按需读盘;下载带 3 次退避+120s 超时防线,复用媒体域 hash 去重/OSS 双写
- 群聊门控对齐 u-claw 模式:前移至类型分流前,@自己/@所有人响应,未艾特静默旁观(原门控后置致未支持类型误回提示卡)
- 修复补充注入丢失:pending_injects 未消化计数+收敛出口续轮+桌面注入 IPC+前端本地气泡
- df-ai-core 测试构造器补 source 字段(溯源字段遗漏,全量门禁暴露)
This commit is contained in:
lxy
2026-10-15 14:27:22 +08:00
parent 7548896f6d
commit 795935bf6a
10 changed files with 395 additions and 79 deletions
+215 -60
View File
@@ -384,6 +384,46 @@ const IMAGE_INBOUND_PROMPT: &str = "(用户通过飞书发来一张图片,请查
/// 10MB 原图留足余量;超限引导用户压缩,防超大图烧 token 与转码内存
const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024;
/// 文件入站大小上限:文件不经 base64 内嵌(模型经 read_file 按需读盘),上限只防
/// 恶意超大件烧下载流量与磁盘,20MB 覆盖常规文档/报表/压缩包
const MAX_FILE_BYTES: usize = 20 * 1024 * 1024;
/// 飞书消息资源下载带防线(图片/文件共用,对齐 u-claw fetchResource 经验):3 次指数
/// 退避(1s/4s)+单次 120s 超时——飞书资源接口偶发抖动,无重试时用户收到一次性失败;
/// 超时防接口挂起拖死受理链。空响应与错误同待遇进重试(可能为服务端瞬时异常),
/// 耗尽后报最后一次原因。resource_type=image/file,同一资源接口
async fn download_message_resource_with_retry(
client: &LarkClient,
message_id: &str,
file_key: &str,
resource_type: &str,
label: &str,
) -> Result<Vec<u8>, String> {
let mut last_err = String::from("下载失败: 未知错误");
for attempt in 0u32..3 {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_secs(1 << (attempt - 1))).await;
}
let dl = tokio::time::timeout(
std::time::Duration::from_secs(120),
client.channel_messaging().download_message_resource(
message_id,
file_key,
resource_type,
&RequestOption::default(),
),
)
.await;
match dl {
Ok(Ok(r)) if !r.data.is_empty() => return Ok(r.data),
Ok(Ok(_)) => last_err = format!("{label}内容为空"),
Ok(Err(e)) => last_err = format!("下载失败: {e}"),
Err(_) => last_err = "下载超时(120s)".into(),
}
}
Err(last_err)
}
/// 下载飞书图片消息资源并落盘,返回本地引用 ContentPart(#958 引用化存储)。
/// 存储面只存 `file://` 引用(base64 不进 SQLite,防大图随历史全量重放膨胀),
/// 发送请求时由 df-ai::image_ref 单点按需读盘转 base64(模型使用按需转换)。
@@ -396,53 +436,20 @@ async fn download_feishu_image(
message_id: &str,
file_key: &str,
) -> Result<ContentPart, String> {
// 下载带防线(对齐 u-claw fetchResource 经验):3 次指数退避(1s/4s)+单次 120s 超时
// ——飞书资源接口偶发抖动,无重试时用户收到一次性失败;超时防接口挂起拖死受理链。
// 空响应与错误同待遇进重试(可能为服务端瞬时异常),耗尽后报最后一次原因
let resp = {
let mut last_err = String::from("下载失败: 未知错误");
let mut out = None;
for attempt in 0u32..3 {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_secs(1 << (attempt - 1))).await;
}
let dl = tokio::time::timeout(
std::time::Duration::from_secs(120),
client.channel_messaging().download_message_resource(
message_id,
file_key,
"image",
&RequestOption::default(),
),
)
.await;
match dl {
Ok(Ok(r)) if !r.data.is_empty() => {
out = Some(r);
break;
}
Ok(Ok(_)) => last_err = "图片内容为空".into(),
Ok(Err(e)) => last_err = format!("下载失败: {e}"),
Err(_) => last_err = "下载超时(120s)".into(),
}
}
match out {
Some(r) => r,
None => return Err(last_err),
}
};
if resp.data.len() > MAX_IMAGE_BYTES {
let data = download_message_resource_with_retry(client, message_id, file_key, "image", "图片")
.await?;
if data.len() > MAX_IMAGE_BYTES {
return Err(format!(
"图片超过 10MB 上限(约 {}MB),请压缩后重发",
resp.data.len() / 1024 / 1024
data.len() / 1024 / 1024
));
}
let media_type = sniff_image_mime(&resp.data)
let media_type = sniff_image_mime(&data)
.ok_or_else(|| "不支持的图片格式(仅支持 png/jpeg/webp/gif)".to_string())?;
// 存储决策收口 media_store:hash 去重落盘,OSS 配置在则双写并引用 https(跨设备可解析),
// 失败降级本机 file://(不阻塞)。本函数只管飞书协议(下载+防线),不管存储。
// 飞书 image 消息无原始文件名(None=纯 hash 名)
let url = store.save(&resp.data, media_type, None).await?;
let url = store.save(&data, media_type, None).await?;
Ok(ContentPart::Image {
url: Some(url),
base64: None,
@@ -451,6 +458,67 @@ async fn download_feishu_image(
})
}
/// 文件消息喂模型的任务语境:告知文件名与本机落盘路径,引导用 read_file 按需读取;
/// 二进制/超大文件的读取局限如实交底,防模型谎称已完整处理。落盘前(无路径)给
/// 占位版,仅供空判与命令/审批判定,不喂模型不落卡
fn file_inbound_prompt(file_name: &str, path: Option<&str>) -> String {
match path {
Some(p) => format!(
"(用户通过飞书发来文件「{file_name}」,已保存到本地路径 {p};请按用户需求用 read_file 工具读取该文件处理。二进制/超大文件可能无法完整读取,如实告知用户)"
),
None => format!("(用户通过飞书发来文件「{file_name}」,保存中)"),
}
}
/// 文件超限判定:超 20MB 上限时给可读错误话术(提示压缩/拆分),宁拒不落盘
fn file_size_over_limit_error(len: usize) -> Option<String> {
(len > MAX_FILE_BYTES).then(|| {
format!(
"文件超过 20MB 上限(约 {}MB),请压缩或拆分后重发",
len / 1024 / 1024
)
})
}
/// 文件展示名裁决:SDK 归一化 file_name 优先(保真,清洗首尾空白);缺失回退
/// `file_<内容 hash 前 8>`(与落盘 hash 命名同源,可对账辨认)
fn file_display_name(raw: &str, bytes: &[u8]) -> String {
let raw = raw.trim();
if raw.is_empty() {
format!("file_{}", &crate::media_store::content_hash(bytes)[..8])
} else {
raw.to_string()
}
}
/// 下载飞书文件消息资源并落盘,返回 (展示名, 本机绝对路径)。
/// 与图片同一资源接口(resource_type=file)与下载防线;落盘走 media_store 通用入口
/// (hash 去重+OSS 双写+失败降级,原文件名缀后保目录可读)。返回本机路径喂模型
/// read_file(OSS https 引用对本机读取无意义,不作返回);超 20MB 拒收回可读错误
async fn download_feishu_file(
client: &LarkClient,
store: &crate::media_store::MediaStore,
message_id: &str,
res: &larksuite_oapi_sdk_rs::channel::ChannelResource,
) -> Result<(String, String), String> {
let data =
download_message_resource_with_retry(client, message_id, &res.file_key, "file", "文件")
.await?;
if let Some(err) = file_size_over_limit_error(data.len()) {
return Err(err);
}
let display_name = file_display_name(&res.file_name, &data);
let original = res.file_name.trim();
let (_, path) = store
.save_with_path(
&data,
"application/octet-stream",
(!original.is_empty()).then_some(original),
)
.await?;
Ok((display_name, path.to_string_lossy().into_owned()))
}
/// 读取媒体 OSS 配置(KV `media-oss-config`;未配置/解析失败 → None=纯本地模式)
async fn read_media_oss_config(
db: &df_storage::db::Database,
@@ -588,12 +656,21 @@ async fn handle_message(
) {
// D3:任何入站消息都是事件管道存活证据,先于一切过滤更新活性时间戳
ctx.last_alive.store(now_secs(), Ordering::SeqCst);
// 类型分流(#958 图片入站):text 原样;image 走下载装配(vision ContentPart);
// 其余类型维持 #25 提示卡。image 消息 SDK 归一化已把 image_key 放入
// msg.resources(ChannelResource),无需自行解析 content JSON。
let image_key = if msg.message_type == "image" {
// 群聊响应门控对齐 u-claw 模式(feishu_events.go mentionedBot 四条):①@自己响应
// ②@所有人响应 ③@别的bot不响应 ④无mention静默旁观;私聊恒响应。SDK 的
// mentioned_bot/mention_all 已承载①②③的精确判定。门控必须在类型分流之前——
// 分流段对语音等未支持类型会回提示卡,门控放后面时未艾特的群消息也会得到回复
// (2026-10-15 用户实测「群里没艾特也处理」根因)。
if msg.chat_type == "group" && !msg.mentioned_bot && !msg.mention_all {
return;
}
// 类型分流(#958 图片入站,文件入站同源扩展):text 原样;image 走下载装配(vision
// ContentPart);file 走下载落盘(read_file 本机路径);语音等其余类型维持提示卡。
// image/file 消息 SDK 归一化已把 file_key/file_name 放入 msg.resources(ChannelResource),
// 无需自行解析 content JSON。
let (image_key, file_res) = if msg.message_type == "image" {
match msg.resources.iter().find(|r| r.resource_type == "image") {
Some(r) => Some(r.file_key.clone()),
Some(r) => (Some(r.file_key.clone()), None),
None => {
let _ = reply_markdown_card(
&ctx.client,
@@ -604,26 +681,39 @@ async fn handle_message(
return;
}
}
} else if msg.message_type == "file" {
match msg.resources.iter().find(|r| r.resource_type == "file") {
Some(r) => (None, Some(r.clone())),
None => {
let _ = reply_markdown_card(
&ctx.client,
&msg.message_id,
"⚠️ 文件消息缺少 file_key,无法处理。",
)
.await;
return;
}
}
} else if msg.message_type != "text" {
// #25 非文本消息静默丢修复:回提示卡(D7 媒体能力前的兜底),不再零反馈。
// 补替代路径引导:用户发文件等往往是想让 AI 处理内容,直接告知改用文字描述
// #25 非文本消息静默丢修复:回提示卡,不再零反馈。文本/图片/文件已支持,
// 余下语音等类型如实告知规划中
let _ = reply_markdown_card(
&ctx.client,
&msg.message_id,
"📎 暂支持文本与图片消息,文件/语音等其他类型规划中。\n\n如需处理文件内容,请用文字描述你的需求(如「把这段文字翻译成英文:…」)。",
"📎 暂支持文本、图片与文件消息,语音等其他类型规划中。",
)
.await;
return;
} else {
None
(None, None)
};
if msg.chat_type == "group" && !msg.mentioned_bot {
return;
}
// 图片消息不带文字(飞书 image 为独立消息类型),喂固定引导词交代任务语境;
// msg.text 的 markdown 图链([图片](image_key))对模型无意义,不喂
let text = if image_key.is_some() {
// 附件消息不带文字(飞书 image/file 均为独立消息类型),msg.text 的图链/文件标签
// 对模型无意义,不喂:图片喂固定引导词交代任务语境;文件语境需落盘路径,先预填
// 文件名占位版(命令/审批判定恒不命中),下载点覆写为含路径终版
let mut text = if image_key.is_some() {
IMAGE_INBOUND_PROMPT.to_string()
} else if let Some(res) = &file_res {
file_inbound_prompt(res.file_name.trim(), None)
} else {
let text = msg.text.clone().unwrap_or_default().trim().to_string();
// 飞书 @ 占位清洗(如 @_user_1):@ 语义已由 mentioned_bot 承载,占位串只污染文本
@@ -685,6 +775,30 @@ async fn handle_message(
None => None,
};
// 文件入站:下载消息资源→落盘拿本机路径→覆写任务语境终版(含路径,模型 read_file
// 按需读取)。与图片同段白名单后下载(未授权不烧流量)、占位发票前早退(失败回
// 可读错误卡,超 20MB 话术在下载错误内)
if let Some(res) = &file_res {
let state = ctx.app.state::<crate::state::AppState>();
let oss_cfg = read_media_oss_config(&state.db).await;
let store = crate::media_store::MediaStore::new(state.data_dir.clone(), oss_cfg);
match download_feishu_file(&ctx.client, &store, &message_id, res).await {
Ok((name, path)) => {
text = file_inbound_prompt(&name, Some(&path));
}
Err(e) => {
feishu_log(&format!("文件下载失败 chat={chat_id}: {e}"));
let _ = reply_markdown_card(
&ctx.client,
&message_id,
&format!("⚠️ 文件下载失败:{e}\n\n请重发,或直接用文字描述你的需求。"),
)
.await;
return;
}
}
}
// 命令通路(#30 容错):全角/大小写/附言归一后识别,仅命令词走控制面,任务原文不动。
// 顺序约束:命令与审批决议须先于注入旁路——conv 活跃时这两类文本否则会被当补充
// 指令注入任务;未知命令词落回常规任务路径,零行为变更
@@ -836,9 +950,10 @@ async fn handle_message(
let state = ctx.app.state::<crate::state::AppState>();
let conv_id_now = ctx.chat_map.lock().await.get(&chat_id).cloned();
if let Some(cid) = conv_id_now {
// 图片消息不注入:inject_runtime_message 只收文本,注入则图被丢、模型只见
// 引导词空跑;落常规路径由受理守卫拦,语义诚实(任务占用中,稍后重发)
if image_key.is_none() && state.conv_states.is_active(&cid) {
// 附件消息(图片/文件)不注入,走与图片相同的后续链:inject_runtime_message
// 只收文本,图片注入则图被丢、模型只见引导词空跑;文件作为独立新任务语义
// 同样应过受理/防抖/发票链,conv 占用时受理守卫如实回告,不并入运行中任务
if image_key.is_none() && file_res.is_none() && state.conv_states.is_active(&cid) {
match crate::commands::ai::commands::chat::inject_runtime_message(&state, &cid, &text).await {
Ok(true) => {
let _ = reply_markdown_card(&ctx.client, &message_id, "✅ 补充指令已注入运行中任务,下一轮生效").await;
@@ -2402,9 +2517,10 @@ async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap<String, Stri
mod tests {
use super::{
ContentPart, DebounceEntry, delivery_blocked, extract_delivery_paths, feishu_file_type,
heartbeat_due, is_terminal_push, md_to_lark_friendly, merge_debounce_parts,
merge_debounce_text, msg_after_acceptance, parse_command, sniff_image_mime,
strip_mention_placeholders, stream_preview,
file_display_name, file_inbound_prompt, file_size_over_limit_error, heartbeat_due,
is_terminal_push, md_to_lark_friendly, merge_debounce_parts, merge_debounce_text,
msg_after_acceptance, parse_command, sniff_image_mime, strip_mention_placeholders,
stream_preview,
};
#[test]
@@ -2453,6 +2569,45 @@ mod tests {
assert_eq!(sniff_image_mime(b"RIFF\x24\x08\x00\x00AVI "), None);
}
// ── 文件消息入站:任务语境构造/大小限流/展示名回退 ──
#[test]
fn file_prompt_carries_name_path_and_read_guidance() {
// 落盘后终版:文件名+本机路径+read_file 引导+读取局限交底,四要素齐备
let full = file_inbound_prompt("报表.xlsx", Some("E:\\data\\media\\ab12_报表.xlsx"));
assert!(full.contains("「报表.xlsx」"), "缺文件名: {full}");
assert!(full.contains("本地路径 E:\\data\\media\\ab12_报表.xlsx"), "缺路径: {full}");
assert!(full.contains("read_file"), "缺读取引导: {full}");
assert!(full.contains("如实告知用户"), "缺读取局限交底: {full}");
// 落盘前占位版:不含路径(不喂模型不落卡,仅供空判与命令/审批判定)
let pending = file_inbound_prompt("报表.xlsx", None);
assert!(!pending.contains("本地路径"), "占位版不该有路径: {pending}");
assert!(pending.contains("「报表.xlsx」"));
}
#[test]
fn file_size_limit_rejects_over_20mb_only() {
// 恰 20MB 放行(> 判定),超限给可读错误话术(含实际大小,引导压缩/拆分)
assert_eq!(file_size_over_limit_error(20 * 1024 * 1024), None);
assert_eq!(file_size_over_limit_error(1024), None);
let err = file_size_over_limit_error(21 * 1024 * 1024).unwrap();
assert!(err.contains("20MB") && err.contains("21MB"), "话术缺限值/实际值: {err}");
}
#[test]
fn file_display_name_prefers_original_and_hash_fallback() {
// 原始名保真(清洗首尾空白);缺失回退 file_<hash 前 8>,不同内容回退名不同
assert_eq!(file_display_name(" 报表.xlsx ", b"abc"), "报表.xlsx");
let fallback = file_display_name("", b"abc");
assert!(fallback.starts_with("file_"), "回退名形态: {fallback}");
assert_eq!(fallback.len(), "file_".len() + 8, "回退名取 hash 前 8: {fallback}");
assert_ne!(
fallback,
file_display_name(" ", b"abcd"),
"不同内容的回退名应可区分"
);
}
#[test]
fn command_case_and_tail_tolerance() {
// 30:/new 大小写与附言容错,附言不烧 LLM