// 電腦現況: what a person sitting at each computer would already know before starting — which // system it is, whether it has a desktop and systemd, whether sudo works, whether the network // is up, how to install things, which apps are there. Bots without this spent most of a job // finding it out (or never did: they "set the timezone" through an environment variable because // they believed sudo was broken, and tried timedatectl on a box without systemd). // // Read once per computer by one small read-only script, kept for half an hour, and put in the // instructions at the start of a turn. The person's own computers are read only when the bot // may already work on them. use std::collections::HashMap; use std::sync::Mutex; use std::time::{Duration, Instant}; use crate::tools::{Place, Target}; /// How long a computer's facts are trusted (they change when someone installs or reconfigures). pub const FRESH_SECS: u64 = 30 * 60; /// Longest a turn waits for facts it doesn't have yet; late answers still fill the cache. pub const WAIT_MS: u64 = 6_000; /// Apps listed at most (the rest is "…"). const MAX_APPS: usize = 30; /// Read-only, POSIX sh, the same on Linux and macOS: one `key=value` per line. pub const PROBE: &str = r#" k() { printf '%s=%s\n' "$1" "$2"; } k kernel "$(uname -s)"; k arch "$(uname -m)" if [ -r /etc/os-release ]; then . /etc/os-release; k os "$PRETTY_NAME"; elif command -v sw_vers >/dev/null 2>&1; then k os "macOS $(sw_vers -productVersion)"; fi [ "$(uname -s)" = Linux ] && { [ -d /run/systemd/system ] && k systemd yes || k systemd no; } if [ "$(id -u)" = 0 ]; then k sudo root; elif command -v sudo >/dev/null 2>&1; then if sudo -n true >/dev/null 2>&1; then k sudo yes; else k sudo password; fi; else k sudo none; fi if command -v curl >/dev/null 2>&1; then curl -s -m 4 -o /dev/null https://www.google.com/generate_204 && k net yes || k net no; fi for p in apt-get dnf yum pacman apk zypper brew; do command -v $p >/dev/null 2>&1 && k pkg $p; done k desktop "${XDG_CURRENT_DESKTOP:-}" [ -n "${TZ:-}" ] && k tz "$TZ" for w in fluxbox gnome-shell plasmashell xfce4-session mate-session cinnamon sway Hyprland; do pgrep -x $w >/dev/null 2>&1 && k wm $w; done t=""; for x in python3 node npm git docker curl wget jq ffmpeg xdotool wmctrl osascript shortcuts brew; do command -v $x >/dev/null 2>&1 && t="$t $x"; done; k tools "$t" if [ -d /usr/share/applications ]; then k apps "$(for f in /usr/share/applications/*.desktop; do sed -n 's/^Name=//p' "$f" | head -1; done 2>/dev/null | sort -u | head -60 | tr '\n' '|')"; fi if [ -d /Applications ]; then k apps "$(ls /Applications 2>/dev/null | sed -n 's/\.app$//p' | head -80 | tr '\n' '|')"; fi "#; #[derive(Default, Debug, Clone, PartialEq)] pub struct Facts { pub kernel: String, pub arch: String, pub os: String, /// Some(true/false) on Linux; None elsewhere. pub systemd: Option, /// root, yes, password, none. pub sudo: String, pub net: Option, pub pkg: Vec, pub desktop: String, /// A TZ variable every program is started with (it overrides /etc/localtime). pub tz: String, pub wm: Vec, pub tools: Vec, pub apps: Vec, } pub fn parse(out: &str) -> Facts { let mut f = Facts::default(); for line in out.lines() { let Some((k, v)) = line.split_once('=') else { continue }; let v = v.trim(); match k.trim() { "kernel" => f.kernel = v.into(), "arch" => f.arch = v.into(), "os" => f.os = v.into(), "systemd" => f.systemd = Some(v == "yes"), "sudo" => f.sudo = v.into(), "net" => f.net = Some(v == "yes"), "pkg" => f.pkg.push(v.into()), "desktop" => f.desktop = v.into(), "tz" => f.tz = v.into(), "wm" => f.wm.push(v.into()), "tools" => f.tools = v.split_whitespace().map(String::from).collect(), "apps" => f.apps.extend(v.split('|').map(str::trim).filter(|a| !a.is_empty()).map(String::from)), _ => {} } } f.apps.sort(); f.apps.dedup(); f } /// One line about a computer, in the words the bot is instructed in. `own_box`: the bot's own /// box, where AnyBot runs sudo commands as root (sudo itself can't rise under gVisor). pub fn describe(name: &str, f: &Facts, own_box: bool, mac_sandbox: bool) -> String { let mut parts: Vec = vec![]; let os = if f.os.is_empty() { f.kernel.clone() } else { f.os.clone() }; parts.push(if f.arch.is_empty() { os } else { format!("{os}({})", f.arch) }); let desk = if !f.wm.is_empty() { f.wm.join("、") } else { f.desktop.clone() }; if !desk.is_empty() { parts.push(format!("桌面 {desk}")); } match f.systemd { Some(false) => parts.push("沒有 systemd(systemctl、timedatectl、hostnamectl 都不能用:改系統設定就直接改設定檔,例如時區是 /etc/localtime 和 /etc/timezone)".into()), Some(true) => parts.push("有 systemd".into()), None => {} } let sudo = if own_box { "可以 sudo(免密碼;AnyBot 會用 root 執行含 sudo 的指令)" } else { match f.sudo.as_str() { "root" => "指令以 root 執行", "yes" => "可以 sudo(免密碼)", "password" if mac_sandbox => "sudo 在指令沙盒裡不能用", "password" => "sudo 要密碼(不要嘗試,需要時請使用者自己執行)", "none" => "沒有 sudo", _ => "", } }; if !sudo.is_empty() { parts.push(sudo.into()); } if !f.tz.is_empty() && f.kernel != "Darwin" { parts.push(format!("每個程式都帶著環境變數 TZ={}(容器設定的,會蓋過 /etc/localtime:改了系統時區,date 和桌面時鐘還是顯示 {};確認系統時區用 env -u TZ date,或 readlink /etc/localtime)", f.tz, f.tz)); } match f.net { Some(true) => parts.push("有網路".into()), Some(false) => parts.push("連不到網路(不要嘗試下載或安裝,先告訴使用者)".into()), None => {} } if let Some(p) = f.pkg.iter().find(|p| p.as_str() != "brew").or(f.pkg.first()) { parts.push(format!("裝東西用 {p}")); } if !f.tools.is_empty() { parts.push(format!("指令有 {}", f.tools.join("、"))); } if mac_sandbox { parts.push("指令在 AnyBot 的沙盒裡跑:ps、top、App Store 版 App 自己的指令列、單獨一條 osascript -e 會在沙盒外跑,其他碰不到祕密資料夾".into()); } // What anyone used to the system knows about its standard windows and dialogs: an app the bot // never saw still uses them (it lost minutes to TextEdit's save panel, then changed the format // nobody asked about). if f.kernel == "Darwin" { parts.push("Mac 的操作慣例:存檔、開檔對話框和 Finder 裡按 cmd+shift+G 可以直接打資料夾的完整路徑再按 Return;存檔對話框在「儲存為」打檔名後按 Return 就存好了;cmd+W 關掉目前的視窗、cmd+Q 結束 App(有沒存的會先問);檔案格式、副檔名這類選項使用者沒說就照預設,不要另外改;指令是用 /bin/sh 跑的,echo -n 會把「-n」也寫出去,要不換行就用 printf;讀到的結果和預期不同(多了字、少了字)就照實說,不要說成預期的樣子".into()); } else if f.kernel == "Linux" && (!f.wm.is_empty() || !f.desktop.is_empty()) { parts.push("Linux 桌面的操作慣例:GTK 的開檔、存檔對話框按 ctrl+L 可以直接打完整路徑再按 Return;ctrl+S 存檔、ctrl+W 關視窗、ctrl+Q 結束 App;檔案格式這類選項使用者沒說就照預設".into()); } if !f.apps.is_empty() { let more = if f.apps.len() > MAX_APPS { "…" } else { "" }; parts.push(format!("裝了的 App:{}{more}", f.apps.iter().take(MAX_APPS).cloned().collect::>().join("、"))); } format!("「{name}」:{}。", parts.join(";")) } fn cache() -> &'static Mutex> { static C: std::sync::OnceLock>> = std::sync::OnceLock::new(); C.get_or_init(|| Mutex::new(HashMap::new())) } /// Which computers to read, and whether they may be read: the bot's own always; the person's /// only when the bot may already work there (no command runs on them for this otherwise). fn readable(t: &Target, may_use_persons: bool) -> bool { match &t.place { Place::Box { .. } => crate::cloudbox::run_state().is_running(), Place::Local { .. } => true, Place::Device { .. } | Place::Ssh { .. } => may_use_persons, Place::Sealed { .. } => false, } } /// Run the probe on one computer. The person's own Mac (this one) is read by AnyBot itself, not /// as one of the bot's commands: nothing the bot asked runs there. fn probe(t: &Target, via: &str) -> Result { match &t.place { Place::Box { .. } => crate::cloudbox::read_facts(PROBE), Place::Local { .. } => { let out = std::process::Command::new("/bin/sh").arg("-c").arg(PROBE).env("PATH", crate::cloudbox::tool_path()).output().map_err(|e| e.to_string())?; Ok(String::from_utf8_lossy(&out.stdout).to_string()) } Place::Device { host, .. } => { let args = serde_json::json!({"command": PROBE, "timeout_ms": 8000}).to_string(); crate::remote::run_on_device(host, "shell", &args, via).map(|v| v["text"].as_str().unwrap_or("").to_string()) } Place::Ssh { .. } => { let cancel = std::sync::atomic::AtomicBool::new(false); let ids = crate::tools::RunIds { conversation: "facts".into(), request: "facts".into() }; let args = serde_json::json!({"command": PROBE, "timeout_ms": 8000}).to_string(); let (out, ok) = crate::tools::run_on("shell", &args, t, &cancel, &ids); if ok { Ok(out) } else { Err(out) } } Place::Sealed { .. } => Err("no".into()), } } fn key(t: &Target) -> String { format!("{}|{:?}", t.name, t.place) } /// The facts section for a turn's instructions: cached facts, plus fresh reads of the rest (at /// most WAIT_MS for all of them together; a slow one is used next turn). pub fn for_turn(computers: &[Target], may_use_persons: bool, via: &str) -> String { let wanted: Vec<&Target> = computers.iter().filter(|t| readable(t, may_use_persons)).collect(); let fresh = |t: &Target| cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key(t)).filter(|(at, _)| at.elapsed().as_secs() < FRESH_SECS).map(|(_, l)| l.clone()); let missing: Vec = wanted.iter().filter(|t| fresh(t).is_none()).map(|t| (*t).clone()).collect(); if !missing.is_empty() { let (tx, rx) = std::sync::mpsc::channel(); for t in missing { let tx = tx.clone(); let via = via.to_string(); std::thread::spawn(move || { if let Ok(out) = probe(&t, &via) { let f = parse(&out); if !f.kernel.is_empty() || !f.os.is_empty() { let mac_sandbox = matches!(t.place, Place::Local { .. }) && cfg!(target_os = "macos") || f.kernel == "Darwin" && matches!(t.place, Place::Device { .. }); let line = describe(&t.name, &f, matches!(t.place, Place::Box { .. }), mac_sandbox); cache().lock().unwrap_or_else(|e| e.into_inner()).insert(key(&t), (Instant::now(), line)); } } let _ = tx.send(()); }); } drop(tx); let end = Instant::now() + Duration::from_millis(WAIT_MS); while let Some(left) = end.checked_duration_since(Instant::now()) { if rx.recv_timeout(left).is_err() { break; } } } let lines: Vec = wanted.iter().filter_map(|t| fresh(t)).collect(); if lines.is_empty() { return String::new(); } format!( "\n\n電腦現況(AnyBot 剛剛自動查的,照這個選做法,不用再花步驟去試探):\n{}", lines.iter().map(|l| format!("- {l}")).collect::>().join("\n") ) } #[cfg(test)] mod tests { use super::*; const BOX: &str = "kernel=Linux\narch=aarch64\nos=Debian GNU/Linux 13 (trixie)\nsystemd=no\nsudo=password\nnet=yes\npkg=apt-get\ndesktop=\ntz=UTC\nwm=fluxbox\ntools= python3 node npm git curl wget jq xdotool\napps=Chromium|XTerm|galculator|\n"; const MAC: &str = "kernel=Darwin\narch=arm64\nos=macOS 26.1\nsudo=password\nnet=yes\npkg=brew\ndesktop=\ntools= python3 git curl osascript shortcuts brew\napps=Tailscale|LINE|Safari|\n"; #[test] fn a_box_reads_like_a_person_would_know_it() { let f = parse(BOX); assert_eq!(f.systemd, Some(false)); assert_eq!(f.net, Some(true)); assert_eq!(f.apps, vec!["Chromium", "XTerm", "galculator"]); let line = describe("Bot 的電腦", &f, true, false); for want in ["Debian GNU/Linux 13", "fluxbox", "沒有 systemd", "/etc/localtime", "可以 sudo(免密碼", "有網路", "apt-get", "galculator", "TZ=UTC", "env -u TZ date"] { assert!(line.contains(want), "{want}: {line}"); } } #[test] fn a_mac_says_what_its_sandbox_lets_through() { let line = describe("王小明的Mac mini", &parse(MAC), false, true); assert!(line.contains("macOS 26.1") && line.contains("沙盒") && line.contains("osascript") && line.contains("裝東西用 brew") && line.contains("Tailscale"), "{line}"); assert!(line.contains("cmd+shift+G") && line.contains("照預設") && line.contains("printf"), "{line}"); assert!(describe("Bot 的電腦", &parse(BOX), true, false).contains("ctrl+L")); assert!(!line.contains("systemd"), "{line}"); // Offline and no sudo, said plainly so the bot doesn't try. let off = describe("x", &parse("kernel=Linux\nnet=no\nsudo=password\n"), false, false); assert!(off.contains("連不到網路") && off.contains("sudo 要密碼"), "{off}"); } /// The probe runs here and says something sensible (any Unix). #[test] fn the_probe_runs_on_this_computer() { let out = std::process::Command::new("/bin/sh").arg("-c").arg(PROBE).output().unwrap(); let f = parse(&String::from_utf8_lossy(&out.stdout)); assert!(!f.kernel.is_empty() && !f.arch.is_empty(), "{f:?}"); } }