1817 lines
92 KiB
Rust
1817 lines
92 KiB
Rust
// 手機遙控: 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 {
|
||
// Asked of the system once (it runs a program).
|
||
static N: OnceLock<String> = OnceLock::new();
|
||
N.get_or_init(system_host_name).clone()
|
||
}
|
||
|
||
fn system_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 {
|
||
/// `from`: the account computer the person typed it on (empty: the phone, or this one).
|
||
Send { bot: u64, text: String, attachments: Vec<String>, from: String },
|
||
Stop { bot: u64 },
|
||
Answer { bot: u64, answer: String },
|
||
Read { bot: u64 },
|
||
NewThread { bot: u64 },
|
||
/// The time zone of the phone the person uses (a brain on a server only knows UTC).
|
||
PersonTimezone(String),
|
||
/// The phone changes a bot's model or its computer (empty = unchanged).
|
||
SetBot { bot: u64, provider: String, model: String, computer: String },
|
||
/// 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 },
|
||
/// 清除聊天記錄 from a screen of this brain (the chat lives here).
|
||
Clear { bot: u64 },
|
||
/// 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>,
|
||
/// The models the phone can switch a bot to: each ready provider with its known models.
|
||
pub models: Vec<Value>,
|
||
/// The sidebar's 分區 in order (None = 未分區), empty ones too, as the desktop shows them.
|
||
pub sections: Vec<Option<String>>,
|
||
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>,
|
||
/// Wrong logins, counted per source (one noisy LAN device must not lock out the owner).
|
||
fails: Mutex<HashMap<String, (u32, 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 {
|
||
/// 手機: something the desktop does with a bot, a page, a routine or a setting, or one of the
|
||
/// views it shows (a bot's details, 設定), done on the app's thread; `role` is who asks.
|
||
Phone { action: String, body: Value, role: String },
|
||
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(HashMap::new()), 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, "path": a.path})).collect::<Vec<_>>(),
|
||
"at": m.at,
|
||
"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", ["screen"]) => return Resp::bytes("text/html; charset=utf-8", SCREEN_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' ws: wss:"),
|
||
("GET", ["novnc.js"]) => return Resp::bytes("text/javascript; charset=utf-8", NOVNC.as_bytes().to_vec()).with_header("Cache-Control", "public, max-age=86400"),
|
||
("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", [name]) if ICON_PNGS.iter().any(|(n, _)| n == name) => return Resp::bytes("image/png", ICON_PNGS.iter().find(|(n, _)| n == name).map(|(_, b)| b.to_vec()).unwrap_or_default()),
|
||
("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);
|
||
// The phone says where the person is (once per change, not every poll).
|
||
if let Some(tz) = req.query.get("tz").filter(|t| t.parse::<chrono_tz::Tz>().is_ok()) {
|
||
static TOLD: Mutex<String> = Mutex::new(String::new());
|
||
let mut told = TOLD.lock().unwrap_or_else(|e| e.into_inner());
|
||
if *told != *tz {
|
||
*told = tz.clone();
|
||
hub().push(Cmd::PersonTimezone(tz.clone()));
|
||
}
|
||
}
|
||
let v = hub().wait_newer(since, if since == 0 { 0 } else { LONG_POLL_SECS });
|
||
// 頁面 for the phone's home: the newest first, each with its bot.
|
||
let now = crate::model::now_ms();
|
||
let mut pages: Vec<&crate::pages::Page> = v.pages.iter().collect();
|
||
pages.sort_by_key(|p| std::cmp::Reverse(p.updated));
|
||
let bot_name = |id: u64| v.bots.iter().find(|b| b["id"].as_u64() == Some(id)).and_then(|b| b["name"].as_str()).unwrap_or("").to_string();
|
||
let pages: Vec<Value> = pages.into_iter().take(12).map(|p| {
|
||
let mut c = crate::pages::card_json(p, now);
|
||
c["bot"] = json!(bot_name(p.bot));
|
||
c
|
||
}).collect();
|
||
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,
|
||
"pages": pages,
|
||
"models": if role == "member" { vec![] } else { v.models.clone() },
|
||
"sections": v.sections,
|
||
}))
|
||
}
|
||
// 頁面: 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"),
|
||
}
|
||
}
|
||
// What the desktop shows beside the chat and in 設定, made when asked (never keys, tokens
|
||
// or the vault's secrets), and everything else it can do (api/act/<what>).
|
||
("GET", ["api", "bots", id, "detail"]) => phone_act("detail", json!({"bot": id.parse::<u64>().unwrap_or(0)}), &role),
|
||
("GET", ["api", "settings"]) => phone_act("settings", json!({}), &role),
|
||
("GET", ["api", "box", "thumb"]) => box_thumb(),
|
||
("POST", ["api", "act", action]) => phone_act(action, req.json(), &role),
|
||
("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, from: String::new() }
|
||
}
|
||
"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 },
|
||
"restore" => Cmd::RestoreThread { bot, index: b["index"].as_u64().unwrap_or(0) as usize },
|
||
"set" => Cmd::SetBot {
|
||
bot,
|
||
provider: b["provider"].as_str().unwrap_or("").to_string(),
|
||
model: b["model"].as_str().unwrap_or("").trim().to_string(),
|
||
computer: b["computer"].as_str().unwrap_or("").trim().to_string(),
|
||
},
|
||
"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),
|
||
// A screen of this brain shows the bots' own computer, which lives here: a small picture
|
||
// (people watch it live through VNC, see vnc.rs).
|
||
("GET", ["api", "sync", "screen"]) => match crate::cloudbox::thumbnail(320) {
|
||
Some(img) => match crate::computer::encode_jpeg(&image::DynamicImage::ImageRgba8(img).to_rgb8()) {
|
||
Some(j) => Resp::bytes("image/jpeg", j),
|
||
None => Resp::error(500, "畫面編碼失敗"),
|
||
},
|
||
None => Resp::error(404, "還沒有畫面"),
|
||
},
|
||
// 接管 / 我完成了 from a screen: the bot's steps wait (or not) for the person.
|
||
("POST", ["api", "sync", "screen"]) => {
|
||
let v = req.json();
|
||
match v["kind"].as_str().unwrap_or("") {
|
||
"hold" => {
|
||
crate::cloudbox::HELD_BY_PERSON.store(v["on"].as_bool().unwrap_or(false), Ordering::SeqCst);
|
||
Resp::json(&json!({"ok": true}))
|
||
}
|
||
"wake" => match crate::cloudbox::ensure_running() {
|
||
Ok(()) => Resp::json(&json!({"ok": true})),
|
||
Err(e) => Resp::error(500, &e),
|
||
},
|
||
_ => Resp::error(400, "不認得的操作"),
|
||
}
|
||
}
|
||
("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).
|
||
// A file in a chat (a picture the bot showed, a file it sent, one the person attached),
|
||
// for the phone to show or download. Only from the chats' own file folder.
|
||
("GET", ["api", "file"]) => match chat_file(&req.query.get("path").cloned().unwrap_or_default()) {
|
||
Some((f, b)) => Resp::bytes(content_type(&f), b),
|
||
None => Resp::error(404, "找不到這個檔案"),
|
||
},
|
||
("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(),
|
||
// The relay names the computer that asked: the one the person is using.
|
||
from: req.header("x-anybot-from").to_string(),
|
||
},
|
||
"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 },
|
||
"clear" => Cmd::Clear { bot },
|
||
_ => 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.
|
||
/// A phone action carried out on the app's thread (where the state is), with its answer.
|
||
fn phone_act(action: &str, body: Value, role: &str) -> Resp {
|
||
match hub().ask_sync_for(SyncKind::Phone { action: action.to_string(), body, role: role.to_string() }, 20) {
|
||
Ok(v) if v.get("error").is_some() => Resp::error(400, v["error"].as_str().unwrap_or("做不到")),
|
||
Ok(v) => Resp::json(&v),
|
||
Err(e) => Resp::error(503, &e),
|
||
}
|
||
}
|
||
|
||
/// The bots' computer's screen, small (the phone's 電腦 card and a hand-off), as JPEG.
|
||
fn box_thumb() -> Resp {
|
||
let Some(img) = crate::cloudbox::thumbnail(480) else { return Resp::error(404, "Bot 的電腦沒有開著") };
|
||
let mut out = std::io::Cursor::new(vec![]);
|
||
match image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 72).encode_image(&image::DynamicImage::ImageRgba8(img).to_rgb8()) {
|
||
Ok(()) => Resp::bytes("image/jpeg", out.into_inner()),
|
||
Err(e) => Resp::error(500, &e.to_string()),
|
||
}
|
||
}
|
||
|
||
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", ..]) | ("GET", ["api", "file"]) | ("POST", ["api", "bots", _, "send" | "read" | "upload"])),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// A chat file by its path, only when it really is inside the chats' file folder.
|
||
fn chat_file(want: &str) -> Option<(std::path::PathBuf, Vec<u8>)> {
|
||
let root = crate::model::State::path().parent()?.join("attachments").canonicalize().ok()?;
|
||
let f = std::path::PathBuf::from(want).canonicalize().ok()?;
|
||
if !f.starts_with(&root) || !f.is_file() {
|
||
return None;
|
||
}
|
||
let b = std::fs::read(&f).ok()?;
|
||
Some((f, b))
|
||
}
|
||
|
||
fn content_type(f: &std::path::Path) -> &'static str {
|
||
match f.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
|
||
Some("jpg" | "jpeg") => "image/jpeg",
|
||
Some("png") => "image/png",
|
||
Some("gif") => "image/gif",
|
||
Some("webp") => "image/webp",
|
||
Some("pdf") => "application/pdf",
|
||
Some("txt" | "md" | "csv") => "text/plain; charset=utf-8",
|
||
_ => "application/octet-stream",
|
||
}
|
||
}
|
||
|
||
fn login(req: &Req) -> Resp {
|
||
// One counter per source address, cleaned as it is touched.
|
||
let ip = req.peer.split(':').next().unwrap_or(&req.peer).to_string();
|
||
{
|
||
let mut f = hub().fails.lock().unwrap();
|
||
f.retain(|_, (_, t)| t.elapsed() < Duration::from_secs(60));
|
||
if let Some((n, t)) = f.get(&ip) {
|
||
if *n >= MAX_FAILS && t.elapsed() < Duration::from_secs(60) {
|
||
return Resp::error(429, "密碼錯太多次,請一分鐘後再試");
|
||
}
|
||
}
|
||
}
|
||
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();
|
||
let e = f.entry(ip.clone()).or_insert((0, Instant::now()));
|
||
if e.1.elapsed() >= Duration::from_secs(60) {
|
||
*e = (0, Instant::now());
|
||
}
|
||
e.0 += 1;
|
||
e.1 = 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().remove(&ip);
|
||
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.
|
||
/// This Mac's screen for the phone (when allowed). The bots' computer is watched through VNC
|
||
/// (the page /screen, vnc.rs).
|
||
fn screen(which: &str, s: &Settings) -> Resp {
|
||
match which {
|
||
"mac" if s.mac_from_phone => {
|
||
match crate::computer::plain_picture() {
|
||
Ok((w, h, jpeg)) => Resp::bytes("image/jpeg", jpeg).with_header("X-Size", &format!("{w}x{h}")),
|
||
Err(e) => Resp::error(503, &e),
|
||
}
|
||
}
|
||
"mac" => Resp::error(403, "這台 Mac 沒有開放從手機操作(AnyBot 設定 › 手機與帳號)"),
|
||
_ => Resp::error(404, "沒有這個畫面"),
|
||
}
|
||
}
|
||
|
||
/// The phone taps, types or scrolls on this Mac's screen, as the person, in the pixels of the
|
||
/// picture it was sent (shot_size). The bots' computer takes the person's input through VNC.
|
||
fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp {
|
||
let (x, y) = (v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0));
|
||
let down = v["direction"].as_str().unwrap_or("down") != "up";
|
||
let amount = v["amount"].as_i64().unwrap_or(3).clamp(1, 20);
|
||
match which {
|
||
"mac" if s.mac_from_phone => {
|
||
let (name, args) = match v["kind"].as_str().unwrap_or("") {
|
||
"click" => ("computer_use_click", json!({"x": x, "y": y, "button": v["button"].as_str().unwrap_or("left"), "count": v["count"].as_u64().unwrap_or(1)})),
|
||
"type" => ("computer_use_typing", json!({"value": v["text"]})),
|
||
"key" => ("computer_use_press_key", json!({"key": v["key"]})),
|
||
"scroll" => ("computer_use_scroll", json!({"x": x, "y": y, "direction": if down { "down" } else { "up" }, "amount": amount})),
|
||
_ => return Resp::error(400, "不認得的操作"),
|
||
};
|
||
match crate::computer::person_act(name, &args) {
|
||
Ok(()) => Resp::json(&json!({"ok": true})),
|
||
Err(e) => Resp::error(500, &e),
|
||
}
|
||
}
|
||
"mac" => Resp::error(403, "這台 Mac 沒有開放從手機操作"),
|
||
_ => Resp::error(404, "沒有這個畫面"),
|
||
}
|
||
}
|
||
|
||
/// 裝置連結: 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");
|
||
// The brain's model request to a model only this computer's network reaches.
|
||
if tool == MODEL_FETCH {
|
||
return model_fetch_here(args);
|
||
}
|
||
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}));
|
||
}
|
||
// send_file from this computer: the file itself goes to the brain, which puts it in the chat.
|
||
if tool == FETCH_FILE {
|
||
return fetch_file_here(args);
|
||
}
|
||
if matches!(tool, "shell" | "read_file" | "edit_file" | "list_dir" | "search" | "read_terminal" | "write_terminal") {
|
||
// The brain passes the bot's work folder inside the tool's args (empty = this home).
|
||
let dir = crate::tools::args_of(args).get("workdir").and_then(Value::as_str).unwrap_or("").trim().to_string();
|
||
let mut workdir = crate::tools::expand_home(if dir.is_empty() { "~" } else { &dir });
|
||
if !workdir.is_absolute() {
|
||
workdir = dirs::home_dir().unwrap_or(workdir);
|
||
}
|
||
let t = crate::tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: crate::tools::Place::Local { workdir } };
|
||
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, "這個工具不能遠端使用")
|
||
}
|
||
|
||
/// A file on this computer, for the brain's send_file (the brain has no other way to it).
|
||
pub const FETCH_FILE: &str = "fetch_file";
|
||
/// Largest file sent that way: the relay carries 30 MB, and base64 adds a third.
|
||
const FETCH_FILE_MAX: u64 = 20 * 1024 * 1024;
|
||
|
||
fn fetch_file_here(args: &str) -> Resp {
|
||
let v = crate::tools::args_of(args);
|
||
let path = v["path"].as_str().unwrap_or("").trim().to_string();
|
||
let dir = v["workdir"].as_str().unwrap_or("").trim().to_string();
|
||
let base = crate::tools::expand_home(if dir.is_empty() { "~" } else { &dir });
|
||
let full = crate::tools::resolve(&base, &path);
|
||
// Same rule as reading a file: keys, the app's own data and other secrets never leave.
|
||
if crate::tools::is_ignored(&full) || crate::tools::is_ignored(std::path::Path::new(&path)) {
|
||
return Resp::json(&json!({"text": "這個檔案是機密的,不能傳到聊天。", "ok": false}));
|
||
}
|
||
match std::fs::metadata(&full) {
|
||
Ok(m) if m.is_dir() => Resp::json(&json!({"text": format!("「{path}」是資料夾,請先壓縮成一個檔案。"), "ok": false})),
|
||
Ok(m) if m.len() > FETCH_FILE_MAX => Resp::json(&json!({"text": format!("「{path}」太大了(最多 20 MB)。"), "ok": false})),
|
||
Ok(_) => match std::fs::read(&full) {
|
||
Ok(b) => {
|
||
use base64::Engine;
|
||
let name = full.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "檔案".into());
|
||
Resp::json(&json!({"ok": true, "text": "", "name": name, "data": base64::engine::general_purpose::STANDARD.encode(&b)}))
|
||
}
|
||
Err(e) => Resp::json(&json!({"text": format!("讀不到「{path}」:{e}"), "ok": false})),
|
||
},
|
||
Err(_) => Resp::json(&json!({"text": format!("這台電腦上找不到「{path}」。"), "ok": false})),
|
||
}
|
||
}
|
||
|
||
// ---------- a model on the person's network, for a brain on a server ----------
|
||
// A brain in the cloud can't reach a model at a private address (a Qwen server at work, Ollama at
|
||
// home). Its hands can: the brain hands them the request and gets the whole answer back.
|
||
|
||
const MODEL_FETCH: &str = "model_fetch";
|
||
|
||
/// Private, link-local or loopback address (by name: localhost and *.local).
|
||
fn private_host(host: &str) -> bool {
|
||
let h = host.trim_matches(['[', ']']);
|
||
if h == "localhost" || h.ends_with(".local") {
|
||
return true;
|
||
}
|
||
match h.parse::<std::net::IpAddr>() {
|
||
// Not link-local: a cloud VM's metadata service (169.254.169.254) must never be
|
||
// reachable through the hands, only real model servers on the person's network.
|
||
Ok(std::net::IpAddr::V4(a)) => (a.is_private() || a.is_loopback()) && !a.is_link_local(),
|
||
Ok(std::net::IpAddr::V6(a)) => (a.is_loopback() || (a.segments()[0] & 0xfe00) == 0xfc00) && !a.is_unicast_link_local(),
|
||
Err(_) => false,
|
||
}
|
||
}
|
||
|
||
fn host_port(url: &str) -> Option<(String, u16)> {
|
||
let (scheme, rest) = url.split_once("://")?;
|
||
let auth = rest.split('/').next()?;
|
||
let auth = auth.rsplit('@').next()?;
|
||
let (h, p) = match auth.rsplit_once(':') {
|
||
Some((h, p)) if !h.ends_with(']') || auth.starts_with('[') => (h.to_string(), p.parse().ok()?),
|
||
_ => (auth.to_string(), if scheme == "https" { 443 } else { 80 }),
|
||
};
|
||
Some((h, p))
|
||
}
|
||
|
||
/// The hands to send a model request through: the model is at a private address this brain
|
||
/// can't open a connection to, and the person's computer lends this brain its hands.
|
||
pub fn model_hands_for(url: &str) -> Option<String> {
|
||
let s = hub().settings();
|
||
// The person's own computer (the one they type on) is the one on their network; the first
|
||
// hands by id was a desktop in the server room that can't reach a model at their office.
|
||
let hands = my_hands(&s.host_id);
|
||
let person = person_host();
|
||
let hand = hands.iter().find(|h| **h == person).or_else(|| hands.first()).cloned()?;
|
||
let (h, p) = host_port(url)?;
|
||
if !private_host(&h) || h == "localhost" || h.starts_with("127.") {
|
||
return None;
|
||
}
|
||
static SEEN: Mutex<Option<HashMap<String, (bool, std::time::Instant)>>> = Mutex::new(None);
|
||
let key = format!("{h}:{p}");
|
||
if let Some((reach, at)) = SEEN.lock().unwrap().get_or_insert_with(HashMap::new).get(&key) {
|
||
if at.elapsed().as_secs() < 300 {
|
||
return if *reach { None } else { Some(hand) };
|
||
}
|
||
}
|
||
use std::net::ToSocketAddrs;
|
||
let reach = (h.as_str(), p).to_socket_addrs().ok().and_then(|mut a| a.next()).map(|a| std::net::TcpStream::connect_timeout(&a, std::time::Duration::from_millis(1500)).is_ok()).unwrap_or(false);
|
||
SEEN.lock().unwrap().get_or_insert_with(HashMap::new).insert(key, (reach, std::time::Instant::now()));
|
||
if reach { None } else { Some(hand) }
|
||
}
|
||
|
||
/// Send a model request (POST, JSON) through the hands; gives back its status and whole body.
|
||
pub fn model_via_hands(hand: &str, url: &str, headers: &[(String, String)], body: &str) -> Result<(u16, Vec<u8>), String> {
|
||
let args = json!({"url": url, "headers": headers, "body": body}).to_string();
|
||
let req = json!({"tool": MODEL_FETCH, "args": args, "who": "大腦"}).to_string();
|
||
let name = peer_name(hand);
|
||
let whom = if name.is_empty() { "使用者的電腦".to_string() } else { format!("「{name}」") };
|
||
let r = crate::tunnel::call(hand, "POST", "/api/device/run", &[], req.as_bytes(), 600).ok_or_else(|| format!("模型在只有{whom}連得到的網路上,但大腦現在和帳號伺服器沒有連線"))?;
|
||
let a = r.map_err(|e| format!("透過{whom}連模型失敗:{e}"))?;
|
||
let v = answer_value(a.status, &a.body, &whom)?;
|
||
if let Some(e) = v["error"].as_str() {
|
||
return Err(format!("{whom}也連不到模型:{e}"));
|
||
}
|
||
Ok((v["status"].as_u64().unwrap_or(502) as u16, v["text"].as_str().unwrap_or("").as_bytes().to_vec()))
|
||
}
|
||
|
||
/// On the hands: make the brain's model request, only to an address on this computer's network.
|
||
fn model_fetch_here(args: &str) -> Resp {
|
||
let v: Value = serde_json::from_str(args).unwrap_or(Value::Null);
|
||
let url = v["url"].as_str().unwrap_or("");
|
||
if !host_port(url).map(|(h, _)| private_host(&h)).unwrap_or(false) {
|
||
return Resp::error(403, "只幫大腦連這台電腦網路裡的模型");
|
||
}
|
||
let mut r = agent(600).post(url).header("Content-Type", "application/json");
|
||
for h in v["headers"].as_array().into_iter().flatten() {
|
||
if let (Some(k), Some(val)) = (h[0].as_str(), h[1].as_str()) {
|
||
r = r.header(k, val);
|
||
}
|
||
}
|
||
match r.config().http_status_as_error(false).build().send(v["body"].as_str().unwrap_or("").as_bytes()) {
|
||
Ok(mut resp) => {
|
||
let status = resp.status().as_u16();
|
||
let text = resp.body_mut().with_config().limit(50 * 1024 * 1024).read_to_string().unwrap_or_default();
|
||
Resp::json(&json!({"status": status, "text": text}))
|
||
}
|
||
Err(e) => Resp::json(&json!({"error": why_unreachable(&e)})),
|
||
}
|
||
}
|
||
|
||
// ---------- 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_upgrade(listener, Arc::new(|r| handle(r, false)), Some(Arc::new(lan_vnc)), Arc::new(move || LAN_GEN.load(Ordering::SeqCst) == gen)));
|
||
Ok(port)
|
||
}
|
||
|
||
/// The phone page on the local network watching the bots' computer: /api/vnc, signed in with
|
||
/// the phone's password like every other request here; the VNC stream goes straight to the box.
|
||
fn lan_vnc(req: Req, mut c: std::net::TcpStream) {
|
||
use tungstenite::{protocol::Role, Message, WebSocket};
|
||
if req.path != "/api/vnc" {
|
||
return httpd::write_response(&mut c, &Resp::error(404, "沒有這個網址"));
|
||
}
|
||
if devices_ok(&req.bearer()).is_none() {
|
||
return httpd::write_response(&mut c, &Resp::error(401, "請先登入"));
|
||
}
|
||
if !httpd::accept_websocket(&req, &mut c) {
|
||
return;
|
||
}
|
||
let _ = c.set_read_timeout(Some(Duration::from_millis(2)));
|
||
let mut ws = WebSocket::from_raw_socket(c, Role::Server, None);
|
||
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||
let send = move |b: &[u8]| tx.send(b.to_vec()).is_ok();
|
||
let Ok(mut session) = crate::vnc::open(send.clone()) else { return };
|
||
if !send(crate::vnc::GREETING) {
|
||
return;
|
||
}
|
||
'live: loop {
|
||
match ws.read() {
|
||
Ok(Message::Binary(b)) => {
|
||
if !session.feed(&b, &send) {
|
||
break;
|
||
}
|
||
}
|
||
Ok(Message::Text(t)) => crate::vnc::person_typed(&t),
|
||
Ok(Message::Close(_)) => break,
|
||
Ok(_) => {}
|
||
Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {}
|
||
Err(_) => break,
|
||
}
|
||
loop {
|
||
match rx.try_recv() {
|
||
// An empty send: the box's side ended.
|
||
Ok(b) if b.is_empty() => break 'live,
|
||
Ok(b) => {
|
||
if ws.write(Message::Binary(b.into())).is_err() {
|
||
break 'live;
|
||
}
|
||
}
|
||
Err(_) => break,
|
||
}
|
||
}
|
||
match ws.flush() {
|
||
Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {}
|
||
Err(_) => break,
|
||
Ok(()) => {}
|
||
}
|
||
}
|
||
let _ = ws.close(None);
|
||
}
|
||
|
||
/// 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> {
|
||
// 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
|
||
// that cannot open each other's backups.
|
||
let user = user.trim().to_lowercase();
|
||
let user = user.as_str();
|
||
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> {
|
||
// Same name shape the relay (and relay_join) use for the backup key.
|
||
let user = user.trim().to_lowercase();
|
||
let user = user.as_str();
|
||
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}={}", httpd::url_encode(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())
|
||
}
|
||
|
||
/// The brain's bots' computer as a JPEG (`thumb`: the small picture that never wakes it).
|
||
pub fn sync_screen(s: &Settings, thumb: bool) -> Result<Vec<u8>, String> {
|
||
let (st, bytes) = to_brain_raw(s, "GET", "/screen", &[("thumb", if thumb { "1" } else { "0" }.to_string())], &[], 30)?;
|
||
if !(200..300).contains(&st) {
|
||
return Err(why_refused(st, &serde_json::from_slice(&bytes).unwrap_or(Value::Null), "大腦"));
|
||
}
|
||
Ok(bytes)
|
||
}
|
||
|
||
/// A link for this account's browser to watch the brain's computer (/screen on the account
|
||
/// server; one-time, it works for ten minutes).
|
||
pub fn screen_ticket(s: &Settings) -> Result<String, String> {
|
||
if s.relay_url.is_empty() || s.brain_id.is_empty() {
|
||
return Err("這台沒有連到帳號的大腦".into());
|
||
}
|
||
let url = format!("{}/relay/screen-ticket", s.relay_url.trim_end_matches('/'));
|
||
let mut r = agent(15)
|
||
.post(&url)
|
||
.header("Authorization", &format!("Bearer {}", s.host_token))
|
||
.header("Content-Type", "application/json")
|
||
.send(json!({"host": s.brain_id}).to_string().as_bytes())
|
||
.map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?;
|
||
let v: Value = r.body_mut().read_json().map_err(|_| "帳號伺服器的回答看不懂".to_string())?;
|
||
v["ticket"].as_str().map(String::from).ok_or_else(|| "帳號伺服器沒有給看畫面的連結(它可能是舊版,請更新)".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)
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// The account's computers as last seen: id → (name, the brain it uses, lends its hands).
|
||
fn peer_rows() -> &'static Mutex<HashMap<String, (String, String, bool)>> {
|
||
static P: OnceLock<Mutex<HashMap<String, (String, String, bool)>>> = OnceLock::new();
|
||
P.get_or_init(Mutex::default)
|
||
}
|
||
|
||
/// Computers of the account that lend their hands to this brain (`me`), by id.
|
||
pub fn my_hands(me: &str) -> Vec<String> {
|
||
if me.is_empty() {
|
||
return vec![];
|
||
}
|
||
let mut v: Vec<String> = peer_rows().lock().unwrap().iter().filter(|(_, (_, uses, hands))| *hands && uses == me).map(|(id, _)| id.clone()).collect();
|
||
v.sort();
|
||
v
|
||
}
|
||
|
||
fn person_cell() -> &'static Mutex<String> {
|
||
static P: OnceLock<Mutex<String>> = OnceLock::new();
|
||
P.get_or_init(Mutex::default)
|
||
}
|
||
|
||
/// The account computer the person last typed on (they are sitting at it). Kept beside the data,
|
||
/// so a brain that restarts (an update) still knows it.
|
||
pub fn set_person_host(id: &str) {
|
||
let mut cur = person_cell().lock().unwrap();
|
||
if *cur != id {
|
||
*cur = id.to_string();
|
||
let _ = std::fs::write(person_file(), id);
|
||
}
|
||
}
|
||
|
||
fn person_file() -> std::path::PathBuf {
|
||
crate::model::State::path().with_file_name("person-computer.txt")
|
||
}
|
||
|
||
/// At start: the computer the person used last time.
|
||
pub fn load_person_host() -> Option<String> {
|
||
let id = std::fs::read_to_string(person_file()).ok()?.trim().to_string();
|
||
(!id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric())).then(|| {
|
||
*person_cell().lock().unwrap() = id.clone();
|
||
id
|
||
})
|
||
}
|
||
|
||
pub fn person_host() -> String {
|
||
person_cell().lock().unwrap().clone()
|
||
}
|
||
|
||
/// The brain an account computer shows (empty: it thinks for itself), as last seen.
|
||
pub fn peer_uses(id: &str) -> String {
|
||
peer_rows().lock().unwrap().get(id).map(|r| r.1.clone()).unwrap_or_default()
|
||
}
|
||
|
||
/// A computer's own name as the account server knows it.
|
||
pub fn peer_name(id: &str) -> String {
|
||
peer_rows().lock().unwrap().get(id).map(|r| r.0.clone()).unwrap_or_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);
|
||
*peer_rows().lock().unwrap() = v["hosts"]
|
||
.as_array()
|
||
.into_iter()
|
||
.flatten()
|
||
.filter_map(|h| Some((h["id"].as_str()?.to_string(), (h["name"].as_str().unwrap_or("").to_string(), h["uses"].as_str().unwrap_or("").to_string(), h["hands"].as_bool() == Some(true)))))
|
||
.collect();
|
||
*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");
|
||
/// The bots' computer, watched and used in a browser (noVNC; see vnc.rs).
|
||
const SCREEN_PAGE: &str = include_str!("../mobile/screen.html");
|
||
const NOVNC: &str = include_str!("../mobile/novnc.js");
|
||
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"},{"src":"icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}"##;
|
||
/// The app's icon, the same drawing everywhere (icons/anybot.svg is the Mac/Linux one).
|
||
pub const ICON: &str = include_str!("../mobile/icon.svg");
|
||
/// PNG sizes for the iPhone home screen (it ignores SVG) and Android.
|
||
pub const ICON_PNGS: [(&str, &[u8]); 3] = [("icon-180.png", include_bytes!("../mobile/icon-180.png")), ("icon-192.png", include_bytes!("../mobile/icon-192.png")), ("icon-512.png", include_bytes!("../mobile/icon-512.png"))];
|
||
|
||
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 model_requests_go_only_to_private_addresses() {
|
||
assert!(private_host("10.184.10.252") && private_host("192.168.1.5") && private_host("localhost") && private_host("nas.local"));
|
||
assert!(!private_host("api.x.ai") && !private_host("8.8.8.8"));
|
||
// A cloud VM's metadata service answers on link-local: the hands must never reach it.
|
||
assert!(!private_host("169.254.169.254") && !private_host("169.254.0.1"));
|
||
assert_eq!(host_port("http://10.184.10.252:8000/v1"), Some(("10.184.10.252".into(), 8000)));
|
||
assert_eq!(host_port("https://api.x.ai/v1"), Some(("api.x.ai".into(), 443)));
|
||
assert_eq!(model_fetch_here(&json!({"url": "https://api.x.ai/v1/chat/completions"}).to_string()).status, 403);
|
||
assert_eq!(model_fetch_here(&json!({"url": "http://169.254.169.254/latest/meta-data/"}).to_string()).status, 403);
|
||
}
|
||
|
||
#[test]
|
||
fn lan_login_lock_is_per_device() {
|
||
// One noisy device guessing passwords must not lock the owner's phone out (a single
|
||
// global counter used to do exactly that).
|
||
let mut s = Settings { password: httpd::hash_password("pw-123"), ..Default::default() };
|
||
s.host_name = "測試 Mac".into();
|
||
hub().set_settings(&s);
|
||
let bad = |ip: &str| handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"nope"}"#.to_vec(), peer: ip.into(), ..Default::default() }, false);
|
||
for _ in 0..MAX_FAILS {
|
||
assert_eq!(bad("192.168.1.66:5000").status, 401);
|
||
}
|
||
// The noisy device is now locked out…
|
||
assert_eq!(bad("192.168.1.66:5000").status, 429);
|
||
// …while every other device on the LAN can still sign in.
|
||
let ok = handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"pw-123"}"#.to_vec(), peer: "192.168.1.77:5001".into(), ..Default::default() }, false);
|
||
assert_eq!(ok.status, 200);
|
||
}
|
||
|
||
#[test]
|
||
fn roles_gate_the_sync_and_hands_apis() {
|
||
// 大腦 sync (mirror) and 裝置連結 (device) are separate doors on the hands machine:
|
||
// a chat admin must not reach either, and a brain may not run tools through the sync door.
|
||
assert!(role_allows("device", "POST", &["api", "device", "run"]));
|
||
assert!(!role_allows("device", "GET", &["api", "sync"]));
|
||
assert!(role_allows("mirror", "GET", &["api", "sync"]));
|
||
assert!(role_allows("mirror", "POST", &["api", "sync", "edit"]));
|
||
assert!(!role_allows("mirror", "POST", &["api", "device", "run"]));
|
||
assert!(role_allows("admin", "POST", &["api", "bots", "1", "send"]));
|
||
assert!(!role_allows("admin", "POST", &["api", "device", "run"]));
|
||
assert!(!role_allows("admin", "GET", &["api", "sync"]));
|
||
assert!(role_allows("member", "POST", &["api", "bots", "1", "send"]));
|
||
assert!(!role_allows("member", "POST", &["api", "bots", "1", "stop"]));
|
||
|
||
// A hands machine takes tool orders only through the relay, only when the person opened
|
||
// it (裝置連結), and only from the brain it was told to serve.
|
||
let open_for_one = Settings { device_link: true, hands_for: "brain1".into(), hands_for_name: "雲端大腦".into(), ..Default::default() };
|
||
assert_eq!(device_run(&json!({"tool": "x"}), &open_for_one, false, "brain1").status, 403);
|
||
assert_eq!(device_run(&json!({"tool": "x"}), &Settings { device_link: false, hands_for: "brain1".into(), ..Default::default() }, true, "brain1").status, 403);
|
||
assert_eq!(device_run(&json!({"tool": "x"}), &open_for_one, true, "other-brain").status, 403);
|
||
// The chosen brain reaches past the gates (and gets "this tool can't run remotely" for x).
|
||
assert_eq!(device_run(&json!({"tool": "x"}), &open_for_one, true, "brain1").status, 400);
|
||
}
|
||
|
||
#[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![], from: String::new() }));
|
||
// 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);
|
||
}
|
||
}
|