AnyBot/src/facts.rs

272 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 電腦現況: 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<bool>,
/// root, yes, password, none.
pub sudo: String,
pub net: Option<bool>,
pub pkg: Vec<String>,
pub desktop: String,
/// A TZ variable every program is started with (it overrides /etc/localtime).
pub tz: String,
pub wm: Vec<String>,
pub tools: Vec<String>,
pub apps: Vec<String>,
}
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<String> = 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::<Vec<_>>().join("、")));
}
format!("「{name}」:{}。", parts.join(";"))
}
fn cache() -> &'static Mutex<HashMap<String, (Instant, String)>> {
static C: std::sync::OnceLock<Mutex<HashMap<String, (Instant, String)>>> = 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<String, String> {
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<Target> = 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<String> = 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::<Vec<_>>().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:?}");
}
}