262 lines
13 KiB
Rust
262 lines
13 KiB
Rust
|
|
// 電腦現況: 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());
|
|||
|
|
}
|
|||
|
|
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("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:?}");
|
|||
|
|
}
|
|||
|
|
}
|