新增: 飞书文件消息入站与补充注入链完善
- 文件消息入站:20MB 上限落盘引用,模型经 read_file 按需读盘;下载带 3 次退避+120s 超时防线,复用媒体域 hash 去重/OSS 双写 - 群聊门控对齐 u-claw 模式:前移至类型分流前,@自己/@所有人响应,未艾特静默旁观(原门控后置致未支持类型误回提示卡) - 修复补充注入丢失:pending_injects 未消化计数+收敛出口续轮+桌面注入 IPC+前端本地气泡 - df-ai-core 测试构造器补 source 字段(溯源字段遗漏,全量门禁暴露)
This commit is contained in:
+215
-60
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user