AnyBot/src/remote.rs

1253 lines
60 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

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

// 手機遙控: 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>>,
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>) -> 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",
};
if c.name == "show_card" {
json!({"card": serde_json::from_str::<Value>(&c.args).unwrap_or(Value::Null), "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,
}))
}
("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", _]) | ("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();
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("tls") || text.contains("certificate") => "和伺服器的安全連線失敗(伺服器的憑證有問題)",
_ 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())
}
/// 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 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");
}
}