新增: 媒体引用化存储与消息溯源
- media_store 模块: sha256 去重落盘 + 阿里云 OSS 双写(签名手写), OSS 失败降级本机引用 - 飞书图片入站: image 消息下载装配 vision 片, 3 次退避+120s 超时+残件清理防线 - 出站回传: 报告 [交付] 路径行自动上传飞书原生附件, 凭据/密钥名拦截兜底 - 溯源 V52: ai_messages.source 列, 飞书 msg_id/chat_id/发送者随消息落库 - spawn offload: 桌面粘贴图 base64 统一转落盘引用, SQLite 不进大字符串 - 引用统一 url 字段(https/file://)不自造 scheme, image_ref 发送前按需展开
This commit is contained in:
+359
-11
@@ -22,6 +22,7 @@ use serde::Deserialize;
|
||||
|
||||
use df_storage::crud::SettingsRepo;
|
||||
use df_types::types::new_id;
|
||||
use df_ai::provider::ContentPart;
|
||||
|
||||
/// app_settings 键:飞书渠道配置(与设置面板 FeishuPanel 同键契约)
|
||||
const KV_FEISHU_CONFIG: &str = "feishu-config";
|
||||
@@ -371,6 +372,103 @@ fn parse_command(text: &str) -> Option<(String, String)> {
|
||||
Some((cmd, tail))
|
||||
}
|
||||
|
||||
/// 图片消息喂模型的任务语境(#958):飞书 image 为独立消息类型无法带文字,
|
||||
/// 用引导词让 vision 模型知道「这是用户发来的图,按图作答」
|
||||
const IMAGE_INBOUND_PROMPT: &str = "(用户通过飞书发来一张图片,请查看图片并按图片内容处理)";
|
||||
|
||||
/// 图片入站大小上限(#958):base64 转换膨胀 4/3,vision 端点普遍接受 ≤20MB 内嵌图,
|
||||
/// 10MB 原图留足余量;超限引导用户压缩,防超大图烧 token 与转码内存
|
||||
const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024;
|
||||
|
||||
/// 下载飞书图片消息资源并落盘,返回本地引用 ContentPart(#958 引用化存储)。
|
||||
/// 存储面只存 `file://` 引用(base64 不进 SQLite,防大图随历史全量重放膨胀),
|
||||
/// 发送请求时由 df-ai::image_ref 单点按需读盘转 base64(模型使用按需转换)。
|
||||
/// 走 channel_messaging 便捷方法(与 reply/send 同风格),租户 token 由 client 自管。
|
||||
/// media_type 由字节魔数判定(下载响应无可靠 MIME;飞书图床为 png/jpeg/webp/gif),
|
||||
/// 未知格式拒收——错误 media_type 会被 vision 端点 400,宁早拒不晚炸。
|
||||
async fn download_feishu_image(
|
||||
client: &LarkClient,
|
||||
store: &crate::media_store::MediaStore,
|
||||
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 {
|
||||
return Err(format!(
|
||||
"图片超过 10MB 上限(约 {}MB),请压缩后重发",
|
||||
resp.data.len() / 1024 / 1024
|
||||
));
|
||||
}
|
||||
let media_type = sniff_image_mime(&resp.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?;
|
||||
Ok(ContentPart::Image {
|
||||
url: Some(url),
|
||||
base64: None,
|
||||
media_type: Some(media_type.into()),
|
||||
alt: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 读取媒体 OSS 配置(KV `media-oss-config`;未配置/解析失败 → None=纯本地模式)
|
||||
async fn read_media_oss_config(
|
||||
db: &df_storage::db::Database,
|
||||
) -> Option<crate::media_store::OssConfig> {
|
||||
crate::media_store::read_oss_config(db).await
|
||||
}
|
||||
|
||||
/// 图片格式魔数判定(#958):PNG/JPEG/GIF/WEBP 四态,与飞书图床常见格式对齐
|
||||
fn sniff_image_mime(b: &[u8]) -> Option<&'static str> {
|
||||
if b.starts_with(&[0x89, b'P', b'N', b'G']) {
|
||||
Some("image/png")
|
||||
} else if b.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
Some("image/jpeg")
|
||||
} else if b.starts_with(b"GIF8") {
|
||||
Some("image/gif")
|
||||
} else if b.len() > 12 && b.starts_with(b"RIFF") && &b[8..12] == b"WEBP" {
|
||||
Some("image/webp")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 入站文本:群聊需 @bot;白名单校验;/new 重置;其余走 ai-chat 会话链路
|
||||
async fn handle_message(
|
||||
ctx: Arc<Ctx>,
|
||||
@@ -378,23 +476,47 @@ async fn handle_message(
|
||||
) {
|
||||
// D3:任何入站消息都是事件管道存活证据,先于一切过滤更新活性时间戳
|
||||
ctx.last_alive.store(now_secs(), Ordering::SeqCst);
|
||||
if msg.message_type != "text" {
|
||||
// 类型分流(#958 图片入站):text 原样;image 走下载装配(vision ContentPart);
|
||||
// 其余类型维持 #25 提示卡。image 消息 SDK 归一化已把 image_key 放入
|
||||
// msg.resources(ChannelResource),无需自行解析 content JSON。
|
||||
let image_key = if msg.message_type == "image" {
|
||||
match msg.resources.iter().find(|r| r.resource_type == "image") {
|
||||
Some(r) => Some(r.file_key.clone()),
|
||||
None => {
|
||||
let _ = reply_markdown_card(
|
||||
&ctx.client,
|
||||
&msg.message_id,
|
||||
"⚠️ 图片消息缺少 image_key,无法处理。",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if msg.message_type != "text" {
|
||||
// #25 非文本消息静默丢修复:回提示卡(D7 媒体能力前的兜底),不再零反馈。
|
||||
// 补替代路径引导:用户发图/文件往往是想让 AI 处理内容,直接告知改用文字描述
|
||||
// 补替代路径引导:用户发文件等往往是想让 AI 处理内容,直接告知改用文字描述
|
||||
let _ = reply_markdown_card(
|
||||
&ctx.client,
|
||||
&msg.message_id,
|
||||
"📎 暂仅支持文本消息,图片/文件等媒体能力规划中。\n\n如需处理图片/文件内容,请用文字描述你的需求(如「把这段文字翻译成英文:…」)。",
|
||||
"📎 暂支持文本与图片消息,文件/语音等其他类型规划中。\n\n如需处理文件内容,请用文字描述你的需求(如「把这段文字翻译成英文:…」)。",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if msg.chat_type == "group" && !msg.mentioned_bot {
|
||||
return;
|
||||
}
|
||||
let text = msg.text.clone().unwrap_or_default().trim().to_string();
|
||||
// 飞书 @ 占位清洗(如 @_user_1):@ 语义已由 mentioned_bot 承载,占位串只污染文本
|
||||
let text = strip_mention_placeholders(&text);
|
||||
// 图片消息不带文字(飞书 image 为独立消息类型),喂固定引导词交代任务语境;
|
||||
// msg.text 的 markdown 图链([图片](image_key))对模型无意义,不喂
|
||||
let text = if image_key.is_some() {
|
||||
IMAGE_INBOUND_PROMPT.to_string()
|
||||
} else {
|
||||
let text = msg.text.clone().unwrap_or_default().trim().to_string();
|
||||
// 飞书 @ 占位清洗(如 @_user_1):@ 语义已由 mentioned_bot 承载,占位串只污染文本
|
||||
strip_mention_placeholders(&text)
|
||||
};
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -427,6 +549,30 @@ async fn handle_message(
|
||||
return;
|
||||
}
|
||||
|
||||
// 图片入站(#958):下载消息资源→落盘→引用片。置于白名单后(未授权不下载,
|
||||
// 不替白名单外用户烧流量)、占位发票前(失败早退,无占位卡需原地更新)
|
||||
let parts = match &image_key {
|
||||
Some(key) => {
|
||||
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_image(&ctx.client, &store, &message_id, key).await {
|
||||
Ok(part) => Some(vec![part]),
|
||||
Err(e) => {
|
||||
feishu_log(&format!("图片下载失败 chat={chat_id}: {e}"));
|
||||
let _ = reply_markdown_card(
|
||||
&ctx.client,
|
||||
&message_id,
|
||||
&format!("⚠️ 图片下载失败:{e}\n\n请重发,或直接用文字描述你的需求。"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
// 命令通路(#30 容错):全角/大小写/附言归一后识别,仅命令词走控制面,任务原文不动。
|
||||
// 顺序约束:命令与审批决议须先于注入旁路——conv 活跃时这两类文本否则会被当补充
|
||||
// 指令注入任务;未知命令词落回常规任务路径,零行为变更
|
||||
@@ -578,7 +724,9 @@ 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 {
|
||||
if state.conv_states.is_active(&cid) {
|
||||
// 图片消息不注入:inject_runtime_message 只收文本,注入则图被丢、模型只见
|
||||
// 引导词空跑;落常规路径由受理守卫拦,语义诚实(任务占用中,稍后重发)
|
||||
if image_key.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;
|
||||
@@ -654,9 +802,16 @@ async fn handle_message(
|
||||
None,
|
||||
None,
|
||||
Some(conv_id.clone()),
|
||||
None,
|
||||
parts, // #958 图片入站:图片消息带 vision 片,文本消息 None;vision 能力判定/降级在 spawn 内
|
||||
None,
|
||||
false, // 不劫持用户活跃面板(编排节点同款语义)
|
||||
// #958 溯源:飞书原始消息定位随 user 消息落库,可跳原消息/区分多端来源
|
||||
Some(df_ai::provider::MessageSource {
|
||||
channel: "feishu".into(),
|
||||
message_id: message_id.clone(),
|
||||
chat_id: Some(chat_id.clone()),
|
||||
sender: if open_id.is_empty() { None } else { Some(open_id.clone()) },
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -717,10 +872,12 @@ async fn handle_message(
|
||||
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
cancel_watchers().lock().await.insert(conv_id.clone(), cancel_tx);
|
||||
let conv_watched = conv_id.clone();
|
||||
let watched_chat = chat_id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_completion_watcher(
|
||||
ctx,
|
||||
conv_id,
|
||||
watched_chat,
|
||||
ack,
|
||||
inflight_guard,
|
||||
cancel_rx,
|
||||
@@ -745,6 +902,145 @@ struct CardFrame {
|
||||
terminal: bool,
|
||||
}
|
||||
|
||||
// ── 出站文件回传(#958,u-claw replyLocalFiles 同模式) ──
|
||||
// 约定:AI 报告中写 `[交付] <绝对路径>` 行,渠道自动把文件上传飞书回发——
|
||||
// 图片走图片消息、其余走文件消息(飞书原生附件体验,免用户去桌面端翻)。
|
||||
// 正文中提及路径但无标记行=叙述,不发送(防止口述路径误上传)。
|
||||
|
||||
/// 提取报告中全部 `[交付] <路径>` 行的路径(纯函数)。容错:全半角冒号后空格可选,
|
||||
/// 路径含空格整行取(行内唯一路径假设,与 AI 约定一致)
|
||||
fn extract_delivery_paths(text: &str) -> Vec<String> {
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
let line = line.trim();
|
||||
let rest = line.strip_prefix("[交付]")?;
|
||||
let path = rest.trim().trim_start_matches(':').trim_start_matches(':').trim();
|
||||
(!path.is_empty()).then(|| path.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 敏感文件拦截(防御纵深,对齐 u-claw replyBlocked):名字命中凭据/密钥特征即拒,
|
||||
/// AI 被诱导交付敏感文件时兜底。返回 Some(原因)=拦截
|
||||
fn delivery_blocked(path: &str) -> Option<&'static str> {
|
||||
let lower = path.to_lowercase();
|
||||
const SENSITIVE: [&str; 8] = [
|
||||
".env", "id_rsa", "credential", "password", "secret", "token", "api_key", "apikey",
|
||||
];
|
||||
SENSITIVE
|
||||
.iter()
|
||||
.find(|k| lower.contains(**k))
|
||||
.map(|_| "敏感文件(凭据/密钥特征)")
|
||||
}
|
||||
|
||||
/// 扩展名 → 飞书 file_type(上传接口必填;对齐飞书开放平台枚举)
|
||||
fn feishu_file_type(ext: &str) -> &'static str {
|
||||
match ext {
|
||||
"pdf" => "pdf",
|
||||
"xls" | "xlsx" | "csv" => "xls",
|
||||
"doc" | "docx" | "rtf" => "doc",
|
||||
"ppt" | "pptx" => "ppt",
|
||||
"mp4" | "mov" | "avi" | "mkv" => "mp4",
|
||||
"mp3" | "wav" | "m4a" | "aac" | "flac" => "mp3",
|
||||
_ => "stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传回传全部交付文件。单文件失败只提示不中断(部分成功优于整体失败);
|
||||
/// 文件不存在静默跳过并记日志(过滤残余误匹配,"写了路径文件不在"经日志可查)
|
||||
async fn reply_deliveries(client: &LarkClient, chat_id: &str, text: &str) {
|
||||
for path in extract_delivery_paths(text) {
|
||||
if let Some(why) = delivery_blocked(&path) {
|
||||
feishu_log(&format!("回传跳过({why}): {path}"));
|
||||
send_text(client, chat_id, &format!("⚠️ 已拦截交付请求:{path}({why})")).await;
|
||||
continue;
|
||||
}
|
||||
let p = std::path::Path::new(&path);
|
||||
match tokio::fs::metadata(p).await {
|
||||
Ok(m) if m.is_file() => {}
|
||||
_ => {
|
||||
feishu_log(&format!("回传跳过(文件不存在或为目录): {path}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let bytes = match tokio::fs::read(p).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
feishu_log(&format!("回传读取失败({path}): {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let opt = &RequestOption::default();
|
||||
let ext = p
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().to_lowercase())
|
||||
.unwrap_or_default();
|
||||
let is_image = matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp");
|
||||
let sent: Result<(), String> = if is_image {
|
||||
// 图片:上传 image → image 消息直发(直发不做 reply,占位卡已是回复链,
|
||||
// 附件直发即可,避免嵌套回复噪声)
|
||||
match client.im().image.create("message", bytes, opt).await {
|
||||
Ok(resp) => match resp.data.as_ref().and_then(|d| d.image_key.clone()) {
|
||||
Some(key) => {
|
||||
let input = SendInput {
|
||||
msg_type: Some("image".into()),
|
||||
image_key: Some(key),
|
||||
chat_id: Some(chat_id.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
client
|
||||
.channel_messaging()
|
||||
.send(&input, opt)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("发送失败: {e}"))
|
||||
}
|
||||
None => Err("响应缺 image_key".into()),
|
||||
},
|
||||
Err(e) => Err(format!("图片上传失败: {e}")),
|
||||
}
|
||||
} else {
|
||||
// 文件:上传 file → file 消息直发(飞书好友互发文件同款体验)
|
||||
let name = p
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "file".into());
|
||||
match client
|
||||
.im()
|
||||
.file
|
||||
.create(feishu_file_type(&ext), &name, None, bytes, opt)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => match resp.data.as_ref().and_then(|d| d.file_key.clone()) {
|
||||
Some(key) => {
|
||||
let input = SendInput {
|
||||
msg_type: Some("file".into()),
|
||||
file_key: Some(key),
|
||||
chat_id: Some(chat_id.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
client
|
||||
.channel_messaging()
|
||||
.send(&input, opt)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("发送失败: {e}"))
|
||||
}
|
||||
None => Err("响应缺 file_key".into()),
|
||||
},
|
||||
Err(e) => Err(format!("文件上传失败: {e}")),
|
||||
}
|
||||
};
|
||||
if let Err(e) = sent {
|
||||
feishu_log(&format!("回传失败({path}): {e}"));
|
||||
let base = path.rsplit(['/', '\\']).next().unwrap_or(path.as_str());
|
||||
send_text(client, chat_id, &format!("⚠️ 文件回传失败:{base}({e})")).await;
|
||||
} else {
|
||||
feishu_log(&format!("回传完成: {path}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 完成事件订阅任务(#14 两段式):只做裁决——消费 ai_event_bus 的 AiAgentFinished(本 conv)
|
||||
/// 决定终版文本,经帧通道交给预览任务原地落卡(占位卡唯一写者是预览任务,受理卡/终版/阶段
|
||||
/// 提示全部经帧通道按序落卡)。引擎层 deliver_completion_report_if_waited 是广播单一发出点。
|
||||
@@ -755,6 +1051,7 @@ struct CardFrame {
|
||||
async fn run_completion_watcher(
|
||||
ctx: Arc<Ctx>,
|
||||
conv_id: String,
|
||||
chat_id: String,
|
||||
ack: String,
|
||||
// #20:计数生命周期跟任务走,guard 持有至 watcher 退出
|
||||
_inflight: InflightGuard,
|
||||
@@ -855,6 +1152,10 @@ async fn run_completion_watcher(
|
||||
}
|
||||
};
|
||||
feishu_log(&format!("watcher 终版 conv={conv_id} len={}", frame.text.len()));
|
||||
// #958 出站回传(u-claw replyLocalFiles 同模式):报告内 [交付] 标记行的本地文件
|
||||
// 自动上传飞书(图片→图片消息/其余→文件消息),用户在飞书直接收原生附件。
|
||||
// 终版文本中的标记行本身保留(AI 写路径也是叙述的一部分,卡内可见)
|
||||
reply_deliveries(&ctx.client, &chat_id, &frame.text).await;
|
||||
let _ = frame_tx.send(frame).await;
|
||||
let _ = stream_task.await; // 等预览任务落卡终版并退出,再释放 inflight
|
||||
return;
|
||||
@@ -1958,10 +2259,57 @@ async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap<String, Stri
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
heartbeat_due, is_terminal_push, md_to_lark_friendly, msg_after_acceptance, parse_command,
|
||||
strip_mention_placeholders, stream_preview,
|
||||
delivery_blocked, extract_delivery_paths, feishu_file_type, heartbeat_due,
|
||||
is_terminal_push, md_to_lark_friendly, msg_after_acceptance, parse_command,
|
||||
sniff_image_mime, strip_mention_placeholders, stream_preview,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn delivery_paths_extract_only_marked_lines() {
|
||||
// #958 出站回传:只认 [交付] 标记行,叙述路径不发送;全半角冒号容错
|
||||
let text = "方案如下。\n已生成图表:\n[交付] E:\\out\\chart.png\n另见 E:\\ref\\说明.docx\n[交付]:/home/u/报表 最终.xlsx\n[交付]";
|
||||
assert_eq!(
|
||||
extract_delivery_paths(text),
|
||||
vec!["E:\\out\\chart.png".to_string(), "/home/u/报表 最终.xlsx".to_string()]
|
||||
);
|
||||
assert!(extract_delivery_paths("没有标记").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivery_blocked_catches_credential_names() {
|
||||
assert!(delivery_blocked("E:\\proj\\.env").is_some());
|
||||
assert!(delivery_blocked("/home/u/id_rsa").is_some());
|
||||
assert!(delivery_blocked("E:\\app\\API_KEY.txt").is_some());
|
||||
assert_eq!(delivery_blocked("E:\\out\\chart.png"), None);
|
||||
assert_eq!(delivery_blocked("E:\\out\\report.pdf"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feishu_file_type_maps_common_exts() {
|
||||
assert_eq!(feishu_file_type("pdf"), "pdf");
|
||||
assert_eq!(feishu_file_type("xlsx"), "xls");
|
||||
assert_eq!(feishu_file_type("docx"), "doc");
|
||||
assert_eq!(feishu_file_type("mp4"), "mp4");
|
||||
assert_eq!(feishu_file_type("zip"), "stream");
|
||||
assert_eq!(feishu_file_type(""), "stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_mime_sniff_four_formats() {
|
||||
// #958:四格式魔数命中 + 未知格式拒收(宁早拒不晚炸 vision 端点 400)
|
||||
assert_eq!(sniff_image_mime(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A]), Some("image/png"));
|
||||
assert_eq!(sniff_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg"));
|
||||
assert_eq!(sniff_image_mime(b"GIF89a"), Some("image/gif"));
|
||||
assert_eq!(
|
||||
sniff_image_mime(b"RIFF\x24\x08\x00\x00WEBPVP8 "),
|
||||
Some("image/webp")
|
||||
);
|
||||
assert_eq!(sniff_image_mime(b"<html>"), None);
|
||||
assert_eq!(sniff_image_mime(&[]), None);
|
||||
// RIFF 但非 WEBP(avi/wav 等)不误判
|
||||
assert_eq!(sniff_image_mime(b"RIFF\x24\x08\x00\x00AVI "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_case_and_tail_tolerance() {
|
||||
// 30:/new 大小写与附言容错,附言不烧 LLM
|
||||
|
||||
Reference in New Issue
Block a user