AnyBot/src/attach.rs

206 lines
9.8 KiB
Rust
Raw Normal View History

// Files and pictures the person gives a bot with a message (the original: 附件 — drag files in,
// paste a picture, or pick files; pictures go to the model, documents are read for it).
// Each one is copied into AnyBot's data folder, so the chat keeps working after the original moves.
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// Largest file accepted (estimated).
pub const MAX_FILE_BYTES: u64 = 52_428_800;
/// Text read from one file for the model (estimated; the original reads 20,000 characters of mail
/// and notes).
pub const MAX_TEXT_CHARS: usize = 20_000;
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub struct Attachment {
pub name: String,
/// The copy in AnyBot's data folder.
pub path: String,
#[serde(default)]
pub image: bool,
#[serde(default)]
pub bytes: u64,
/// Where it was also put on the bot's own computer (empty when not).
#[serde(default)]
pub on_box: String,
}
fn dir_for(bot_id: u64) -> PathBuf {
crate::model::State::path().parent().map(|p| p.to_path_buf()).unwrap_or_else(std::env::temp_dir).join("attachments").join(format!("bot-{bot_id}"))
}
pub fn is_image_name(name: &str) -> bool {
let n = name.to_lowercase();
[".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".heic", ".tif", ".tiff"].iter().any(|e| n.ends_with(e))
}
/// Copy a file in as an attachment.
pub fn store(bot_id: u64, src: &Path) -> Result<Attachment, String> {
let meta = std::fs::metadata(src).map_err(|e| format!("讀不到 {}:{e}", src.display()))?;
if meta.is_dir() {
return Err(format!("「{}」是資料夾,請壓縮後再附加,或請 Bot 直接處理那個資料夾。", src.display()));
}
if meta.len() > MAX_FILE_BYTES {
return Err(format!("「{}」太大了(最多 50 MB)", src.display()));
}
let name = src.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "附件".into());
let dir = dir_for(bot_id);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let dst = dir.join(format!("{}-{name}", chrono::Local::now().format("%Y%m%d%H%M%S%3f")));
std::fs::copy(src, &dst).map_err(|e| format!("複製失敗:{e}"))?;
Ok(Attachment { image: is_image_name(&name), name, path: dst.display().to_string(), bytes: meta.len(), on_box: String::new() })
}
/// A picture on the clipboard (e.g. a screenshot copied with ⌃⇧⌘4) becomes an attachment.
pub fn paste_image(bot_id: u64) -> Option<Attachment> {
let dir = dir_for(bot_id);
std::fs::create_dir_all(&dir).ok()?;
let name = format!("貼上的圖片 {}.png", chrono::Local::now().format("%H%M%S"));
let dst = dir.join(format!("{}-{name}", chrono::Local::now().format("%Y%m%d%H%M%S%3f")));
if !cfg!(target_os = "macos") {
let png = crate::platform::clipboard_png_linux()?;
std::fs::write(&dst, &png).ok()?;
return Some(Attachment { name, path: dst.display().to_string(), image: true, bytes: png.len() as u64, on_box: String::new() });
}
let script = r#"ObjC.import('AppKit');
function run(argv) {
const img = $.NSImage.alloc.initWithPasteboard($.NSPasteboard.generalPasteboard);
if (img.isNil()) return 'none';
const rep = $.NSBitmapImageRep.imageRepWithData(img.TIFFRepresentation);
const png = rep.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $({}));
return png.writeToFileAtomically(argv[0], true) ? 'ok' : 'fail';
}"#;
let out = std::process::Command::new("osascript").args(["-l", "JavaScript", "-e", script, "--"]).arg(&dst).output().ok()?;
if String::from_utf8_lossy(&out.stdout).trim() != "ok" {
return None;
}
let bytes = std::fs::metadata(&dst).map(|m| m.len()).unwrap_or(0);
Some(Attachment { name, path: dst.display().to_string(), image: true, bytes, on_box: String::new() })
}
/// The Finder file picker (several files at once).
pub fn pick_files() -> Vec<PathBuf> {
if !cfg!(target_os = "macos") {
return crate::platform::pick_files_linux(&crate::i18n::t("選擇要附加的檔案"));
}
let script = r#"function run() {
const app = Application.currentApplication(); app.includeStandardAdditions = true;
try { return app.chooseFile({withPrompt: '選擇要附加的檔案', multipleSelectionsAllowed: true}).map(f => f.toString()).join('\n'); } catch (e) { return ''; }
}"#;
std::process::Command::new("osascript")
.args(["-l", "JavaScript", "-e", script])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::trim).filter(|l| !l.is_empty()).map(PathBuf::from).collect())
.unwrap_or_default()
}
pub fn human(b: u64) -> String {
crate::cloudbox::human(b)
}
/// A picture, sized like a screenshot, as base64 JPEG.
pub fn image_b64(path: &str) -> Option<String> {
// The viewer's decoder: every common format, SVG, and HEIC/AVIF through sips or ffmpeg.
let img = image::DynamicImage::ImageRgba8(crate::preview::decode_image(std::path::Path::new(path))?);
let img = img.to_rgb8();
let (w, h) = img.dimensions();
let (fw, fh, resize) = crate::computer::fit(w, h);
let img = if resize { image::imageops::resize(&img, fw, fh, image::imageops::FilterType::Triangle) } else { img };
crate::computer::encode_jpeg(&img).map(|j| base64::engine::general_purpose::STANDARD.encode(j))
}
fn clip(s: &str) -> String {
let n = s.chars().count();
if n <= MAX_TEXT_CHARS {
s.to_string()
} else {
format!("{}\n…(後面省略 {} 個字)", s.chars().take(MAX_TEXT_CHARS).collect::<String>(), n - MAX_TEXT_CHARS)
}
}
/// The text inside a document, when it can be read here.
pub fn read_text(path: &str, name: &str) -> Option<String> {
let n = name.to_lowercase();
let ext = n.rsplit('.').next().unwrap_or("");
// Word and web pages: read in AnyBot itself (same on every system).
if ext == "docx" {
if let Ok(md) = crate::preview::docx_markdown(std::path::Path::new(path)) {
return Some(md).filter(|t| !t.trim().is_empty());
}
}
if ext == "html" || ext == "htm" {
let bytes = std::fs::read(path).ok()?;
return Some(crate::preview::html_markdown(&String::from_utf8_lossy(&bytes))).filter(|t| !t.trim().is_empty());
}
if ["docx", "doc", "rtf", "rtfd", "odt", "webarchive", "pages"].contains(&ext) && cfg!(target_os = "macos") {
let out = std::process::Command::new("textutil").args(["-convert", "txt", "-stdout", path]).output().ok()?;
return out.status.success().then(|| String::from_utf8_lossy(&out.stdout).to_string()).filter(|t| !t.trim().is_empty());
}
if ext == "pdf" {
// pdftotext keeps the layout best; without it, read the text in AnyBot (pure Rust).
if let Some(t) = std::process::Command::new("pdftotext").env("PATH", crate::cloudbox::tool_path()).args(["-layout", path, "-"]).output().ok().filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).to_string()).filter(|t| !t.trim().is_empty()) {
return Some(t);
}
let bytes = std::fs::read(path).ok()?;
// NFKC: PDFs often carry CJK radicals ("⽂") for the ordinary characters ("文").
use unicode_normalization::UnicodeNormalization;
return std::panic::catch_unwind(|| pdf_extract::extract_text_from_mem(&bytes).ok()).ok().flatten().map(|t| t.nfkc().collect::<String>()).filter(|t| !t.trim().is_empty());
}
let bytes = std::fs::read(path).ok()?;
if bytes.len() > 2 * 1024 * 1024 || bytes.iter().take(8000).any(|b| *b == 0) {
return None;
}
String::from_utf8(bytes).ok()
}
/// What the model gets for a message's attachments: extra text and pictures.
pub fn for_model(atts: &[Attachment]) -> (String, Vec<String>) {
let mut text = String::new();
let mut images = vec![];
for a in atts {
let where_ = if a.on_box.is_empty() { format!("檔案存在 AnyBot 所在的電腦:{}", a.path) } else { format!("檔案存在 AnyBot 所在的電腦:{};也已放進你自己的電腦:{}", a.path, a.on_box) };
if a.image {
match image_b64(&a.path) {
Some(b) => {
images.push(b);
text.push_str(&format!("\n\n[附件圖片「{}」,就是下面這張。{where_}]", a.name));
}
None => text.push_str(&format!("\n\n[附件圖片「{}」讀不出來。{where_}]", a.name)),
}
} else if let Some(t) = read_text(&a.path, &a.name) {
text.push_str(&format!("\n\n[附件「{}」({})。{where_}]\n```\n{}\n```", a.name, human(a.bytes), clip(&t)));
} else {
text.push_str(&format!("\n\n[附件「{}」({}),這裡讀不出內容,可以用工具處理。{where_}]", a.name, human(a.bytes)));
}
}
(text, images)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_and_images_reach_the_model() {
let dir = std::env::temp_dir().join(format!("anybot-attach-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let txt = dir.join("筆記.md");
std::fs::write(&txt, "# 標題\n內容 hello").unwrap();
let png = dir.join("圖.png");
image::RgbImage::from_pixel(2000, 1000, image::Rgb([10, 20, 30])).save(&png).unwrap();
let bin = dir.join("data.bin");
std::fs::write(&bin, [0u8, 1, 2, 0, 5]).unwrap();
let atts: Vec<Attachment> = [&txt, &png, &bin].iter().map(|p| store(99_999, p).unwrap()).collect();
assert!(atts[1].image && !atts[0].image);
let (text, images) = for_model(&atts);
assert!(text.contains("內容 hello") && text.contains("附件圖片「圖.png」") && text.contains("讀不出內容"), "{text}");
assert_eq!(images.len(), 1);
for a in &atts {
let _ = std::fs::remove_file(&a.path);
}
let _ = std::fs::remove_dir_all(&dir);
assert!(store(1, Path::new("/nonexistent/x")).is_err());
}
}