AnyBot/src/remote.rs

1357 lines
67 KiB
Rust
Raw Normal View History

// 手機遙控: use AnyBot from a phone (or any browser).
//
// Two ways in, one API:
// • 區域網路/Tailscale: this AnyBot answers on port 8793 itself (password login).
// • 帳號(薄後端): this AnyBot keeps an outgoing connection to the relay (relay/ in this repo,
// run on a small server). The phone signs in to the relay with the account, and the relay hands
// each request to this AnyBot. No open port, works from anywhere, and the relay also keeps the
// account, the device list and an encrypted backup of AnyBot's data.
//
// The original keeps its brain in its cloud and reaches devices from there (its device tools wait
// 30 s for the device). Here the brain stays in AnyBot — on this Mac, or on an always-on server
// started with `anybot --serve` — and the relay only passes requests along.
use crate::httpd::{self, Req, Resp};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex, OnceLock};
use std::time::{Duration, Instant};
pub const LAN_PORT: u16 = 8793;
/// How long the phone's "anything new?" request waits.
pub const LONG_POLL_SECS: u64 = 20;
/// Wrong passwords allowed before logins slow down.
const MAX_FAILS: u32 = 5;
#[derive(Clone, Serialize, Deserialize, Debug, Default, PartialEq)]
pub struct Device {
pub name: String,
pub token_hash: String,
pub created: i64,
#[serde(default)]
pub last_seen: i64,
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub struct Settings {
/// Answer phones on the local network (and Tailscale) on LAN_PORT.
#[serde(default)]
pub lan: bool,
/// The phone password, stored as salt$hash.
#[serde(default)]
pub password: String,
/// Phones and browsers signed in directly.
#[serde(default)]
pub devices: Vec<Device>,
/// The phone may see and operate this Mac's own screen (not just the bot's computer).
#[serde(default)]
pub mac_from_phone: bool,
/// Other AnyBots in the same account may operate this Mac (裝置連結). Each action still follows
/// this Mac's rules and asks the person on the brain's side.
#[serde(default)]
pub device_link: bool,
/// The relay (thin backend), e.g. https://anybot.example.com
#[serde(default)]
pub relay_url: String,
#[serde(default)]
pub relay_user: String,
/// This AnyBot's own key at the relay (a "host").
#[serde(default)]
pub host_id: String,
#[serde(default)]
pub host_token: String,
/// What this AnyBot is called on the phone.
#[serde(default = "default_host_name")]
pub host_name: String,
/// Key for the encrypted backup (made from the account password; never sent).
#[serde(default)]
pub backup_key: String,
#[serde(default)]
pub auto_backup: bool,
#[serde(default)]
pub last_backup: i64,
/// 大腦: the AnyBot of this account whose bots, chats and settings this one shows (its relay
/// host id). Empty = this AnyBot is its own brain. When set, this one is a screen and hands.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub brain_id: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub brain_name: String,
/// 手腳: only this brain (host id) may use this computer (empty = any AnyBot of the account).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub hands_for: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub hands_for_name: String,
}
impl Default for Settings {
fn default() -> Self {
Settings { lan: false, password: String::new(), devices: vec![], mac_from_phone: false, device_link: false, relay_url: String::new(), relay_user: String::new(), host_id: String::new(), host_token: String::new(), host_name: default_host_name(), backup_key: String::new(), auto_backup: true, last_backup: 0, brain_id: String::new(), brain_name: String::new(), hands_for: String::new(), hands_for_name: String::new() }
}
}
pub fn default_host_name() -> String {
if !cfg!(target_os = "macos") {
let pretty = std::process::Command::new("hostnamectl").arg("--pretty").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
let name = if pretty.is_empty() { std::fs::read_to_string("/etc/hostname").unwrap_or_default().trim().to_string() } else { pretty };
if !name.is_empty() {
return name;
}
}
let out = std::process::Command::new("scutil").args(["--get", "ComputerName"]).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
if !out.is_empty() {
return out;
}
std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()).unwrap_or_else(|| "AnyBot".into())
}
/// What the phone asks AnyBot to do; the app carries these out on its own thread.
#[derive(Clone, Debug, PartialEq)]
pub enum Cmd {
Send { bot: u64, text: String, attachments: Vec<String> },
Stop { bot: u64 },
Answer { bot: u64, answer: String },
Read { bot: u64 },
NewThread { bot: u64 },
/// Answer the last message again (重新產生) / reply to what is there (重新傳送).
Regenerate { bot: u64 },
Reply { bot: u64 },
/// Bring back an earlier conversation (過去的對話).
RestoreThread { bot: u64, index: usize },
/// A new phone signed in (to remember), or one was signed out.
DeviceAdded(Device),
DeviceSeen(String),
}
/// What the phone can see, refreshed by the app when something changes.
#[derive(Default, Clone)]
pub struct View {
pub host: String,
pub bots: Vec<Value>,
pub approvals: Vec<Value>,
pub messages: HashMap<u64, Vec<Value>>,
/// 頁面, for the phone's page view.
pub pages: Vec<crate::pages::Page>,
pub version: u64,
}
pub struct Hub {
view: Mutex<View>,
changed: Condvar,
cmds: Mutex<Vec<Cmd>>,
repaint: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
settings: Mutex<Settings>,
fails: Mutex<(u32, Option<Instant>)>,
relay_status: Mutex<String>,
sync: Mutex<Vec<SyncReq>>,
}
/// 大腦: another AnyBot of this account (a screen) asks for what changed, or sends its edits.
pub enum SyncKind {
Pull { client: String, since: u64 },
Edit(Value),
/// A provider's model list, fetched with the brain's own key (screens don't have it).
Models(String),
}
pub struct SyncReq {
pub kind: SyncKind,
pub reply: std::sync::mpsc::Sender<Value>,
}
pub fn hub() -> &'static Hub {
static H: OnceLock<Hub> = OnceLock::new();
H.get_or_init(|| Hub { view: Mutex::new(View::default()), changed: Condvar::new(), cmds: Mutex::new(vec![]), repaint: Mutex::new(None), settings: Mutex::new(Settings::default()), fails: Mutex::new((0, None)), relay_status: Mutex::new(String::new()), sync: Mutex::new(vec![]) })
}
static VERSION: AtomicU64 = AtomicU64::new(1);
impl Hub {
pub fn publish(&self, mut v: View) {
v.version = VERSION.fetch_add(1, Ordering::SeqCst) + 1;
*self.view.lock().unwrap() = v;
self.changed.notify_all();
}
pub fn take_sync(&self) -> Vec<SyncReq> {
std::mem::take(&mut *self.sync.lock().unwrap())
}
/// Hand a sync request to the app's thread and wait for its answer.
fn ask_sync(&self, kind: SyncKind) -> Result<Value, String> {
self.ask_sync_for(kind, 10)
}
fn ask_sync_for(&self, kind: SyncKind, secs: u64) -> Result<Value, String> {
let (tx, rx) = std::sync::mpsc::channel();
self.sync.lock().unwrap().push(SyncReq { kind, reply: tx });
if let Some(r) = self.repaint.lock().unwrap().as_ref() {
r();
}
rx.recv_timeout(Duration::from_secs(secs)).map_err(|_| "大腦太忙,沒有及時回應".to_string())
}
pub fn version(&self) -> u64 {
self.view.lock().unwrap().version
}
pub fn take_cmds(&self) -> Vec<Cmd> {
std::mem::take(&mut *self.cmds.lock().unwrap())
}
fn push(&self, c: Cmd) {
self.cmds.lock().unwrap().push(c);
if let Some(r) = self.repaint.lock().unwrap().as_ref() {
r();
}
}
pub fn set_repaint(&self, f: Arc<dyn Fn() + Send + Sync>) {
*self.repaint.lock().unwrap() = Some(f);
}
pub fn set_settings(&self, s: &Settings) {
*self.settings.lock().unwrap() = s.clone();
}
pub fn settings(&self) -> Settings {
self.settings.lock().unwrap().clone()
}
pub fn relay_status(&self) -> String {
self.relay_status.lock().unwrap().clone()
}
fn set_relay_status(&self, s: &str) {
*self.relay_status.lock().unwrap() = s.to_string();
}
/// The view once it is newer than `since` (or after the wait).
fn wait_newer(&self, since: u64, secs: u64) -> View {
let g = self.view.lock().unwrap();
let (g, _) = self.changed.wait_timeout_while(g, Duration::from_secs(secs), |v| v.version <= since).unwrap();
g.clone()
}
}
// ---------- shaping the chat for the phone ----------
pub fn tool_label(name: &str) -> &'static str {
if crate::appctl::is_app_tool(name) {
crate::appctl::label(name)
} else if crate::macapps::is_mac_tool(name) {
crate::macapps::label(name)
} else if crate::computer::is_computer_tool(name) {
crate::computer::label(name)
} else if crate::web::is_web_tool(name) {
crate::web::label(name)
} else {
crate::tools::label(name)
}
}
fn tool_detail(name: &str, args: &str) -> String {
let d = if crate::appctl::is_app_tool(name) {
crate::appctl::describe(name, args)
} else if crate::computer::is_computer_tool(name) {
crate::computer::describe(name, args)
} else if crate::web::is_web_tool(name) {
crate::web::describe(name, args)
} else {
crate::tools::describe(name, args)
};
d.chars().take(120).collect()
}
/// One message as the phone shows it (tool results are folded away like in the window).
pub fn message_json(m: &crate::model::Message, names: &HashMap<u64, String>, pages: &[crate::pages::Page]) -> Option<Value> {
use crate::model::{Role, ToolStatus};
if m.role == Role::Tool {
return None;
}
let calls: Vec<Value> = m
.tool_calls
.iter()
.map(|c| {
let st = match c.status {
ToolStatus::Waiting => "waiting",
ToolStatus::Running => "running",
ToolStatus::Done => "done",
ToolStatus::Denied => "denied",
ToolStatus::Failed => "failed",
};
let page = (c.name == "write_page").then(|| serde_json::from_str::<Value>(&c.args).ok().and_then(|a| a["id"].as_u64())).flatten().and_then(|id| pages.iter().find(|p| p.id == id));
if c.name == "show_card" {
json!({"card": serde_json::from_str::<Value>(&c.args).unwrap_or(Value::Null), "status": st})
} else if let Some(p) = page {
json!({"page": crate::pages::card_json(p, crate::model::now_ms()), "status": st})
} else {
json!({"label": tool_label(&c.name), "detail": tool_detail(&c.name, &c.args), "status": st})
}
})
.collect();
Some(json!({
"role": match m.role { Role::User => "user", _ => "assistant" },
"text": m.text,
"via": m.via,
"error": m.error,
"summary": m.summary,
"wake": m.wake,
"author": if m.author == 0 { String::new() } else { names.get(&m.author).cloned().unwrap_or_default() },
"reactions": m.reactions,
"attachments": m.attachments.iter().map(|a| json!({"name": a.name, "image": a.image})).collect::<Vec<_>>(),
"tools": calls,
}))
}
/// Newest messages kept in the phone's view of each chat.
pub const PHONE_TAIL: usize = 120;
// ---------- the API (both the local server and the relay call this) ----------
fn devices_ok(token: &str) -> Option<String> {
if token.is_empty() {
return None;
}
let h = httpd::sha256_hex(token);
hub().settings.lock().unwrap().devices.iter().find(|d| d.token_hash == h).map(|d| d.token_hash.clone())
}
/// `via_relay`: the relay already checked the account, so no phone password is needed.
pub fn handle(req: Req, via_relay: bool) -> Resp {
let parts = req.parts();
let path: Vec<&str> = parts.iter().map(|s| *s).collect();
match (req.method.as_str(), path.as_slice()) {
("GET", []) | ("GET", ["index.html"]) => return page(),
("GET", ["manifest.webmanifest"]) => return Resp::bytes("application/manifest+json", MANIFEST.as_bytes().to_vec()),
("GET", ["icon.svg"]) => return Resp::bytes("image/svg+xml", ICON.as_bytes().to_vec()),
("GET", ["api", "hello"]) => {
let s = hub().settings();
return Resp::json(&json!({"app": "anybot", "host": s.host_name, "relay": via_relay, "needs_password": !via_relay}));
}
("POST", ["api", "login"]) if !via_relay => return login(&req),
_ => {}
}
if !via_relay {
match devices_ok(&req.bearer()) {
Some(h) => hub().push(Cmd::DeviceSeen(h)),
None => return Resp::error(401, "請先登入"),
}
}
let s = hub().settings();
// Direct phones are the owner's. Through the relay, the relay says who is asking.
let role = if via_relay { Some(req.header("x-anybot-role")).filter(|r| !r.is_empty()).unwrap_or("owner").to_string() } else { "owner".to_string() };
if !role_allows(&role, &req.method, &path) {
return Resp::error(403, "你在這台 AnyBot 的角色不能做這件事(請擁有者處理)");
}
match (req.method.as_str(), path.as_slice()) {
("GET", ["api", "state"]) => {
let since: u64 = req.query.get("since").and_then(|v| v.parse().ok()).unwrap_or(0);
let v = hub().wait_newer(since, if since == 0 { 0 } else { LONG_POLL_SECS });
Resp::json(&json!({
"version": v.version, "host": v.host, "bots": v.bots, "approvals": v.approvals,
"box": crate::cloudbox::run_state().label(),
"box_available": crate::cloudbox::available(),
"mac": s.mac_from_phone && cfg!(target_os = "macos") && role != "member",
"role": role,
}))
}
// 頁面: the whole page (or an older version with ?rev=N), and its charts drawn as SVG.
("GET", ["api", "pages", id]) => {
let id: u64 = id.parse().unwrap_or(0);
let v = hub().view.lock().unwrap();
let Some(cur) = v.pages.iter().find(|p| p.id == id) else { return Resp::error(404, "這個頁面已經刪除了") };
let shown = req.query.get("rev").and_then(|r| r.parse().ok()).and_then(|r| crate::pages::version_of(cur, r)).unwrap_or_else(|| cur.clone());
let bot = v.bots.iter().find(|b| b["id"].as_u64() == Some(cur.bot)).and_then(|b| b["name"].as_str()).unwrap_or("").to_string();
let mut out = crate::pages::page_json(&shown, &bot, crate::model::now_ms());
out["versions"] = crate::pages::page_json(cur, &bot, 0)["versions"].clone();
out["latest"] = json!(cur.revision);
Resp::json(&out)
}
("GET", ["api", "pages", id, "chart", n]) => {
let id: u64 = id.parse().unwrap_or(0);
let n: usize = n.parse().unwrap_or(0);
let body = {
let v = hub().view.lock().unwrap();
let Some(cur) = v.pages.iter().find(|p| p.id == id) else { return Resp::error(404, "這個頁面已經刪除了") };
req.query.get("rev").and_then(|r| r.parse().ok()).and_then(|r| crate::pages::version_of(cur, r)).unwrap_or_else(|| cur.clone()).body
};
let Some(src) = crate::pages::mermaid_blocks(&body).into_iter().nth(n) else { return Resp::error(404, "找不到這張圖") };
match crate::diagram::svg(&src, req.query.get("dark").map(|d| d == "1").unwrap_or(false)) {
Ok(svg) => Resp::bytes("image/svg+xml", svg.into_bytes()),
Err(e) => Resp::error(422, &e),
}
}
("GET", ["api", "bots", id]) => {
let id: u64 = id.parse().unwrap_or(0);
let v = hub().view.lock().unwrap();
match v.messages.get(&id) {
Some(m) => Resp::json(&json!({"version": v.version, "messages": m})),
None => Resp::error(404, "找不到這個 Bot"),
}
}
("POST", ["api", "bots", id, action]) => {
let bot: u64 = id.parse().unwrap_or(0);
let b = req.json();
let cmd = match *action {
"send" => {
let text = b["text"].as_str().unwrap_or("").trim().to_string();
let attachments: Vec<String> = b["attachments"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();
if text.is_empty() && attachments.is_empty() {
return Resp::error(400, "訊息是空的");
}
// 團隊: the bot knows who is talking when it isn't the owner.
let who = req.header("x-anybot-user");
let text = if role != "owner" && !who.is_empty() { format!("【{who}】{text}") } else { text };
Cmd::Send { bot, text, attachments }
}
"stop" => Cmd::Stop { bot },
"answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() },
"read" => Cmd::Read { bot },
"new" => Cmd::NewThread { bot },
"upload" => return upload(bot, &req),
_ => return Resp::error(404, "沒有這個動作"),
};
hub().push(cmd);
Resp::json(&json!({"ok": true}))
}
("GET", ["api", "screen", which]) => screen(which, &s),
("POST", ["api", "screen", which]) => screen_input(which, &req.json(), &s),
("POST", ["api", "device", "run"]) => device_run(&req.json(), &s, via_relay, req.header("x-anybot-from")),
("GET", ["api", "sync"]) => {
let since: u64 = req.query.get("since").and_then(|v| v.parse().ok()).unwrap_or(0);
let client = req.query.get("client").cloned().unwrap_or_default();
if since > 0 {
let _ = hub().wait_newer(since, LONG_POLL_SECS);
}
match hub().ask_sync(SyncKind::Pull { client, since }) {
Ok(v) => Resp::json(&v),
Err(e) => Resp::error(503, &e),
}
}
("POST", ["api", "sync", "edit"]) => match hub().ask_sync(SyncKind::Edit(req.json())) {
Ok(v) if v["error"].is_string() => Resp::error(400, v["error"].as_str().unwrap_or("")),
Ok(v) => Resp::json(&v),
Err(e) => Resp::error(503, &e),
},
("POST", ["api", "sync", "upload", id]) => upload(id.parse().unwrap_or(0), &req),
("POST", ["api", "sync", "models"]) => match hub().ask_sync_for(SyncKind::Models(req.json()["provider"].as_str().unwrap_or("").to_string()), 45) {
Ok(v) => Resp::json(&v),
Err(e) => Resp::error(503, &e),
},
// A file shown in a chat (only what AnyBot keeps as attachments).
("GET", ["api", "sync", "file"]) => {
let want = std::path::PathBuf::from(req.query.get("path").cloned().unwrap_or_default());
let root = crate::model::State::path().parent().map(|p| p.join("attachments")).and_then(|p| p.canonicalize().ok());
match (want.canonicalize().ok(), root) {
(Some(f), Some(root)) if f.starts_with(&root) && f.is_file() => match std::fs::read(&f) {
Ok(b) => Resp::bytes("application/octet-stream", b),
Err(_) => Resp::error(404, "大腦上找不到這個檔案"),
},
_ => Resp::error(404, "大腦上找不到這個檔案"),
}
}
("POST", ["api", "sync", "act"]) => {
let b = req.json();
let bot = b["bot"].as_u64().unwrap_or(0);
let cmd = match b["action"].as_str().unwrap_or("") {
"send" => Cmd::Send {
bot,
text: b["text"].as_str().unwrap_or("").trim().to_string(),
attachments: b["attachments"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(),
},
"stop" => Cmd::Stop { bot },
"answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() },
"read" => Cmd::Read { bot },
"new" => Cmd::NewThread { bot },
"regenerate" => Cmd::Regenerate { bot },
"reply" => Cmd::Reply { bot },
"restore" => Cmd::RestoreThread { bot, index: b["index"].as_u64().unwrap_or(0) as usize },
_ => return Resp::error(404, "沒有這個動作"),
};
hub().push(cmd);
Resp::json(&json!({"ok": true}))
}
("POST", ["api", "logout"]) if !via_relay => {
let h = httpd::sha256_hex(&req.bearer());
hub().push(Cmd::DeviceSeen(format!("-{h}")));
Resp::json(&json!({"ok": true}))
}
_ => Resp::error(404, "沒有這個網址"),
}
}
/// 團隊角色: what each role may do. "member" chats and attaches files; "admin" also approves,
/// stops, starts new chats and uses the screens; only "device" (another AnyBot of the owner's
/// account) may run tools here.
pub fn role_allows(role: &str, method: &str, path: &[&str]) -> bool {
let device = matches!(path, ["api", "device", "run"]);
let sync = matches!(path, ["api", "sync", ..]);
match role {
"device" => device,
// 大腦: the owner's other AnyBots showing this one's bots.
"mirror" => sync,
"owner" | "admin" => !device && !sync,
"member" => matches!((method, path), ("GET", ["api", "state"]) | ("GET", ["api", "bots", _]) | ("GET", ["api", "pages", ..]) | ("POST", ["api", "bots", _, "send" | "read" | "upload"])),
_ => false,
}
}
fn login(req: &Req) -> Resp {
{
let mut f = hub().fails.lock().unwrap();
if let (n, Some(t)) = *f {
if n >= MAX_FAILS && t.elapsed() < Duration::from_secs(60) {
return Resp::error(429, "密碼錯太多次,請一分鐘後再試");
}
if t.elapsed() >= Duration::from_secs(60) {
*f = (0, None);
}
}
}
let b = req.json();
let pw = b["password"].as_str().unwrap_or("");
let stored = hub().settings().password;
if stored.is_empty() || !httpd::check_password(pw, &stored) {
let mut f = hub().fails.lock().unwrap();
*f = (f.0 + 1, Some(Instant::now()));
// Slow guessing down a little more.
std::thread::sleep(Duration::from_millis(600));
return Resp::error(401, if stored.is_empty() { "這台 AnyBot 還沒設定手機密碼" } else { "密碼不對" });
}
*hub().fails.lock().unwrap() = (0, None);
let token = httpd::random_hex(32);
let name = b["device"].as_str().filter(|d| !d.trim().is_empty()).unwrap_or("手機").chars().take(40).collect::<String>();
let d = Device { name, token_hash: httpd::sha256_hex(&token), created: chrono::Utc::now().timestamp(), last_seen: chrono::Utc::now().timestamp() };
// Known at once (the app saves it a moment later).
hub().settings.lock().unwrap().devices.push(d.clone());
hub().push(Cmd::DeviceAdded(d));
Resp::json(&json!({"token": token}))
}
fn upload(bot: u64, req: &Req) -> Resp {
let name = req.query.get("name").cloned().unwrap_or_else(|| "手機上傳".into());
let name: String = name.chars().filter(|c| !matches!(c, '/' | '\\' | '\0')).take(80).collect();
if req.body.is_empty() {
return Resp::error(400, "檔案是空的");
}
let dir = std::env::temp_dir().join(format!("anybot-phone-{}", httpd::random_hex(6)));
let _ = std::fs::create_dir_all(&dir);
let tmp = dir.join(if name.is_empty() { "upload".to_string() } else { name });
if let Err(e) = std::fs::write(&tmp, &req.body) {
return Resp::error(500, &e.to_string());
}
let r = crate::attach::store(bot, &tmp);
let _ = std::fs::remove_dir_all(&dir);
match r {
Ok(a) => Resp::json(&json!({"path": a.path, "name": a.name, "image": a.image})),
Err(e) => Resp::error(400, &e),
}
}
/// A frame of the bot's computer ("box") or this Mac ("mac") as JPEG.
fn screen(which: &str, s: &Settings) -> Resp {
match which {
"box" => {
crate::cloudbox::touch();
match crate::cloudbox::frame() {
Some(img) => {
let rgb = image::DynamicImage::ImageRgba8(img).to_rgb8();
match crate::computer::encode_jpeg(&rgb) {
Some(j) => Resp::bytes("image/jpeg", j).with_header("X-Size", &format!("{}x{}", rgb.width(), rgb.height())),
None => Resp::error(500, "畫面編碼失敗"),
}
}
None => Resp::error(503, "Bot 的電腦在休息,按「叫醒」"),
}
}
"mac" if s.mac_from_phone => {
let (text, ok, img) = crate::computer::run_by("computer_use_screenshot", "{}", "手機");
match (ok, img) {
(true, Some(b)) => {
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD.decode(b).unwrap_or_default();
let size = image::load_from_memory(&bytes).map(|i| format!("{}x{}", i.width(), i.height())).unwrap_or_default();
Resp::bytes("image/jpeg", bytes).with_header("X-Size", &size)
}
_ => Resp::error(503, &text),
}
}
"mac" => Resp::error(403, "這台 Mac 沒有開放從手機操作(AnyBot 設定 › 手機與帳號)"),
_ => Resp::error(404, "沒有這個畫面"),
}
}
/// The phone taps, types or scrolls on a screen (coordinates in the frame's pixels).
fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp {
let kind = v["kind"].as_str().unwrap_or("");
if which == "box" && kind == "wake" {
return match crate::cloudbox::ensure_running() {
Ok(()) => Resp::json(&json!({"ok": true})),
Err(e) => Resp::error(500, &e),
};
}
let (name, args) = match kind {
"click" => ("computer_use_click", json!({"x": v["x"], "y": v["y"], "button": v["button"].as_str().unwrap_or("left"), "count": v["count"].as_u64().unwrap_or(1)})),
"type" => ("computer_use_typing", json!({"text": v["text"]})),
"key" => ("computer_use_press_key", json!({"key": v["key"]})),
"scroll" => ("computer_use_scroll", json!({"x": v["x"], "y": v["y"], "direction": v["direction"].as_str().unwrap_or("down"), "amount": v["amount"].as_i64().unwrap_or(3)})),
_ => return Resp::error(400, "不認得的操作"),
};
match which {
"box" => {
crate::cloudbox::touch();
match crate::cloudbox::computer_run(name, &args) {
Ok(_) => Resp::json(&json!({"ok": true})),
Err(e) => Resp::error(500, &e),
}
}
"mac" if s.mac_from_phone => {
let (t, ok, _) = crate::computer::run_by(name, &args.to_string(), "手機");
if ok {
Resp::json(&json!({"ok": true}))
} else {
Resp::error(500, &t)
}
}
_ => Resp::error(403, "這台 Mac 沒有開放從手機操作"),
}
}
/// 裝置連結: another AnyBot in this account (e.g. the one on the server) runs a screen tool on
/// this Mac. Only through the relay, and only when this Mac allows it.
fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp {
if !via_relay || !s.device_link {
return Resp::error(403, "這台電腦沒有開放給帳號裡的其他 AnyBot 操作");
}
if !s.hands_for.is_empty() && from != s.hands_for {
return Resp::error(403, &format!("這台電腦只讓大腦「{}」使用", s.hands_for_name));
}
let tool = v["tool"].as_str().unwrap_or("");
let args = v["args"].as_str().unwrap_or("{}");
let who = v["who"].as_str().unwrap_or("遠端 AnyBot");
if crate::computer::is_computer_tool(tool) {
let (text, ok, img) = crate::computer::run_by(tool, args, who);
return Resp::json(&json!({"text": text, "ok": ok, "image": img}));
}
// The AnyBot browser here (its logins are this computer's).
if crate::web::is_web_tool(tool) && tool != "web_fetch" {
let mut v: Value = serde_json::from_str(args).unwrap_or(json!({}));
if let Some(o) = v.as_object_mut() {
o.remove("computer");
}
let (text, ok, img) = crate::web::run_full(tool, &v.to_string(), &std::sync::atomic::AtomicBool::new(false));
return Resp::json(&json!({"text": text, "ok": ok, "image": img}));
}
if crate::macapps::is_mac_tool(tool) && cfg!(target_os = "macos") {
let (text, ok) = crate::macapps::run(tool, args);
return Resp::json(&json!({"text": text, "ok": ok}));
}
if matches!(tool, "shell" | "read_file" | "edit_file" | "list_dir" | "search" | "read_terminal" | "write_terminal") {
let t = crate::tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: crate::tools::Place::Local { workdir: crate::tools::expand_home(v["workdir"].as_str().unwrap_or("~")) } };
let ids = crate::tools::RunIds { conversation: "device".into(), request: httpd::random_hex(6) };
let (text, ok) = crate::tools::run_on(tool, args, &t, &std::sync::atomic::AtomicBool::new(false), &ids);
return Resp::json(&json!({"text": text, "ok": ok}));
}
Resp::error(400, "這個工具不能遠端使用")
}
// ---------- the local server ----------
static LAN_GEN: AtomicU64 = AtomicU64::new(0);
static LAN_BOUND: Mutex<Option<u16>> = Mutex::new(None);
pub fn lan_port() -> Option<u16> {
*LAN_BOUND.lock().unwrap()
}
/// Start (or stop) answering phones on the local network.
pub fn set_lan(on: bool) -> Result<u16, String> {
let gen = LAN_GEN.fetch_add(1, Ordering::SeqCst) + 1;
*LAN_BOUND.lock().unwrap() = None;
if !on {
return Ok(0);
}
let port_env: Option<u16> = std::env::var("ANYBOT_PHONE_PORT").ok().and_then(|p| p.parse().ok());
let start = port_env.unwrap_or(LAN_PORT);
// The old listener notices the new generation within 50 ms; give it the port back.
let listener = (0..40)
.find_map(|_| {
let l = (start..start + 10).find_map(|p| std::net::TcpListener::bind(("0.0.0.0", p)).ok());
if l.is_none() {
std::thread::sleep(Duration::from_millis(60));
}
l
})
.ok_or("找不到可用的連接埠")?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
*LAN_BOUND.lock().unwrap() = Some(port);
std::thread::spawn(move || httpd::serve(listener, Arc::new(|r| handle(r, false)), Arc::new(move || LAN_GEN.load(Ordering::SeqCst) == gen)));
Ok(port)
}
/// Addresses a phone can open (local network and Tailscale).
pub fn addresses(port: u16) -> Vec<String> {
// macOS: ifconfig ("inet 192.168.1.5 netmask…"); Linux: `ip -4 -o addr` ("… inet 192.168.1.5/24 …").
let mut out = std::process::Command::new("ifconfig").output().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default();
if out.trim().is_empty() {
out = std::process::Command::new("ip").args(["-4", "-o", "addr"]).output().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default();
}
let mut v: Vec<String> = out
.lines()
.filter_map(|l| l.split("inet ").nth(1))
.filter_map(|l| l.split_whitespace().next())
.map(|ip| ip.split('/').next().unwrap_or(ip))
.filter(|ip| !ip.starts_with("127.") && !ip.starts_with("169.254."))
.map(|ip| format!("http://{ip}:{port}"))
.collect();
v.dedup();
v
}
// ---------- the relay connection (this AnyBot as a "host") ----------
static RELAY_GEN: AtomicU64 = AtomicU64::new(0);
/// One agent per timeout, kept: its connections stay open between requests (no new TCP and TLS
/// handshake each time).
fn agent(secs: u64) -> ureq::Agent {
static AGENTS: OnceLock<Mutex<HashMap<u64, ureq::Agent>>> = OnceLock::new();
AGENTS
.get_or_init(Mutex::default)
.lock()
.unwrap()
.entry(secs)
.or_insert_with(|| ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(secs))).http_status_as_error(false).build().into())
.clone()
}
/// Why a request never got an answer, in plain words (never an error code).
pub fn why_unreachable(e: &ureq::Error) -> String {
use std::io::ErrorKind as K;
use ureq::Error as E;
let text = e.to_string().to_lowercase();
// A TLS failure often comes wrapped as an I/O error: look at the words first.
if text.contains("certificate") || text.contains("tls") || text.contains("handshake") {
return "和伺服器的安全連線失敗:對方的憑證不對(網址可能連到了別的服務,或憑證過期)".to_string();
}
let w = match e {
E::HostNotFound => "找不到伺服器:這台電腦可能沒連上網路,或伺服器網址打錯了",
E::ConnectionFailed => "伺服器沒有回應:可能沒開機,或網路把連線擋住了",
E::Timeout(_) => "等太久沒有回應:網路很慢或斷斷續續",
E::Io(io) => match io.kind() {
K::ConnectionRefused => "伺服器沒有開(連線被拒絕)",
K::ConnectionReset | K::ConnectionAborted | K::BrokenPipe | K::UnexpectedEof => "連線到一半斷掉了",
K::TimedOut => "等太久沒有回應:網路很慢或斷斷續續",
K::NetworkUnreachable | K::HostUnreachable | K::NetworkDown | K::AddrNotAvailable => "這台電腦沒有連上網路",
_ => "網路連線出了問題",
},
_ if text.contains("dns") || text.contains("resolve") => "找不到伺服器:這台電腦可能沒連上網路,或伺服器網址打錯了",
_ => "網路連線出了問題",
};
w.to_string()
}
/// An answer that wasn't a yes, in plain words. `whom` names the other side (大腦, 那台電腦…).
pub fn why_refused(st: u16, v: &Value, whom: &str) -> String {
if let Some(e) = v["error"].as_str().filter(|e| !e.is_empty()) {
return e.replace("那台 AnyBot ", whom).replace("那台 AnyBot", whom);
}
match st {
401 | 403 => "這台電腦的帳號登入失效了,請到「設定 › 手機與帳號」重新登入".into(),
404 => format!("{whom}不在這個帳號裡了(可能被移除,或兩邊 AnyBot 版本不同)"),
413 => "檔案太大,帳號伺服器不收".into(),
502 | 503 => "帳號伺服器暫時無法使用(可能正在重新啟動),稍後會自動再試".into(),
504 => format!("{whom}沒有及時回應(可能太忙或網路很慢)"),
500..=599 => "帳號伺服器出了問題,稍後會自動再試".into(),
_ => format!("{whom}的回應看不懂,請把兩邊的 AnyBot 都更新到最新版"),
}
}
pub fn relay_post(url: &str, bearer: &str, body: &Value) -> Result<Value, String> {
let mut r = agent(30).post(url).header("Content-Type", "application/json");
if !bearer.is_empty() {
r = r.header("Authorization", &format!("Bearer {bearer}"));
}
let mut resp = r.send(body.to_string().as_bytes()).map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?;
let st = resp.status().as_u16();
let text = resp.body_mut().read_to_string().unwrap_or_default();
let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
if !(200..300).contains(&st) {
return Err(why_refused(st, &v, "帳號伺服器"));
}
Ok(v)
}
fn base(url: &str) -> String {
url.trim().trim_end_matches('/').to_string()
}
/// Sign in (or create the account) at the relay and register this AnyBot as one of its hosts.
/// Returns the settings to keep.
pub fn relay_join(base_settings: &Settings, url: &str, user: &str, password: &str, create: bool, invite: &str) -> Result<Settings, String> {
let host_name = base_settings.host_name.as_str();
let url = base(url);
// Plain http only inside one machine or one Docker network (e.g. http://relay:8080).
let host = url.strip_prefix("http://").map(|r| r.split(['/', ':']).next().unwrap_or("")).unwrap_or("");
// Tailscale (100.64.0.0/10 and *.ts.net) is already encrypted end to end.
let tailscale = host.ends_with(".ts.net") || host.split('.').collect::<Vec<_>>().as_slice().first().map(|a| *a == "100").unwrap_or(false) && host.split('.').nth(1).and_then(|b| b.parse::<u8>().ok()).map(|b| (64..128).contains(&b)).unwrap_or(false);
let internal = host == "127.0.0.1" || host == "localhost" || tailscale || (!host.is_empty() && !host.contains('.'));
if !url.starts_with("https://") && !internal {
return Err("帳號伺服器要用 https:// 開頭的網址(這台電腦或 Tailscale 位址可以用 http://)".into());
}
if create {
if let Err(e) = relay_post(&format!("{url}/relay/signup"), "", &json!({"user": user, "password": password, "invite": invite})) {
// The account may be there already (made earlier, or on the phone): just sign in with it.
if relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": host_name})).is_err() {
return Err(if e.contains("不開放註冊") || e.contains("邀請碼") {
"這台帳號伺服器已經有帳號了,不能再註冊新的。請取消勾選「建立新帳號」,用已經有的帳號登入。忘記密碼的話,在帳號伺服器那台電腦執行:anybot-relay --data 資料夾 --reset-password 帳號".into()
} else {
e
});
}
}
}
let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": host_name}))?;
let session = v["token"].as_str().ok_or("帳號伺服器沒有給登入憑證")?.to_string();
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.
let _ = relay_post(&format!("{url}/relay/logout"), &session, &json!({}));
let mut s = base_settings.clone();
s.relay_url = url;
s.relay_user = user.to_string();
s.host_id = h["id"].as_str().unwrap_or("").to_string();
s.host_token = h["token"].as_str().unwrap_or("").to_string();
s.backup_key = httpd::derive_key(password, user).iter().map(|b| format!("{b:02x}")).collect();
if s.host_id.is_empty() || s.host_token.is_empty() {
return Err("帳號伺服器沒有給這台電腦的金鑰".into());
}
Ok(s)
}
/// Keep the outgoing connection to the relay: wait for the phone's requests and answer them.
pub fn relay_restart(s: &Settings) {
let gen = RELAY_GEN.fetch_add(1, Ordering::SeqCst) + 1;
if s.relay_url.is_empty() || s.host_token.is_empty() {
hub().set_relay_status("");
return;
}
let (url, token) = (s.relay_url.clone(), s.host_token.clone());
hub().set_relay_status("連線中…");
std::thread::spawn(move || {
// 長連線 first; an older account server without it gets asked over and over instead.
if crate::tunnel::run(&url, &token, &|| alive(gen), &|m| hub().set_relay_status(m)) || !alive(gen) {
return;
}
// A few workers, so a slow request (a long-poll) doesn't block the others.
for _ in 0..4 {
let (url, token) = (url.clone(), token.clone());
std::thread::spawn(move || relay_worker(&url, &token, gen));
}
});
}
fn alive(gen: u64) -> bool {
RELAY_GEN.load(Ordering::SeqCst) == gen
}
fn relay_worker(url: &str, token: &str, gen: u64) {
let mut backoff = 1u64;
while alive(gen) {
let r = agent(40).get(&format!("{url}/relay/host/next")).header("Authorization", &format!("Bearer {token}")).call();
let mut resp = match r {
Ok(r) => r,
Err(e) => {
hub().set_relay_status(&format!("連不上帳號伺服器:{}", why_unreachable(&e)));
std::thread::sleep(Duration::from_secs(backoff));
backoff = (backoff * 2).min(30);
continue;
}
};
let st = resp.status().as_u16();
if st == 401 {
hub().set_relay_status("這台電腦的金鑰失效了,請重新登入帳號");
return;
}
if st == 204 {
hub().set_relay_status("已連線");
backoff = 1;
continue;
}
let text = resp.body_mut().read_to_string().unwrap_or_default();
let Ok(job) = serde_json::from_str::<Value>(&text) else {
hub().set_relay_status(&format!("連不上帳號伺服器:{}", why_refused(st, &Value::Null, "帳號伺服器")));
std::thread::sleep(Duration::from_secs(backoff));
backoff = (backoff * 2).min(30);
continue;
};
hub().set_relay_status("已連線");
backoff = 1;
let id = job["id"].as_str().unwrap_or("").to_string();
let req = req_from_json(&job);
// Answer on a separate thread so this worker can pick up the next request at once.
let (url, token) = (url.to_string(), token.to_string());
std::thread::spawn(move || {
let resp = handle(req, true);
let _ = agent(60).post(&format!("{url}/relay/host/reply/{id}")).header("Authorization", &format!("Bearer {token}")).header("Content-Type", "application/json").send(resp_to_json(&resp).to_string().as_bytes());
});
}
}
pub fn req_from_json(job: &Value) -> Req {
use base64::Engine;
Req {
method: job["method"].as_str().unwrap_or("GET").to_string(),
path: job["path"].as_str().unwrap_or("/").to_string(),
query: job["query"].as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default(),
// Who asked, as the relay vouches for it (團隊: owner / admin / member; "device" for 裝置連結).
headers: [("x-anybot-user", job["user"].as_str().unwrap_or("")), ("x-anybot-role", job["role"].as_str().unwrap_or("owner")), ("x-anybot-from", job["from"].as_str().unwrap_or(""))].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
body: {
let b = base64::engine::general_purpose::STANDARD.decode(job["body"].as_str().unwrap_or("")).unwrap_or_default();
if job["z"].as_bool().unwrap_or(false) {
crate::tunnel::unzip(&b).unwrap_or_default()
} else {
b
}
},
peer: "relay".into(),
}
}
pub fn resp_to_json(r: &Resp) -> Value {
use base64::Engine;
json!({"status": r.status, "ctype": r.ctype, "body": base64::engine::general_purpose::STANDARD.encode(&r.body), "headers": r.headers})
}
/// Upload an encrypted copy of AnyBot's data to the relay.
pub fn backup_now(s: &Settings, state_json: &[u8]) -> Result<usize, String> {
let key = key_of(&s.backup_key).ok_or("還沒登入帳號")?;
let sealed = httpd::seal(&key, state_json);
let mut resp = agent(120)
.put(&format!("{}/relay/backup", s.relay_url))
.header("Authorization", &format!("Bearer {}", s.host_token))
.header("Content-Type", "application/octet-stream")
.send(&sealed[..])
.map_err(|e| format!("備份上傳失敗:{e}"))?;
let st = resp.status().as_u16();
if !(200..300).contains(&st) {
return Err(format!("備份上傳失敗:{}", resp.body_mut().read_to_string().unwrap_or_default()));
}
Ok(sealed.len())
}
/// 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> {
let url = base(url);
let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": "還原備份"}))?;
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 _ = relay_post(&format!("{url}/relay/logout"), &session, &json!({}));
if resp.status().as_u16() == 404 {
return Err("這個帳號還沒有備份".into());
}
let sealed = resp.body_mut().with_config().limit(200 * 1024 * 1024).read_to_vec().map_err(|e| e.to_string())?;
httpd::open(&httpd::derive_key(password, user), &sealed).ok_or_else(|| "備份打不開(密碼不對,或備份壞了)".to_string())
}
fn key_of(hex: &str) -> Option<[u8; 32]> {
if hex.len() != 64 {
return None;
}
let mut k = [0u8; 32];
for i in 0..32 {
k[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
}
Some(k)
}
/// 裝置連結: run a tool on another AnyBot of this account (through the relay).
pub fn run_on_device(host_id: &str, tool: &str, args: &str, who: &str) -> Result<Value, String> {
let s = hub().settings();
if s.relay_url.is_empty() {
return Err("沒有登入帳號,連不到其他裝置".into());
}
let body = json!({"tool": tool, "args": args, "who": who}).to_string();
if let Some(r) = crate::tunnel::call(host_id, "POST", "/api/device/run", &[], body.as_bytes(), 50) {
return r.map_err(|e| format!("連不到那台電腦:{e}")).and_then(|a| answer_value(a.status, &a.body, "那台電腦"));
}
let url = format!("{}/relay/hosts/{host_id}/api/device/run", s.relay_url);
let mut resp = agent(45)
.post(&url)
.header("Authorization", &format!("Bearer {}", s.host_token))
.header("Content-Type", "application/json")
.send(json!({"tool": tool, "args": args, "who": who}).to_string().as_bytes())
.map_err(|e| format!("連不到帳號伺服器,所以也連不到那台電腦:{}", why_unreachable(&e)))?;
let st = resp.status().as_u16();
let text = resp.body_mut().with_config().limit(40 * 1024 * 1024).read_to_string().unwrap_or_default();
let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
if !(200..300).contains(&st) {
return Err(why_refused(st, &v, "那台電腦"));
}
Ok(v)
}
// ---------- 大腦: this AnyBot as a screen of another ----------
fn brain_url(s: &Settings, rest: &str) -> Result<String, String> {
if s.relay_url.is_empty() || s.host_token.is_empty() {
return Err("沒有登入帳號".into());
}
if s.brain_id.is_empty() {
return Err("沒有選大腦".into());
}
Ok(format!("{}/relay/hosts/{}/api/sync{rest}", s.relay_url, s.brain_id))
}
/// A JSON answer, or what went wrong in words.
fn answer_value(st: u16, bytes: &[u8], whom: &str) -> Result<Value, String> {
let v: Value = serde_json::from_slice(bytes).unwrap_or(Value::Null);
if !(200..300).contains(&st) {
return Err(why_refused(st, &v, whom));
}
if v.is_null() {
return Err(format!("{whom}的回應看不懂,請把兩邊的 AnyBot 都更新到最新版"));
}
Ok(v)
}
/// Ask the brain: over the lasting connection when it is open, else an ordinary request.
fn to_brain(s: &Settings, method: &str, rest: &str, query: &[(&str, String)], body: &[u8], secs: u64) -> Result<Value, String> {
let (st, bytes) = to_brain_raw(s, method, rest, query, body, secs)?;
answer_value(st, &bytes, "大腦")
}
fn to_brain_raw(s: &Settings, method: &str, rest: &str, query: &[(&str, String)], body: &[u8], secs: u64) -> Result<(u16, Vec<u8>), String> {
if s.brain_id.is_empty() {
return Err("沒有選大腦".into());
}
if let Some(r) = crate::tunnel::call(&s.brain_id, method, &format!("/api/sync{rest}"), query, body, secs) {
return r.map(|a| (a.status, a.body));
}
let q: Vec<String> = query.iter().map(|(k, v)| format!("{k}={}", url_escape(v))).collect();
let url = brain_url(s, &format!("{rest}{}{}", if q.is_empty() { "" } else { "?" }, q.join("&")))?;
let auth = format!("Bearer {}", s.host_token);
let r = if method == "GET" { agent(secs).get(&url).header("Authorization", &auth).call() } else { agent(secs).post(&url).header("Authorization", &auth).header("Content-Type", "application/octet-stream").send(body) };
let mut resp = r.map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?;
let st = resp.status().as_u16();
let bytes = resp.body_mut().with_config().limit(200 * 1024 * 1024).read_to_vec().map_err(|_| "和大腦的連線到一半斷掉了".to_string())?;
Ok((st, bytes))
}
/// What changed at the brain since `since` (waits up to LONG_POLL_SECS when nothing has).
pub fn sync_pull(s: &Settings, since: u64, client: &str) -> Result<Value, String> {
to_brain(s, "GET", "", &[("since", since.to_string()), ("client", client.to_string())], &[], LONG_POLL_SECS + 25)
}
/// Send edits ("edit") or a chat action ("act") to the brain.
pub fn sync_post(s: &Settings, what: &str, body: &Value) -> Result<Value, String> {
to_brain(s, "POST", &format!("/{what}"), &[], body.to_string().as_bytes(), 60)
}
/// A file for the next message, kept at the brain; gives back its path there.
pub fn sync_upload(s: &Settings, bot: u64, name: &str, bytes: &[u8]) -> Result<String, String> {
let v = to_brain(s, "POST", &format!("/upload/{bot}"), &[("name", name.to_string())], bytes, 120)?;
v["path"].as_str().map(String::from).ok_or("大腦沒有收下檔案".into())
}
/// A file the brain keeps (an attachment shown in a chat), as bytes.
pub fn sync_file(s: &Settings, path: &str) -> Result<Vec<u8>, String> {
let (st, bytes) = to_brain_raw(s, "GET", "/file", &[("path", path.to_string())], &[], 120)?;
if !(200..300).contains(&st) {
return Err(why_refused(st, &serde_json::from_slice(&bytes).unwrap_or(Value::Null), "大腦"));
}
Ok(bytes)
}
fn url_escape(s: &str) -> String {
s.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
_ => format!("%{b:02X}"),
})
.collect()
}
/// A notification on the person's phones (through the relay; phones that turned them on).
pub fn notify(title: &str, body: &str, bot: u64) {
let s = hub().settings();
if s.relay_url.is_empty() || s.host_token.is_empty() {
return;
}
let (title, body) = (title.to_string(), body.to_string());
std::thread::spawn(move || {
let _ = relay_post(&format!("{}/relay/host/notify", s.relay_url), &s.host_token, &json!({"title": title, "body": body, "bot": bot}));
});
}
/// 發布網站: upload a folder (or one page) to the account server; returns the public address.
pub fn publish_site(name: &str, local: &std::path::Path) -> Result<String, String> {
use base64::Engine;
let s = hub().settings();
if s.relay_url.is_empty() || s.host_token.is_empty() {
return Err("要先在 設定 › 手機與帳號 登入帳號,才能發布網站".into());
}
let mut files = vec![];
let mut total = 0u64;
if local.is_file() {
let bytes = std::fs::read(local).map_err(|e| e.to_string())?;
let is_html = local.extension().map(|e| e == "html" || e == "htm").unwrap_or(false);
let n = if is_html { "index.html".to_string() } else { local.file_name().unwrap_or_default().to_string_lossy().to_string() };
if bytes.len() > 25 * 1024 * 1024 {
return Err("網頁太大了(最多 25 MB)".into());
}
files.push(json!({"path": n, "data": base64::engine::general_purpose::STANDARD.encode(bytes)}));
} else {
let mut stack = vec![local.to_path_buf()];
while let Some(d) = stack.pop() {
for e in std::fs::read_dir(&d).map_err(|e| format!("讀不到資料夾:{e}"))?.flatten() {
let p = e.path();
let fname = e.file_name().to_string_lossy().to_string();
if fname.starts_with('.') || fname == "node_modules" {
continue;
}
if p.is_dir() {
stack.push(p);
} else if let Ok(bytes) = std::fs::read(&p) {
total += bytes.len() as u64;
if files.len() >= 500 || total > 25 * 1024 * 1024 {
return Err("網站太大了(最多 500 個檔案、25 MB)".into());
}
let rel = p.strip_prefix(local).unwrap_or(&p).to_string_lossy().replace('\\', "/");
files.push(json!({"path": rel, "data": base64::engine::general_purpose::STANDARD.encode(bytes)}));
}
}
}
}
if !files.iter().any(|f| f["path"] == "index.html") {
return Err("找不到 index.html(網站的首頁)".into());
}
let v = relay_post(&format!("{}/relay/host/sites/{name}", s.relay_url), &s.host_token, &json!({"files": files}))?;
let url = v["url"].as_str().filter(|u| !u.is_empty()).map(String::from).unwrap_or_else(|| format!("{}{}", s.relay_url, v["path"].as_str().unwrap_or("")));
Ok(url)
}
/// 團隊: who else this AnyBot is shared with (user, role).
pub fn members(s: &Settings) -> Result<Vec<(String, String)>, String> {
let mut r = agent(10).get(&format!("{}/relay/host/members", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call().map_err(|e| e.to_string())?;
let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null);
Ok(v["members"].as_array().map(|a| a.iter().map(|m| (m["user"].as_str().unwrap_or("").to_string(), m["role"].as_str().unwrap_or("").to_string())).collect()).unwrap_or_default())
}
/// Share with another account ("member" / "admin"), or stop sharing ("remove").
pub fn set_member(s: &Settings, user: &str, role: &str) -> Result<(), String> {
relay_post(&format!("{}/relay/host/members", s.relay_url), &s.host_token, &json!({"user": user, "role": role})).map(|_| ())
}
/// The other AnyBots signed in to this account (for 裝置連結).
/// 加一台電腦: a code (ten minutes, once) another computer uses to join this account, as hands for
/// `for_host` (empty = any AnyBot of the account).
pub fn pair_code(s: &Settings, for_host: &str) -> Result<String, String> {
let v = relay_post(&format!("{}/relay/pair", s.relay_url), &s.host_token, &json!({"for": for_host}))?;
v["code"].as_str().map(String::from).ok_or("帳號伺服器沒有給配對碼".into())
}
/// Join the account with a 配對碼 (no password). Gives the settings to keep.
pub fn pair_claim(base_settings: &Settings, url: &str, code: &str) -> Result<Settings, String> {
let url = check_server(url)?;
let v = relay_post(&format!("{url}/relay/pair/claim"), "", &json!({"code": code, "name": base_settings.host_name}))?;
let mut s = base_settings.clone();
s.relay_url = url;
s.relay_user = v["user"].as_str().unwrap_or("").to_string();
s.host_id = v["id"].as_str().unwrap_or("").to_string();
s.host_token = v["token"].as_str().unwrap_or("").to_string();
// No password here, so no key for backups (hands keep nothing to back up).
s.backup_key.clear();
s.hands_for = v["for"].as_str().unwrap_or("").to_string();
s.hands_for_name = v["for_name"].as_str().unwrap_or("").to_string();
if s.host_id.is_empty() || s.host_token.is_empty() {
return Err("帳號伺服器沒有給這台電腦的金鑰".into());
}
Ok(s)
}
/// The account's AnyBots as the account server sees them (for choosing a brain).
pub fn peers(s: &Settings) -> Result<Vec<Value>, String> {
let mut r = agent(15).get(&format!("{}/relay/host/peers", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call().map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?;
let st = r.status().as_u16();
let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null);
if !(200..300).contains(&st) {
return Err(why_refused(st, &v, "帳號伺服器"));
}
Ok(v["hosts"].as_array().cloned().unwrap_or_default())
}
/// Right after signing in: where this account's bots already are. Another AnyBot of the account
/// that is on now and thinks for itself (not hands, not showing another) is the brain to sync
/// with; with none, the account's newest backup (if there is one) is what to take in.
pub struct AccountFind {
pub brain: Option<(String, String)>,
pub backup: Option<Vec<u8>>,
}
pub fn find_account_bots(s: &Settings, password: &str) -> AccountFind {
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 };
AccountFind { brain, backup }
}
/// The brain among the account's AnyBots (relay `hosts` rows), never this one.
pub fn pick_brain(hosts: &[Value], me: &str) -> Option<(String, String)> {
hosts
.iter()
.filter(|h| h["online"].as_bool() == Some(true) && h["hands"].as_bool() != Some(true) && h["uses"].as_str().unwrap_or("").is_empty())
.filter_map(|h| Some((h["id"].as_str()?.to_string(), h["name"].as_str().unwrap_or("AnyBot").to_string())))
.find(|(id, _)| !id.is_empty() && id != me)
}
/// Is this an AnyBot account server? (Gives the address to use.)
pub fn check_server(url: &str) -> Result<String, String> {
let mut u = url.trim().trim_end_matches('/').to_string();
if u.is_empty() {
return Err("請輸入帳號伺服器的網址".into());
}
if !u.contains("://") {
let host = u.split(['/', ':']).next().unwrap_or("");
let plain = host == "localhost" || host.parse::<std::net::IpAddr>().is_ok() || !host.contains('.');
u = format!("{}{u}", if plain { "http://" } else { "https://" });
}
let mut r = agent(15).get(&format!("{u}/api/hello")).call().map_err(|e| format!("連不到 {u}:{}", why_unreachable(&e)))?;
let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null);
if v["app"] != "anybot-relay" {
return Err(format!("{u} 有回應,但不是 AnyBot 帳號伺服器(網址打錯了?)"));
}
Ok(u)
}
/// AnyBots of the account that lend their hands (not offered as a brain).
pub fn is_hands(id: &str) -> bool {
hands_ids().lock().unwrap().contains(id)
}
fn hands_ids() -> &'static Mutex<std::collections::HashSet<String>> {
static H: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
H.get_or_init(Mutex::default)
}
pub fn devices_online(s: &Settings) -> Vec<(String, String, bool)> {
if s.relay_url.is_empty() {
return vec![];
}
let Ok(mut r) = agent(10).get(&format!("{}/relay/host/peers", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call() else { return vec![] };
let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null);
*hands_ids().lock().unwrap() = v["hosts"].as_array().into_iter().flatten().filter(|h| h["hands"].as_bool() == Some(true) || !h["uses"].as_str().unwrap_or("").is_empty()).filter_map(|h| h["id"].as_str().map(String::from)).collect();
v["hosts"].as_array().map(|a| a.iter().map(|h| (h["id"].as_str().unwrap_or("").to_string(), h["name"].as_str().unwrap_or("").to_string(), h["online"].as_bool().unwrap_or(false))).collect()).unwrap_or_default()
}
// ---------- the phone page ----------
const PAGE: &str = include_str!("../mobile/index.html");
pub const MANIFEST: &str = r##"{"name":"AnyBot","short_name":"AnyBot","start_url":"./","display":"standalone","background_color":"#121212","theme_color":"#121212","icons":[{"src":"icon.svg","sizes":"any","type":"image/svg+xml"}]}"##;
pub const ICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" rx="22" fill="#161616"/><circle cx="48" cy="48" r="24" fill="none" stroke="#f5f5f5" stroke-width="7"/><circle cx="48" cy="48" r="7" fill="#f5f5f5"/></svg>"##;
pub fn page() -> Resp {
Resp::bytes("text/html; charset=utf-8", PAGE.as_bytes().to_vec()).with_header("Content-Security-Policy", "default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn phone_login_and_api() {
let mut s = Settings { password: httpd::hash_password("pw-123"), ..Default::default() };
s.host_name = "測試 Mac".into();
hub().set_settings(&s);
// Not signed in: refused.
let r = handle(Req { method: "GET".into(), path: "/api/state".into(), ..Default::default() }, false);
assert_eq!(r.status, 401);
let bad = handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"nope"}"#.to_vec(), ..Default::default() }, false);
assert_eq!(bad.status, 401);
let ok = handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"pw-123","device":"iPhone"}"#.to_vec(), ..Default::default() }, false);
assert_eq!(ok.status, 200);
let token = serde_json::from_slice::<Value>(&ok.body).unwrap()["token"].as_str().unwrap().to_string();
hub().publish(View { host: "測試 Mac".into(), bots: vec![json!({"id": 1, "name": "小助理"})], ..Default::default() });
let mut req = Req { method: "GET".into(), path: "/api/state".into(), ..Default::default() };
req.headers.insert("authorization".into(), format!("Bearer {token}"));
let st = handle(req.clone(), false);
assert_eq!(st.status, 200, "{}", String::from_utf8_lossy(&st.body));
let v: Value = serde_json::from_slice(&st.body).unwrap();
assert_eq!(v["bots"][0]["name"], "小助理");
// Sending queues a command for the app.
let mut send = Req { method: "POST".into(), path: "/api/bots/1/send".into(), body: r#"{"text":"你好"}"#.as_bytes().to_vec(), ..Default::default() };
send.headers = req.headers.clone();
assert_eq!(handle(send, false).status, 200);
let cmds = hub().take_cmds();
assert!(cmds.iter().any(|c| matches!(c, Cmd::DeviceAdded(d) if d.name == "iPhone")));
assert!(cmds.contains(&Cmd::Send { bot: 1, text: "你好".into(), attachments: vec![] }));
// The Mac's own screen stays closed unless allowed.
let mut mac = Req { method: "GET".into(), path: "/api/screen/mac".into(), ..Default::default() };
mac.headers = req.headers.clone();
assert_eq!(handle(mac, false).status, 403);
// Device link needs the relay and the switch.
assert_eq!(handle(Req { method: "POST".into(), path: "/api/device/run".into(), body: br#"{"tool":"shell"}"#.to_vec(), ..Default::default() }, true).status, 403);
}
#[test]
fn the_brain_is_an_online_one_that_thinks_for_itself() {
let hosts = vec![
json!({"id": "me", "name": "這台", "online": true, "hands": false, "uses": ""}),
json!({"id": "h", "name": "伺服器手腳", "online": true, "hands": true, "uses": ""}),
json!({"id": "s", "name": "筆電", "online": true, "hands": false, "uses": "b"}),
json!({"id": "old", "name": "舊的", "online": false, "hands": false, "uses": ""}),
json!({"id": "b", "name": "家裡的 Mac", "online": true, "hands": false, "uses": ""}),
];
assert_eq!(pick_brain(&hosts, "me"), Some(("b".into(), "家裡的 Mac".into())));
assert_eq!(pick_brain(&hosts[..4], "me"), None);
}
#[test]
fn team_roles() {
assert!(role_allows("member", "POST", &["api", "bots", "3", "send"]));
assert!(role_allows("member", "GET", &["api", "state"]));
for (m, p) in [("POST", &["api", "bots", "3", "answer"][..]), ("POST", &["api", "bots", "3", "stop"][..]), ("GET", &["api", "screen", "box"][..]), ("POST", &["api", "device", "run"][..])] {
assert!(!role_allows("member", m, p), "{p:?}");
}
assert!(role_allows("admin", "POST", &["api", "bots", "3", "answer"]));
assert!(!role_allows("admin", "POST", &["api", "device", "run"]));
assert!(role_allows("device", "POST", &["api", "device", "run"]) && !role_allows("device", "GET", &["api", "state"]));
assert!(!role_allows("stranger", "GET", &["api", "state"]));
// The relay's word reaches handle() as headers.
let req = req_from_json(&json!({"method": "POST", "path": "/api/bots/1/answer", "user": "amy", "role": "member", "body": ""}));
assert_eq!(handle(req, true).status, 403);
}
#[test]
fn relay_messages_round_trip() {
let r = Resp::bytes("image/jpeg", vec![1, 2, 3]).with_header("X-Size", "2x2");
let j = resp_to_json(&r);
assert_eq!(j["status"], 200);
let req = req_from_json(&json!({"method": "POST", "path": "/api/bots/2/send", "query": {"since": "4"}, "body": "eyJ0ZXh0IjoiaGkifQ=="}));
assert_eq!(req.json()["text"], "hi");
assert_eq!(req.query["since"], "4");
}
}
/// Where the one-step installer lives (it also installs AnyBot on a computer that has nothing yet).
pub const INSTALL_URL: &str = "https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh";
/// The line 「加一台電腦」 shows: pasted into any Linux or Mac terminal, it installs AnyBot when it
/// isn't there yet and joins this computer as hands (`anybot setup 網址 配對碼`).
pub fn pair_line(relay_url: &str, code: &str) -> String {
format!("curl -fsSL {INSTALL_URL} | sh -s -- hands {} {code}", relay_url.trim_end_matches('/'))
}
/// 登入後同步 against a real account server: ANYBOT_RELAY_URL=http://127.0.0.1:PORT
#[cfg(test)]
mod account_sync_live {
use super::*;
#[test]
#[ignore]
fn a_new_computer_gets_the_accounts_bots_after_signing_in() {
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");
// 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 mut st = crate::model::State::samples();
st.remote = Default::default();
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.
let b = relay_join(&Settings { host_name: "新電腦".into(), ..Default::default() }, &url, &user, pw, false, "").unwrap();
let found = find_account_bots(&b, pw);
assert!(found.brain.is_none());
let got: crate::model::State = serde_json::from_slice(&found.backup.expect("backup")).unwrap();
let mut here = crate::model::State::first_run();
assert_eq!(here.take_in_bots(&got), 5);
}
}