Compare commits

...

10 Commits

Author SHA1 Message Date
王性驊 11ed58cb3c Merge pull request '多台電腦(大腦、手腳、螢幕)、一行加電腦、文件與 make' (#2) from daniel.w/multi-device into main
Reviewed-on: #2
2026-11-04 14:40:35 +00:00
王性驊 867732bd73 make, README and docs: build and install from source
- Makefile: make / run / install / uninstall / deps / test / serve / setup /
  hands / relay-run / server / dist / clean (macOS and Linux).
- README.md and docs/: install (all platforms, data locations, troubleshooting),
  multi-device (brain, hands, screens, pairing, what the offline messages mean),
  architecture (layers, data, security, sync, keys, hands, picture helper),
  protocol (relay and AnyBot endpoints, WebSocket frames, sync ops), release
  (packaging, Gitea Actions, Linux and Mac runners).
- install.sh --deps-only, bundle.sh KEEP_APP=1 (used by make deps / install).
- FEATURES.md: the multi-device part is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 22:16:45 +08:00
王性驊 87be66b016 Release builds on Gitea Actions: macOS app, Ubuntu, headless Linux, server
- .gitea/workflows/release.yml: on a v* tag, a Linux runner builds and
  packages the Ubuntu, headless and server bundles; a Mac runner makes
  AnyBot.app. Both attach their files to the tag's release.
- deploy/ci: the Linux build image (Ubuntu 22.04), packaging, release
  upload, runner settings, and a server bundle that runs the prebuilt
  binaries (no compiling on the server).
- deploy/mac/bundle.sh: AnyBot.app with its icon, signed ad hoc, zipped.
- relay/Dockerfile: copy src/wire.rs too (the relay needs it since the
  lasting connection).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 22:07:46 +08:00
王性驊 64e67673c8 Pictures for models that can't see them; bots can read the chat's attachments
- 看圖小幫手: when a bot's model can't take pictures, a model of the
  person's that can (Grok, Claude, GPT, Gemini… whichever has a key)
  describes each picture the person sent, text and numbers included, and
  the bot gets that description. Remembered per picture; helpers that
  fail are skipped for ten minutes.
- On the Mac, commands still can't read AnyBot's data folder (the keys),
  but may read the chat's attachments in it (not write them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 21:52:06 +08:00
王性驊 dd27ed7c4a Signing up to an account that exists signs in; reset a password on the server
- Ticking 建立新帳號 for an account that already exists signs in with it;
  when that fails too, the message says the server has an account and
  how to reset a forgotten password.
- anybot-relay --data DIR --reset-password NAME (only while the server is
  stopped; asks twice, not shown; signs out that account's phones).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 21:34:15 +08:00
王性驊 365dc87e54 Add a computer with one line: pairing codes, simpler setups
- 加一台電腦 (phone menu and the app's 大腦 card) gives a ten-minute,
  one-time code and the line to run: anybot setup <address> <code>. The
  new computer joins as hands for that brain with no password and nothing
  to answer, starts with the computer and checks the brain sees it.
- anybot setup without arguments is one screen: address and code (a pasted
  line fills both), or the account and password instead. No role, name or
  confirm questions; a brain is asked only when there are several.
- Hands computers are no longer offered as brains.
- The server setup asks only the domain, account and password.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 20:56:06 +08:00
王性驊 cb922da3fe anybot setup and the server setup: guided setup with a brain picker
- anybot setup: a full-screen terminal guide (role, name, account server,
  account, which brain) that saves the settings, installs a systemd user
  service (with a virtual screen when there is none), and checks that the
  account server and the brain see the computer. Flags for scripts.
- Hands can be limited to one brain; the account server says which AnyBot
  asks. Choosing a brain in the app does the same.
- deploy/server/setup.sh: domain, account, sign-ups and keys step by step;
  writes .env and Caddyfile, starts everything and checks HTTPS. The
  server's brain creates the account on first start.
- deploy/linux/hands.sh installs what's needed and opens anybot setup
  (no password kept in a file any more).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 20:38:45 +08:00
王性驊 b153051e58 Lasting connection to the account server, per-item sync, files and models via the brain
- Each AnyBot keeps one WebSocket to the account server (pinged, reopened
  within a second when cut). Requests come down it at once; calls to other
  AnyBots of the account (brain sync, hands) go up it. Frames carry raw
  bytes, compressed when that saves space. HTTP long-poll stays as fallback.
- The account server knows at once when an AnyBot goes away.
- Edits to lists and maps go item by item, so two computers editing one
  list keep both changes.
- Screens fetch chat files from the brain, get model lists through the
  brain's keys, and show the brain's computer as the brain's.
- Moving this computer's bots skips starter bots the brain has and bots
  already moved.
- The phone goes to the brain instead of a computer that shows it.
- deploy/linux/hands.sh: a Linux computer (with or without a screen) as
  hands, kept running by systemd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 19:30:32 +08:00
王性驊 a70d29fa51 Brain sync: edits in order and kept while typing, plain-language offline messages
- One worker sends edits, messages and actions to the brain in order.
- Edits carry a number; the brain says which it has, and a screen replays
  the rest on top of newer settings so typing isn't undone.
- A message or edit that didn't arrive makes the screen fetch everything again.
- An older copy of a routine can't reset when it last/next runs.
- Network and server failures read as words (no HTTP codes), naming what
  can't be reached; the account server notices a closed AnyBot in 8s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 19:07:55 +08:00
王性驊 5841d797be One brain, many screens: other AnyBots show and edit the brain's bots
- Brain answers /api/sync (per-screen deltas of settings and messages),
  /api/sync/edit (small edit operations) and /api/sync/act (chat actions).
- Relay forwards host-to-host sync requests within one account (role mirror).
- Desktop: choose a brain in 手機與帳號; its own bots are kept aside; offline
  shows the last copy read-only and reconnects by itself.
- Keys and tokens stay on the brain (redacted and restored on edits).
- Browser tools can run on another computer of the account (computer arg).
- anybot --serve: ANYBOT_DEVICE_LINK=1 and ANYBOT_BRAIN=名稱.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 17:46:43 +08:00
46 changed files with 5967 additions and 97 deletions

View File

@ -0,0 +1,45 @@
# Push a version tag (git tag v1.2.0 && git push origin v1.2.0) and every build lands on that
# tag's release: macOS (the app), Ubuntu (with a window), Linux without a screen, and the server.
#
# Runners (see deploy/ci/README.md):
# anybot-linux a Linux computer with Docker; jobs run in anybot-builder:22.04
# macos-arm64 a Mac (runs directly on it)
name: Release
on:
push:
tags: ['v*']
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
jobs:
linux:
runs-on: anybot-linux
env:
# Kept between releases (a Docker volume on the runner): only what changed is compiled.
CARGO_TARGET_DIR: /ci-target
steps:
- uses: actions/checkout@v4
- name: Build
run: |
cargo build --release --bin anybot
cd relay && cargo build --release
- name: Test
run: cargo test --release --bin anybot -- --skip live 2>&1 | tail -3
- name: Package
run: sh deploy/ci/package-linux.sh "${GITHUB_REF_NAME#v}"
- name: Upload
run: sh deploy/ci/upload-release.sh "$GITHUB_REF_NAME" dist/*.tar.gz
macos:
runs-on: macos-arm64
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/../anybot-target
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --release --bin anybot
- name: Package
run: sh deploy/mac/bundle.sh "${GITHUB_REF_NAME#v}"
- name: Upload
run: sh deploy/ci/upload-release.sh "$GITHUB_REF_NAME" dist/*.zip

5
.gitignore vendored
View File

@ -8,7 +8,12 @@
# Local secrets (deploy/server/.env.example is the template)
.env
# Written by deploy/server/setup.sh
/deploy/server/Caddyfile
*.env.local
# macOS
.DS_Store
# Release packages (deploy/ci, deploy/mac/bundle.sh)
/dist/

132
Cargo.lock generated
View File

@ -343,8 +343,10 @@ dependencies = [
"chrono",
"chrono-tz",
"cpal",
"crossterm",
"dirs",
"eframe",
"flate2",
"futures-lite",
"hayro",
"iana-time-zone",
@ -356,15 +358,18 @@ dependencies = [
"resvg",
"ring",
"rodio",
"rustls",
"serde",
"serde_json",
"tungstenite",
"unicode-normalization",
"unicode-width",
"ureq",
"wayland-client",
"wayland-protocols",
"wayland-protocols-misc",
"wayland-protocols-wlr",
"webpki-roots",
"x11rb 0.13.2",
"zbus",
"zhconv",
@ -393,7 +398,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb 0.13.2",
]
@ -1029,6 +1034,15 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.18.2"
@ -1224,6 +1238,33 @@ version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crossterm"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags 2.13.2",
"crossterm_winapi",
"derive_more",
"document-features",
"mio",
"parking_lot",
"rustix 1.1.5",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "crunchy"
version = "0.2.4"
@ -1291,6 +1332,28 @@ version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.119",
]
[[package]]
name = "digest"
version = "0.10.7"
@ -1641,7 +1704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -2876,6 +2939,18 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
@ -3496,7 +3571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -4202,7 +4277,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -4389,6 +4464,27 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@ -4852,10 +4948,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -5901,15 +5997,37 @@ dependencies = [
"web-sys",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.62.2"

View File

@ -10,6 +10,13 @@ path = "src/main.rs"
[dependencies]
eframe = "0.37"
ureq = { version = "3", features = ["json"] }
# 長連線 to the account server (WebSocket over the same TLS ureq uses), and compressing what goes over it.
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
webpki-roots = "1"
flate2 = "1"
# `anybot setup`: the guided setup in a terminal (servers and Linux computers without a window).
crossterm = "0.29"
unicode-width = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "7"

View File

@ -2074,10 +2074,17 @@ computer_use_screenshot、click、mouse_move、mouse_button(按住/放開)
| 邊說邊插話的即時語音 | 原版用 xAI 即時語音服務;AnyBot 的通話模式是輪流說話 |
| 帳單、方案 | 不需要:AnyBot 用你自己的 AI 金鑰 |
| 代你購物與付款 | 刻意不做:會動到你的錢和卡片 |
| 即時同步(同一個帳號的兩台電腦同時改同一個 Bot) | 帳號只做每日加密備份和還原;要 24 小時運作,請把 AnyBot 放在一直開著的主機(第 36 節) |
| 原生 iPhone/Android App | 用手機網頁版,可加到主畫面、收推播通知 |
## 多台電腦:大腦、手腳、螢幕(2026-11-04)
- 帳號裡一台 AnyBot 當大腦(Bot、聊天、記憶、排程都在它上面),其他有視窗的電腦當螢幕(看到、改的都是大腦的 Bot),沒有螢幕的電腦當手腳;同一個帳號的電腦之間即時同步。
- 每台 AnyBot 和帳號伺服器之間保持一條長連線(WebSocket),斷了自動接回;連不到時畫面說明是哪一段。
- 「加一台電腦」給一行 `anybot setup 網址 配對碼`,新電腦不用密碼就接成那個大腦的手腳。
- 看不到圖片的模型:你帳號裡看得到圖的模型會先描述圖片(看圖小幫手)。
- 詳細說明:docs/multi-device.md;技術細節:docs/architecture.md、docs/protocol.md。
## 側邊欄:釘選與分區(照原版重做,2026-11-04)
- 釘選的 Bot 放在最上面的大頭像格子裡(主要 Bot 第一個,有星星)。右鍵「釘選/取消釘選」,或把 Bot 拖進格子(第一次拖時出現「拖曳到此處以釘選」);格子內可拖曳排序;拖到分區會取消釘選並移過去。

109
Makefile Normal file
View File

@ -0,0 +1,109 @@
# AnyBot — build and install from source. make help lists everything.
#
# git clone git@git.30cm.net:daniel.w/AnyBot.git && cd AnyBot
# make build (release)
# make install install for you: ~/Applications/AnyBot.app on a Mac, ~/.local/bin on Linux
# make run open it without installing
CARGO ?= cargo
UNAME := $(shell uname -s)
VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed 's/^v//' || echo dev)
TARGET := $(or $(CARGO_TARGET_DIR),target)
APPDIR ?= $(HOME)/Applications
BIN := $(TARGET)/release/anybot
RELAY := $(if $(CARGO_TARGET_DIR),$(CARGO_TARGET_DIR),relay/target)/release/anybot-relay
.PHONY: all build app relay run serve setup relay-run test deps install uninstall hands server dist clean help
all: build
help:
@echo "AnyBot $(VERSION)"
@echo ""
@echo " make 建置 AnyBot 和帳號伺服器(正式版)"
@echo " make run 直接打開 AnyBot(不安裝)"
@echo " make install 安裝:Mac → $(APPDIR)/AnyBot.app;Linux → ~/.local/bin/anybot 和選單"
@echo " make uninstall 移除安裝的程式(你的 Bot 和聊天不會刪)"
@echo " make deps Linux:安裝系統套件(會問 sudo 密碼)"
@echo " make test 跑測試"
@echo ""
@echo " make serve 不開視窗執行(伺服器、一直開著的電腦)"
@echo " make setup 把這台接到你的 AnyBot(終端機裡的引導畫面)"
@echo " make hands Linux:裝好虛擬螢幕等,再接成大腦的手腳"
@echo " make relay-run 在這台跑帳號伺服器(埠 8080,資料在 ./relay-data)"
@echo " make server 在伺服器上架好帳號伺服器 + 大腦 + HTTPS(Docker)"
@echo ""
@echo " make dist 打包發行檔到 dist/(Mac:.app 的 zip;Linux:三個 tar.gz)"
@echo " make clean 清掉建置結果"
build: app relay
app:
$(CARGO) build --release --bin anybot
relay:
cd relay && $(CARGO) build --release
run: app
$(BIN)
serve: app
$(BIN) --serve
setup: app
$(BIN) setup
relay-run: relay
$(RELAY) --port 8080 --data relay-data
test:
$(CARGO) test --bin anybot
cd relay && $(CARGO) test
deps:
ifeq ($(UNAME),Linux)
sh deploy/linux/install.sh --deps-only
else ifeq ($(UNAME),Darwin)
@xcode-select -p >/dev/null 2>&1 || xcode-select --install
@command -v cargo >/dev/null || echo "需要 Rust:curl --proto '=https' -sSf https://sh.rustup.rs | sh"
else
@echo "Windows:請照 docs/install.md 安裝 Rust 和 Visual Studio Build Tools"
endif
install: app
ifeq ($(UNAME),Darwin)
KEEP_APP=1 sh deploy/mac/bundle.sh $(VERSION)
mkdir -p "$(APPDIR)"
rm -rf "$(APPDIR)/AnyBot.app"
mv dist/AnyBot.app "$(APPDIR)/AnyBot.app"
@echo "已安裝 $(APPDIR)/AnyBot.app(第一次打開:按右鍵 › 打開)"
else
sh deploy/linux/install.sh
endif
uninstall:
ifeq ($(UNAME),Darwin)
rm -rf "$(APPDIR)/AnyBot.app"
else
rm -f "$(HOME)/.local/bin/anybot" "$(HOME)/.local/share/applications/anybot.desktop"
rm -f "$(HOME)/.local/share/icons/hicolor/scalable/apps/anybot.svg" "$(HOME)/.local/share/icons/hicolor/256x256/apps/anybot.png" "$(HOME)/.local/share/icons/hicolor/128x128/apps/anybot.png"
endif
@echo "已移除程式。你的 Bot、聊天和設定還在(要全部刪掉請看 docs/install.md)。"
hands: app
sh deploy/linux/hands.sh --deps
server:
cd deploy/server && ./setup.sh
dist: build
ifeq ($(UNAME),Darwin)
sh deploy/mac/bundle.sh $(VERSION)
else
sh deploy/ci/package-linux.sh $(VERSION)
endif
clean:
$(CARGO) clean
cd relay && $(CARGO) clean
rm -rf dist

74
README.md Normal file
View File

@ -0,0 +1,74 @@
# AnyBot
你自己的 AI Bot:可以聊天,也能在它自己的電腦和你的電腦上幫你做事(檔案、指令、瀏覽器、螢幕操作、例行任務)。每個 Bot 可以用不同的 AI 服務:xAI Grok、OpenAI、Claude、Gemini、OpenRouter、OpenCode Go、Ollama(本機),或任何 OpenAI 相容的服務。
- 桌面 App:macOS、Linux(Windows 可以編譯,還沒有安裝包)
- 手機:網頁版,可加到主畫面、收推播通知
- 多台電腦:一台當**大腦**(保存 Bot、聊天、記憶和排程),其他電腦當**手腳**或**螢幕**
- 用 Rust 和 egui 寫成,一個執行檔就是全部(視窗、無視窗伺服器模式、設定工具)
## 從原始碼安裝
```sh
git clone git@git.30cm.net:daniel.w/AnyBot.git
cd AnyBot
make deps # 只有 Linux 需要:安裝系統套件(會問 sudo 密碼)
make # 建置
make install # Mac → ~/Applications/AnyBot.app;Linux → ~/.local/bin/anybot 和選單
```
需要 Rust(`curl --proto '=https' -sSf https://sh.rustup.rs | sh`)。Mac 另外需要 Xcode Command Line Tools(`make deps` 會提示)。詳細步驟、各平台需求和常見問題:[docs/install.md](docs/install.md)。
常用指令(`make help` 列出全部):
| 指令 | 做什麼 |
|---|---|
| `make run` | 不安裝,直接打開 |
| `make test` | 跑測試 |
| `make serve` | 不開視窗執行(伺服器、一直開著的電腦) |
| `make setup` | 把這台接到你的 AnyBot(終端機裡的引導畫面) |
| `make server` | 在伺服器上架好帳號伺服器、大腦和 HTTPS(Docker) |
| `make dist` | 打包發行檔到 `dist/` |
| `make uninstall` | 移除程式(Bot 和聊天不會刪) |
## 多台電腦一起用
```
手機(網頁) 筆電(螢幕) 沒螢幕的 Linux(手腳)
\ | /
└──── 帳號伺服器(一條一直開著的連線)────┘
|
大腦:Bot、聊天、記憶、排程
```
1. **架帳號伺服器和大腦**:在伺服器上執行 `make server`(只問網域、帳號、密碼);或先用一台 Mac 當大腦([docs/multi-device.md](docs/multi-device.md))。
2. **其他電腦當螢幕**:打開 AnyBot › 設定 › 手機與帳號,登入同一個帳號,在「大腦」選它。
3. **加一台手腳**:手機 AnyBot 右上「⋯ › 加一台電腦」,把出現的那一行貼到那台的終端機:
```sh
anybot setup https://anybot.example.com 469615
```
不用帳號密碼,會設成開機自動執行;沒有螢幕就開一個虛擬螢幕。
## 文件
| 文件 | 內容 |
|---|---|
| [docs/install.md](docs/install.md) | 從原始碼建置和安裝(macOS、Linux、Windows)、更新、移除、常見問題 |
| [docs/multi-device.md](docs/multi-device.md) | 大腦、手腳、螢幕:怎麼架、怎麼接、連不上時看什麼 |
| [docs/architecture.md](docs/architecture.md) | 技術架構:程式分層、資料存放、安全設計、同步怎麼運作 |
| [docs/protocol.md](docs/protocol.md) | 帳號伺服器和 AnyBot 之間的介面與長連線格式 |
| [docs/release.md](docs/release.md) | 發行:打包、Gitea Actions 自動建置、runner 設定 |
| [FEATURES.md](FEATURES.md) | 所有功能的完整說明 |
| [relay/README.md](relay/README.md) | 帳號伺服器的架設細節 |
## 專案結構
```
src/ AnyBot(桌面 App、無視窗模式、anybot setup)
app/ 視窗介面(聊天、設定、電腦、大腦同步的畫面)
relay/ 帳號伺服器(獨立的小程式)
mobile/ 手機網頁(由 AnyBot 和帳號伺服器提供)
box/ Bot 自己的電腦(Docker 容器)
deploy/ 安裝與部署:mac/、linux/、server/、ci/
docs/ 技術文件
```

View File

@ -0,0 +1,12 @@
# The Linux build environment for the release workflow (.gitea/workflows/release.yml).
# Ubuntu 22.04 (glibc 2.35), so the binaries also run on newer Ubuntu, Debian 12 and the like.
# Built once on the runner's computer: docker build -t anybot-builder:22.04 -f deploy/ci/Dockerfile.linux deploy/ci
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git build-essential pkg-config cmake clang libclang-dev python3 nodejs xz-utils zip \
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxtst-dev libdbus-1-dev \
libxcb1-dev libxcb-randr0-dev libxcb-shm0-dev libxdo-dev libpipewire-0.3-dev libgbm-dev \
&& rm -rf /var/lib/apt/lists/*
ENV RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo PATH=/usr/local/cargo/bin:$PATH
RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.1 && chmod -R a+w $RUSTUP_HOME $CARGO_HOME

View File

@ -0,0 +1,8 @@
# AnyBot (the brain, no window) from the release's binary.
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git ripgrep poppler-utils docker.io openssh-client libasound2 \
&& rm -rf /var/lib/apt/lists/*
COPY bin/anybot /usr/local/bin/anybot
ENV ANYBOT_DATA_DIR=/data
VOLUME /data
CMD ["anybot", "--serve"]

View File

@ -0,0 +1,8 @@
# The account server from the release's binary (no compiling on the server).
FROM debian:bookworm-slim
RUN useradd -m relay && mkdir /data && chown relay /data
COPY bin/anybot-relay /usr/local/bin/anybot-relay
USER relay
VOLUME /data
EXPOSE 8080
CMD ["anybot-relay", "--port", "8080", "--data", "/data"]

23
deploy/ci/linux-install.sh Executable file
View File

@ -0,0 +1,23 @@
#!/bin/sh
# Install AnyBot from this package for the current user (no root needed).
# ./install.sh the app (menu entry and icon too when this computer has a desktop)
# Then: open AnyBot from the menu, or on a computer without a screen: anybot setup
set -e
cd "$(dirname "$0")"
BIN="$HOME/.local/bin"
mkdir -p "$BIN"
install -m 755 anybot "$BIN/anybot"
if [ -f anybot.desktop ]; then
APPS="$HOME/.local/share/applications"; ICONS="$HOME/.local/share/icons/hicolor"
mkdir -p "$APPS" "$ICONS/scalable/apps" "$ICONS/256x256/apps"
install -m 644 anybot.desktop "$APPS/anybot.desktop"
install -m 644 anybot.svg "$ICONS/scalable/apps/anybot.svg"
"$BIN/anybot" --write-icon "$ICONS/256x256/apps/anybot.png" 256 2>/dev/null || true
command -v update-desktop-database >/dev/null && update-desktop-database "$APPS" 2>/dev/null || true
fi
echo "AnyBot 已安裝在 $BIN/anybot"
case ":$PATH:" in *":$BIN:"*) ;; *) echo "(把 $BIN 加到 PATH,才能直接打 anybot)" ;; esac
if ! ldconfig -p 2>/dev/null | grep -q libasound.so.2; then
echo "還需要 libasound2:sudo apt-get install -y libasound2(或 libasound2t64)"
fi
echo "接到你的大腦:在手機或電腦的 AnyBot 按「加一台電腦」,把那一行貼到這裡執行。"

44
deploy/ci/package-linux.sh Executable file
View File

@ -0,0 +1,44 @@
#!/bin/sh
# Package the Linux builds for a release (run from app/ after the release builds):
# dist/AnyBot-<version>-ubuntu-x86_64.tar.gz the app with its window (menu entry, icon)
# dist/AnyBot-<version>-headless-x86_64.tar.gz for a computer without a screen (hands, or a brain)
# dist/AnyBot-<version>-server-x86_64.tar.gz account server + brain on one server (Docker)
set -e
VERSION=${1:?version}
ARCH=$(uname -m)
OUT=dist
rm -rf "$OUT" && mkdir -p "$OUT"
BIN=${CARGO_TARGET_DIR:-target}/release/anybot
RELAY=${CARGO_TARGET_DIR:-relay/target}/release/anybot-relay
strip "$BIN" "$RELAY" 2>/dev/null || true
# Ubuntu (with a window)
D="$OUT/AnyBot-$VERSION-ubuntu-$ARCH"
mkdir -p "$D"
cp "$BIN" deploy/linux/anybot.desktop icons/anybot.svg "$D/"
cp deploy/ci/linux-install.sh "$D/install.sh"
printf 'AnyBot %s(Ubuntu 22.04 以後、Debian 12 以後)\n\n ./install.sh 安裝,之後從選單打開 AnyBot\n' "$VERSION" > "$D/讀我.txt"
tar -C "$OUT" -czf "$D.tar.gz" "$(basename "$D")"
# Without a screen (hands, or a brain)
D="$OUT/AnyBot-$VERSION-headless-$ARCH"
mkdir -p "$D"
cp "$BIN" "$D/"
cp deploy/ci/linux-install.sh "$D/install.sh"
cp deploy/linux/hands.sh "$D/hands.sh"
printf 'AnyBot %s:沒有螢幕的 Linux\n\n ./install.sh\n 然後在手機或電腦的 AnyBot 按「加一台電腦」,把那一行貼到這台執行。\n 想讓 Bot 看這台的畫面(虛擬螢幕):先執行 ./hands.sh --deps\n' "$VERSION" > "$D/讀我.txt"
tar -C "$OUT" -czf "$D.tar.gz" "$(basename "$D")"
# Server: account server + brain, from these binaries (no compiling on the server)
D="$OUT/AnyBot-$VERSION-server-$ARCH"
mkdir -p "$D/bin" "$D/deploy/server" "$D/relay"
cp "$BIN" "$RELAY" "$D/bin/"
cp deploy/server/setup.sh deploy/server/.env.example "$D/deploy/server/"
cp deploy/ci/server-compose.yml "$D/deploy/server/docker-compose.yml"
cp deploy/ci/Dockerfile.server-relay deploy/ci/Dockerfile.server-anybot "$D/deploy/server/"
cp relay/Caddyfile "$D/relay/Caddyfile"
printf 'AnyBot %s 伺服器(帳號伺服器 + 大腦 + HTTPS,用 Docker)\n\n cd deploy/server && ./setup.sh\n' "$VERSION" > "$D/讀我.txt"
tar -C "$OUT" -czf "$D.tar.gz" "$(basename "$D")"
find "$OUT" -maxdepth 1 -type d -name 'AnyBot-*' -exec rm -rf {} +
ls -la "$OUT"

View File

@ -0,0 +1,19 @@
# gitea-runner settings for the Linux build computer (~/.config/gitea-runner/config.yaml).
log:
level: info
runner:
file: /home/daniel/.config/gitea-runner/.runner
capacity: 1
timeout: 3h
labels:
- "anybot-linux:docker://anybot-builder:22.04"
cache:
enabled: true
container:
# anybot-builder is built on this computer, not pulled.
force_pull: false
# Downloads and compiled pieces kept between releases (much faster second builds).
valid_volumes:
- anybot-cargo-registry
- anybot-ci-target
options: "-v anybot-cargo-registry:/usr/local/cargo/registry -v anybot-ci-target:/ci-target"

View File

@ -0,0 +1,63 @@
# The server from a release package (prebuilt binaries in ../../bin). Made from deploy/server/docker-compose.yml.
# Everything on one small server: the account server (relay), HTTPS (Caddy), and AnyBot itself.
# Easiest: ./setup.sh (asks for the domain, account and keys, writes .env and Caddyfile, starts
# everything and checks it). By hand:
# 1. Point your domain at the server; copy ../../relay/Caddyfile here and put the domain in it.
# 2. Put your account name and password in .env (see .env.example).
# 3. docker compose up -d
# 4. On the phone open https://your-domain, create the account (first time), and use it.
# AI keys: set them in .env, or set ANYBOT_RESTORE_FROM_ACCOUNT=1 to start from the newest backup
# your Mac made (bots, chats, routines and the keys you set there).
#
# 大腦: this AnyBot is the brain of the account. It keeps the bots, chats, memory and routines,
# and works even while your computers sleep. On each of your computers: 設定 › 手機與帳號 ›
# 大腦 › 「改用它當大腦」. Those computers then show these bots, and the bots can use them as hands
# (screen, files, browser), asking you first as usual. A Linux computer without a screen can be
# hands too: run `anybot setup` there and pick this brain.
services:
relay:
build:
context: ../..
dockerfile: deploy/server/Dockerfile.server-relay
restart: unless-stopped
environment:
# Your public address (for the links of sites the bots publish), e.g. https://anybot.example.com
ANYBOT_RELAY_PUBLIC_URL: "${ANYBOT_RELAY_PUBLIC_URL:-}"
ANYBOT_RELAY_INVITE: "${ANYBOT_RELAY_INVITE:-}"
volumes:
- relay-data:/data
caddy:
image: caddy:2
restart: unless-stopped
ports: ["80:80", "443:443"]
volumes:
# Written by setup.sh (your domain); ../../relay/Caddyfile is the template.
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
depends_on: [relay]
anybot:
build:
context: ../..
dockerfile: deploy/server/Dockerfile.server-anybot
restart: unless-stopped
depends_on: [relay]
environment:
ANYBOT_ACCOUNT: "http://relay:8080|${ANYBOT_USER}"
ANYBOT_ACCOUNT_PASSWORD: "${ANYBOT_PASSWORD}"
ANYBOT_HOST_NAME: "${ANYBOT_HOST_NAME:-雲端 AnyBot}"
# First start on a new server: make the account (the first one needs no invite).
ANYBOT_ACCOUNT_CREATE: "1"
ANYBOT_RELAY_INVITE: "${ANYBOT_RELAY_INVITE:-}"
XAI_API_KEY: "${XAI_API_KEY:-}"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
GEMINI_API_KEY: "${GEMINI_API_KEY:-}"
ANYBOT_RESTORE_FROM_ACCOUNT: "${ANYBOT_RESTORE_FROM_ACCOUNT:-}"
volumes:
- anybot-data:/data
# The bot's own computer runs as a container next to this one.
- /var/run/docker.sock:/var/run/docker.sock
volumes:
relay-data:
caddy-data:
anybot-data:

25
deploy/ci/upload-release.sh Executable file
View File

@ -0,0 +1,25 @@
#!/bin/sh
# Attach files to this tag's release on Gitea (made if it isn't there yet).
# deploy/ci/upload-release.sh <tag> <file>…
# Uses GITHUB_SERVER_URL, GITHUB_REPOSITORY and RELEASE_TOKEN (set by the workflow).
set -e
TAG=${1:?tag}; shift
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
AUTH="Authorization: token $RELEASE_TOKEN"
json() { python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('$1','') if isinstance(d,dict) else '')"; }
ID=$(curl -fsS -H "$AUTH" "$API/releases/tags/$TAG" 2>/dev/null | json id || true)
if [ -z "$ID" ]; then
# Two jobs may get here at once: whoever is second finds the first one's release.
ID=$(curl -fsS -H "$AUTH" -H "Content-Type: application/json" -X POST "$API/releases" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"AnyBot $TAG\",\"body\":\"自動建置:macOS、Ubuntu、沒有螢幕的 Linux、伺服器。\"}" | json id) \
|| ID=$(curl -fsS -H "$AUTH" "$API/releases/tags/$TAG" | json id)
fi
[ -n "$ID" ] || { echo "找不到也建不了 $TAG 的 release" >&2; exit 1; }
for f in "$@"; do
name=$(basename "$f")
# Built again: replace the old file.
old=$(curl -fsS -H "$AUTH" "$API/releases/$ID/assets" | python3 -c "import json,sys; print(next((str(a['id']) for a in json.load(sys.stdin) if a['name']=='$name'),''))")
[ -n "$old" ] && curl -fsS -H "$AUTH" -X DELETE "$API/releases/$ID/assets/$old" >/dev/null
curl -fsS -H "$AUTH" -F "attachment=@$f" "$API/releases/$ID/assets?name=$name" >/dev/null
echo "已上傳 $name"
done

38
deploy/linux/hands.sh Executable file
View File

@ -0,0 +1,38 @@
#!/bin/sh
# Make this Linux computer a pair of hands for your AnyBot brain (or a brain itself): installs what
# it needs, then opens the guided setup (`anybot setup`), where you pick the brain from your
# account's AnyBots. Works on a desktop and on a computer without a screen. Run from app/:
#
# ./deploy/linux/hands.sh --deps also install what a computer without a screen needs (sudo)
# ./deploy/linux/hands.sh install AnyBot if needed, then the setup
# ./deploy/linux/hands.sh --stop stop AnyBot and don't start it with the computer any more
set -e
cd "$(dirname "$0")/../.."
if [ "$1" = "--stop" ]; then
for u in anybot.service anybot-hands.service; do
systemctl --user disable --now "$u" 2>/dev/null || true
rm -f "$HOME/.config/systemd/user/$u"
done
systemctl --user daemon-reload 2>/dev/null || true
echo "Stopped. Run ./deploy/linux/hands.sh (or anybot setup) to set it up again."
exit 0
fi
if [ "$1" = "--deps" ]; then
# A virtual screen, a window manager, the accessibility bus (for reading apps' buttons), a session bus.
if command -v apt-get >/dev/null; then
sudo apt-get update && sudo apt-get install -y xvfb openbox at-spi2-core libatk-adaptor dbus-x11 dbus-user-session xauth fonts-noto-cjk
elif command -v dnf >/dev/null; then
sudo dnf install -y xorg-x11-server-Xvfb openbox at-spi2-core at-spi2-atk dbus-x11 xorg-x11-xauth google-noto-sans-cjk-fonts
elif command -v pacman >/dev/null; then
sudo pacman -S --needed xorg-server-xvfb openbox at-spi2-core dbus xorg-xauth noto-fonts-cjk
else
echo "Install with your package manager: Xvfb, openbox, at-spi2-core, dbus, xauth"
fi
fi
[ -x "$HOME/.local/bin/anybot" ] || sh deploy/linux/install.sh
# Older versions of this script kept the account in a file; the setup keeps only a key.
rm -f "$HOME/.config/anybot/hands.env" "$HOME/.local/bin/anybot-hands"
exec "$HOME/.local/bin/anybot" setup

View File

@ -3,6 +3,7 @@
#
# ./deploy/linux/install.sh build from source, then install
# ./deploy/linux/install.sh --deps also install the system packages (asks for sudo)
# ./deploy/linux/install.sh --deps-only only the system packages (make deps)
#
# Puts the app in ~/.local/bin/anybot, the menu entry in ~/.local/share/applications and the
# icon in ~/.local/share/icons. Run it from the app/ folder.
@ -21,7 +22,7 @@ DEPS_FEDORA="alsa-lib libxkbcommon-x11 mesa-libGL xdg-utils libnotify libsecret
DEPS_ARCH="alsa-lib libxkbcommon-x11 mesa xdg-utils libnotify libsecret espeak-ng zenity xclip wl-clipboard ffmpeg docker noto-fonts-cjk"
BUILD_DEBIAN="build-essential pkg-config libasound2-dev"
if [ "$1" = "--deps" ]; then
if [ "$1" = "--deps" ] || [ "$1" = "--deps-only" ]; then
if command -v apt-get >/dev/null; then
sudo apt-get update && sudo apt-get install -y $DEPS_DEBIAN $BUILD_DEBIAN
elif command -v dnf >/dev/null; then
@ -37,6 +38,8 @@ if [ "$1" = "--deps" ]; then
fi
fi
[ "$1" = "--deps-only" ] && exit 0
OUT="${CARGO_TARGET_DIR:-target}/release/anybot"
if [ -x "$OUT" ] && [ "$1" != "--rebuild" ]; then
echo "Using the existing build ($OUT)."
@ -58,6 +61,7 @@ command -v update-desktop-database >/dev/null && update-desktop-database "$APPS"
command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -q "$ICONS" 2>/dev/null || true
echo "AnyBot is installed: find it in your app menu, or run: anybot"
echo "To make this computer a brain, or hands for your brain (also without a screen): anybot setup"
case ":$PATH:" in *":$BIN:"*) ;; *) echo "(Add $BIN to your PATH to start it from a terminal.)" ;; esac
if [ "${XDG_SESSION_TYPE:-}" = "wayland" ]; then
echo "Wayland: bots can see and use your screen. On GNOME/KDE the first time, a system window asks:"

48
deploy/mac/bundle.sh Executable file
View File

@ -0,0 +1,48 @@
#!/bin/sh
# Make AnyBot.app from the release build and zip it (run from app/ on a Mac, after
# `cargo build --release`): deploy/mac/bundle.sh 1.2.0 → dist/AnyBot-1.2.0-macos-<arch>.zip
# With KEEP_APP=1 it leaves dist/AnyBot.app instead of zipping it (make install).
# Signed ad hoc (no Apple developer account): the first time, right-click › 打開.
set -e
VERSION=${1:?version}
BIN=${CARGO_TARGET_DIR:-target}/release/anybot
ARCH=$(uname -m)
OUT=dist
APP="$OUT/AnyBot.app"
rm -rf "$APP" && mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "$BIN" "$APP/Contents/MacOS/AnyBot"
# The icon: every size the system asks for.
SET=$(mktemp -d)/AnyBot.iconset
mkdir -p "$SET"
for s in 16 32 128 256 512; do
"$BIN" --write-icon "$SET/icon_${s}x${s}.png" $s
"$BIN" --write-icon "$SET/icon_${s}x${s}@2x.png" $((s * 2))
done
iconutil -c icns "$SET" -o "$APP/Contents/Resources/AnyBot.icns"
cat > "$APP/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleName</key><string>AnyBot</string>
<key>CFBundleDisplayName</key><string>AnyBot</string>
<key>CFBundleIdentifier</key><string>net.30cm.anybot</string>
<key>CFBundleExecutable</key><string>AnyBot</string>
<key>CFBundleIconFile</key><string>AnyBot</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>$VERSION</string>
<key>CFBundleVersion</key><string>$VERSION</string>
<key>LSMinimumSystemVersion</key><string>12.0</string>
<key>NSHighResolutionCapable</key><true/>
<key>NSMicrophoneUsageDescription</key><string>語音聊天時,聽你說話。</string>
<key>NSAppleEventsUsageDescription</key><string>讓 Bot 在你允許時操作其他 App。</string>
</dict></plist>
PLIST
codesign --force --deep -s - "$APP"
if [ "${KEEP_APP:-}" = 1 ]; then
exit 0
fi
ditto -c -k --norsrc --keepParent "$APP" "$OUT/AnyBot-$VERSION-macos-$ARCH.zip"
rm -rf "$APP"
ls -la "$OUT"

View File

@ -1,10 +1,18 @@
# Everything on one small server: the account server (relay), HTTPS (Caddy), and AnyBot itself.
# 1. Point your domain at the server and put it in ../../relay/Caddyfile.
# Easiest: ./setup.sh (asks for the domain, account and keys, writes .env and Caddyfile, starts
# everything and checks it). By hand:
# 1. Point your domain at the server; copy ../../relay/Caddyfile here and put the domain in it.
# 2. Put your account name and password in .env (see .env.example).
# 3. docker compose up -d
# 4. On the phone open https://your-domain, create the account (first time), and use it.
# AI keys: set them in .env, or set ANYBOT_RESTORE_FROM_ACCOUNT=1 to start from the newest backup
# your Mac made (bots, chats, routines and the keys you set there).
#
# 大腦: this AnyBot is the brain of the account. It keeps the bots, chats, memory and routines,
# and works even while your computers sleep. On each of your computers: 設定 › 手機與帳號 ›
# 大腦 › 「改用它當大腦」. Those computers then show these bots, and the bots can use them as hands
# (screen, files, browser), asking you first as usual. A Linux computer without a screen can be
# hands too: run `anybot setup` there and pick this brain.
services:
relay:
build:
@ -22,7 +30,8 @@ services:
restart: unless-stopped
ports: ["80:80", "443:443"]
volumes:
- ../../relay/Caddyfile:/etc/caddy/Caddyfile:ro
# Written by setup.sh (your domain); ../../relay/Caddyfile is the template.
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
depends_on: [relay]
anybot:
@ -35,6 +44,9 @@ services:
ANYBOT_ACCOUNT: "http://relay:8080|${ANYBOT_USER}"
ANYBOT_ACCOUNT_PASSWORD: "${ANYBOT_PASSWORD}"
ANYBOT_HOST_NAME: "${ANYBOT_HOST_NAME:-雲端 AnyBot}"
# First start on a new server: make the account (the first one needs no invite).
ANYBOT_ACCOUNT_CREATE: "1"
ANYBOT_RELAY_INVITE: "${ANYBOT_RELAY_INVITE:-}"
XAI_API_KEY: "${XAI_API_KEY:-}"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"

255
deploy/server/setup.sh Executable file
View File

@ -0,0 +1,255 @@
#!/usr/bin/env bash
# AnyBot 伺服器設定: the account server, HTTPS and the brain on this server, step by step.
#
# cd app/deploy/server && ./setup.sh ask, write .env and Caddyfile, start, check
# ./setup.sh --dry-run ask and write the files, but don't start anything
#
# Run it again any time: it offers to keep the current settings (to update and restart).
set -u
cd "$(dirname "$0")"
DRY=0
[ "${1:-}" = "--dry-run" ] && DRY=1
# ---------- looks ----------
if [ -t 1 ]; then
B=$'\e[1m'; D=$'\e[90m'; A=$'\e[36m'; G=$'\e[32m'; Y=$'\e[33m'; R=$'\e[31m'; N=$'\e[0m'
else
B=""; D=""; A=""; G=""; Y=""; R=""; N=""
fi
STEPS=("檢查" "網址" "帳號" "啟動")
AT=0
header() {
printf '\e[2J\e[H\n'
printf ' %s◆%s %sAnyBot 伺服器設定%s ' "$A" "$N" "$B" "$N"
local i
for i in "${!STEPS[@]}"; do
[ "$i" -gt 0 ] && printf '%s ─ %s' "$D" "$N"
if [ "$i" -lt "$AT" ]; then printf '%s✓ %s%s' "$G" "${STEPS[$i]}" "$N"
elif [ "$i" -eq "$AT" ]; then printf '%s%s%d %s%s' "$A" "$B" $((i + 1)) "${STEPS[$i]}" "$N"
else printf '%s%d %s%s' "$D" $((i + 1)) "${STEPS[$i]}" "$N"; fi
done
printf '\n\n'
}
# A card: title, then lines (each printed after the left edge).
card_open() { printf ' %s╭─%s %s%s%s\n %s│%s\n' "$D" "$N" "$B" "$1" "$N" "$D" "$N"; }
line() { printf ' %s│%s %s\n' "$D" "$N" "$*"; }
note() { printf ' %s│%s %s%s%s\n' "$D" "$N" "$D" "$*" "$N"; }
ok() { printf ' %s│%s %s✓%s %s\n' "$D" "$N" "$G" "$N" "$*"; }
warn() { WARNED=1; printf ' %s│%s %s!%s %s\n' "$D" "$N" "$Y" "$N" "$*"; }
bad() { printf ' %s│%s %s✗ %s%s\n' "$D" "$N" "$R" "$*" "$N"; }
card_close() { printf ' %s│%s\n %s╰────────────────────────────────────────────────%s\n' "$D" "$N" "$D" "$N"; [ -n "${1:-}" ] && printf ' %s%s%s\n' "$D" "$1" "$N"; }
# Pad a label to 10 columns (Chinese characters are two columns wide).
padl() {
local w
w=$(printf '%s' "$1" | LC_ALL=C.UTF-8 wc -L 2>/dev/null || printf '%s' "$1" | wc -m)
printf '%s%*s' "$1" $((10 - w > 1 ? 10 - w : 1)) ""
}
# ask VAR "標籤" [default] [secret]: once answered, the line is redrawn without the marker.
ask() {
local __v=$1 label=$2 def=${3:-} secret=${4:-} got shown
printf ' %s│%s %s❯%s %s%s%s' "$D" "$N" "$A" "$N" "$A" "$(padl "$label")" "$N"
if [ -n "$secret" ]; then
IFS= read -rs got; printf '\n'
shown=$(printf '%s' "$got" | sed 's/./●/g')
else
IFS= read -r -e -i "$def" got || true
shown=$got
fi
printf '\e[1A\r\e[K %s│%s %s%s%s%s\n' "$D" "$N" "$D" "$(padl "$label")" "$N" "$shown"
printf -v "$__v" '%s' "$got"
}
# choose VAR "問題" "選項|說明" ... (↑↓ and Enter)
choose() {
local __v=$1 title=$2; shift 2
local opts=("$@") sel=0 n=$# key rest i
while :; do
header
card_open "$title"
for i in "${!opts[@]}"; do
local name=${opts[$i]%%|*} desc=${opts[$i]#*|}
if [ "$i" -eq "$sel" ]; then line "$A❯ ● $B$name$N"; else line " $D○$N $name"; fi
[ "$desc" != "${opts[$i]}" ] && [ -n "$desc" ] && note " $desc"
line ""
done
card_close "↑↓ 選擇 Enter 確定"
IFS= read -rsn1 key
if [ "$key" = $'\e' ]; then
IFS= read -rsn2 -t 0.05 rest || true
case "$rest" in "[A") sel=$(((sel + n - 1) % n)) ;; "[B") sel=$(((sel + 1) % n)) ;; esac
elif [ "$key" = "k" ]; then sel=$(((sel + n - 1) % n))
elif [ "$key" = "j" ]; then sel=$(((sel + 1) % n))
elif [ -z "$key" ]; then break
fi
done
printf -v "$__v" '%s' "$sel"
}
spin() { # spin "做什麼" command… : runs it, shows a spinner and the newest log line
local what=$1; shift
local log; log=$(mktemp)
"$@" >"$log" 2>&1 &
local pid=$! f=0 c=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
while kill -0 "$pid" 2>/dev/null; do
local last; last=$(tail -n 1 "$log" | tr -d '\r' | cut -c1-60)
printf '\r\e[K %s│%s %s%s%s %s %s%s%s' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N" "$what" "$D" "$last" "$N"
f=$((f + 1)); sleep 0.15
done
wait "$pid"; local rc=$?
printf '\r\e[K'
if [ $rc -eq 0 ]; then ok "$what"; else bad "$what 失敗:"; tail -n 8 "$log" | while IFS= read -r l; do note " $l"; done; fi
rm -f "$log"
return $rc
}
pause() { printf ' %s%s%s' "$D" "${1:-按 Enter 繼續}" "$N"; IFS= read -r _; }
die() { card_close; printf '\n'; exit 1; }
# ---------- 0. this server ----------
AT=0; header; card_open "檢查這台伺服器"
FAIL=0; WARNED=0
if command -v docker >/dev/null; then ok "Docker 已安裝"; else bad "沒有 Docker:curl -fsSL https://get.docker.com | sh"; FAIL=1; fi
if docker compose version >/dev/null 2>&1; then ok "docker compose 可以用"; elif [ $FAIL = 0 ]; then bad "沒有 docker compose(Docker 太舊?請更新 Docker)"; FAIL=1; fi
if [ $FAIL = 0 ] && ! docker info >/dev/null 2>&1; then bad "沒有權限使用 Docker:sudo usermod -aG docker \$USER,登出再登入"; FAIL=1; fi
if command -v ss >/dev/null; then
busy=$(ss -ltnH 2>/dev/null | awk '{print $4}' | grep -E ':(80|443)$' | grep -v docker || true)
if [ -n "$busy" ] && ! docker compose ps -q caddy 2>/dev/null | grep -q .; then warn "80 或 443 埠已被別的程式佔用(HTTPS 需要它們)"; else ok "80、443 埠可以用"; fi
fi
mem=$(awk '/MemTotal/ {print int($2/1024/1024+0.5)}' /proc/meminfo 2>/dev/null || echo 0)
[ "${mem:-0}" -ge 2 ] && ok "記憶體 ${mem} GB" || warn "記憶體只有 ${mem:-?} GB(建議 2 GB 以上,第一次建置會比較久)"
disk=$(df -Pk . 2>/dev/null | awk 'NR==2 {print int($4/1024/1024)}')
[ "${disk:-0}" -ge 10 ] && ok "硬碟還有 ${disk} GB" || warn "硬碟只剩 ${disk:-?} GB(建議 10 GB 以上)"
if [ $FAIL = 1 ] && [ $DRY = 0 ]; then note ""; note "修好上面的問題後再執行一次 ./setup.sh"; die; fi
# All fine: go on by itself.
if [ $WARNED = 1 ]; then card_close; pause; else card_close; sleep 1; fi
# Keep the current settings?
if [ -f .env ] && [ -f Caddyfile ]; then
# shellcheck disable=SC1091
set -a; . ./.env; set +a
choose KEEP "這台已經設定過了(${ANYBOT_RELAY_PUBLIC_URL:-})" \
"用現有設定,更新並重新啟動|拉下最新版、重建、重新啟動,帳號和 Bot 都不變" \
"重新設定|網址、帳號或金鑰要改"
if [ "$KEEP" = 0 ]; then
DOMAIN=${ANYBOT_RELAY_PUBLIC_URL#https://}; USERNAME=$ANYBOT_USER; INVITE=${ANYBOT_RELAY_INVITE:-}; BRAIN=${ANYBOT_HOST_NAME:-}
AT=3; SKIP=1
fi
fi
if [ "${SKIP:-0}" != 1 ]; then
# ---------- 1. the address ----------
AT=1; MSG=""
while :; do
header; card_open "手機和其他電腦用哪個網址連到這台?"
note "網域要先指到這台伺服器(DNS 的 A 紀錄)。HTTPS 憑證會自動申請、自動更新。"
line ""
[ -n "$MSG" ] && { printf '%s\n' "$MSG"; line ""; }
ask DOMAIN "網域" "${DOMAIN:-}"
DOMAIN=${DOMAIN#https://}; DOMAIN=${DOMAIN#http://}; DOMAIN=${DOMAIN%%/*}
if ! printf '%s' "$DOMAIN" | grep -Eq '^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'; then MSG=$(bad "請輸入像 anybot.example.com 這樣的網域"); continue; fi
ips=$(getent ahosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ')
if [ -z "$ips" ]; then
card_close
choose GO "「$DOMAIN」還沒有指到任何位址" "重新輸入|" "仍然繼續|DNS 剛設好、還沒生效時可以先繼續;生效後憑證會自動申請"
[ "$GO" = 0 ] && { MSG=""; continue; }
else
mine=$(hostname -I 2>/dev/null || true)
hit=0; for ip in $ips; do case " $mine " in *" $ip "*) hit=1 ;; esac; done
if [ $hit = 1 ]; then MSG=$(ok "$DOMAIN → $ips(就是這台)"); else MSG=$(warn "$DOMAIN → $ips(這台的位址:${mine:-不知道};在 NAT 或雲端主機後面時這是正常的)"); fi
header; card_open "手機和其他電腦用哪個網址連到這台?"; printf '%s\n' "$MSG"; card_close; sleep 1
fi
break
done
# ---------- 2. the account ----------
AT=2; MSG=""
while :; do
header; card_open "你的 AnyBot 帳號"
note "手機、電腦都用這個帳號登入。第一次啟動時會自動建立。"
line ""
[ -n "$MSG" ] && { printf '%s\n' "$MSG"; line ""; }
ask USERNAME "帳號" "${USERNAME:-}"
if ! printf '%s' "$USERNAME" | grep -Eq '^[A-Za-z0-9._@-]{2,64}$'; then MSG=$(bad "帳號只能用英文字母、數字和 . _ @ -(2–64 個字)"); continue; fi
ask PW1 "密碼" "" secret
ask PW2 "再一次" "" secret
if [ "${#PW1}" -lt 8 ]; then MSG=$(bad "密碼至少 8 個字"); continue; fi
if [ "$PW1" != "$PW2" ]; then MSG=$(bad "兩次密碼不一樣"); continue; fi
case "$PW1" in *"'"*) MSG=$(bad "密碼裡不能有單引號 '"); continue ;; esac
BRAIN="雲端大腦"
break
done
# Only this account (others can be let in later: ANYBOT_RELAY_INVITE in .env). AI keys are set
# in the app (設定 › 模型); they stay on the brain.
INVITE=""; XAI=""; ANT=""; OAI=""; GEM=""; RESTORE=""
q() { printf "%s='%s'\n" "$1" "$2"; }
umask 077
{
echo "# Written by setup.sh. Run ./setup.sh again to change."
q ANYBOT_USER "$USERNAME"
q ANYBOT_PASSWORD "$PW1"
q ANYBOT_HOST_NAME "$BRAIN"
q ANYBOT_RELAY_INVITE "$INVITE"
q ANYBOT_RELAY_PUBLIC_URL "https://$DOMAIN"
q ANYBOT_RESTORE_FROM_ACCOUNT "$RESTORE"
q XAI_API_KEY "$XAI"
q ANTHROPIC_API_KEY "$ANT"
q OPENAI_API_KEY "$OAI"
q GEMINI_API_KEY "$GEM"
} >.env
chmod 600 .env
umask 022
sed "s/^anybot.example.com {/$DOMAIN {/" ../../relay/Caddyfile >Caddyfile
fi
# ---------- 4. start ----------
AT=3; header; card_open "啟動"
ok "寫好設定(.env、Caddyfile)"
if [ $DRY = 1 ]; then
warn "試跑(--dry-run):沒有啟動。要啟動:docker compose up -d --build"
card_close; exit 0
fi
if [ "${SKIP:-0}" = 1 ] && [ -d ../../../.git ] && command -v git >/dev/null; then
spin "拉下最新版" git -C ../../.. pull --ff-only || true
fi
spin "建置並啟動(第一次約 5–15 分鐘)" docker compose up -d --build || die
# The brain joins the account (and makes it, the first time).
f=0; c=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"); joined=""
for _ in $(seq 1 600); do
logs=$(docker compose logs --no-color anybot 2>/dev/null | tail -n 40)
if printf '%s' "$logs" | grep -q "透過帳號"; then joined=1; break; fi
printf '\r\e[K %s│%s %s%s%s 大腦正在登入帳號…' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N"; f=$((f + 1)); sleep 0.3
done
printf '\r\e[K'
if [ -n "$joined" ]; then
printf '%s' "$logs" | grep -q "已建立帳號" && ok "建立帳號 ${USERNAME}" || ok "大腦「${BRAIN}」已登入帳號"
else
bad "大腦沒有登入帳號。最近的紀錄:"
printf '%s\n' "$logs" | grep "AnyBot:" | tail -n 4 | while IFS= read -r l; do note " ${l#*| }"; done
fi
# HTTPS from outside (the certificate can take a minute).
https=""
for _ in $(seq 1 120); do
if curl -fsS --max-time 5 "https://$DOMAIN/api/hello" 2>/dev/null | grep -q anybot-relay; then https=1; break; fi
printf '\r\e[K %s│%s %s%s%s 等 HTTPS 憑證…' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N"; f=$((f + 1)); sleep 1
done
printf '\r\e[K'
if [ -n "$https" ]; then ok "https://$DOMAIN 可以連"; else warn "https://$DOMAIN 還連不到(DNS 還沒生效,或 80/443 埠被防火牆擋住)。看紀錄:docker compose logs caddy"; fi
card_close
printf '\n'
card_open "$([ -n "$joined" ] && echo "完成了" || echo "差一點")"
line "${B}手機${N} 打開 ${A}https://$DOMAIN${N},用 ${B}$USERNAME${N} 登入,可以「加入主畫面」"
line "${B}電腦${N} AnyBot › 設定 › 手機與帳號 登入,在「大腦」選「$BRAIN」"
line "${B}AI 金鑰${N} 手機或電腦的 AnyBot › 設定 › 模型(只存在大腦上)"
line "${B}加一台電腦${N} 手機 AnyBot 右上 ⋯ › 加一台電腦,把那一行貼到那台的終端機"
line ""
note "看紀錄 docker compose logs -f anybot"
note "更新 ./setup.sh(選「用現有設定」)"
card_close
printf '\n'

126
docs/architecture.md Normal file
View File

@ -0,0 +1,126 @@
# 技術架構
AnyBot 是一個 Rust 程式(`anybot`),用 egui/eframe 畫介面。同一個執行檔有三種跑法:
| 跑法 | 指令 | 用途 |
|---|---|---|
| 視窗 | `anybot` | 桌面 App |
| 無視窗 | `anybot --serve` | 伺服器、一直開著的電腦、手腳:介面邏輯照跑,只是不畫畫面 |
| 設定 | `anybot setup …` | 終端機裡把這台接到帳號(`src/setup.rs`) |
另一個小程式 `anybot-relay`(`relay/`)是帳號伺服器。
## 程式分層
```
┌─────────────── 介面(src/app.rs、src/app/*)───────────────┐
│ 聊天、側邊欄、設定、電腦、檔案預覽、大腦同步(mirror_ui) │
│ 每個畫面一個 App 方法;狀態全部在 App.state(model::State) │
└───────┬───────────────────────────────┬────────────────────┘
│ 送出訊息 │ 手機/其他 AnyBot 的要求
┌───────▼──────────┐ ┌────────▼───────────────────┐
│ 回合(agent.rs) │ │ 遠端(remote.rs) │
│ 呼叫模型、跑工具、 │ │ 手機 API、裝置連結、大腦同步 │
│ 等你核准、整理摘要 │ │ 長連線(tunnel.rs、wire.rs) │
└───┬─────────┬────┘ └──────────────────────────────┘
│ │
┌───▼───┐ ┌───▼──────────────────────────────────────────────┐
│ 模型 │ │ 工具(tools.rs 為主) │
│ provi- │ │ 檔案與指令、Bot 的電腦(cloudbox.rs)、瀏覽器 │
│ ders.rs│ │ (botbrowser.rs、browser.rs、web.rs)、螢幕操作 │
└────────┘ │ (computer.rs、desktop.rs、axread.rs;Linux:x11.rs、 │
│ wl.rs、portal.rs、desk.rs)、Mac App(macapps.rs)、 │
│ 保險庫(vault*.rs)、MCP(mcp.rs)、記憶(memory.rs) │
└──────────────────────────────────────────────────────┘
```
| 檔案 | 做什麼 |
|---|---|
| `model.rs` | 資料:State、Bot、Message、設定;讀寫 `state.json` |
| `agent.rs` | 一個回合:組系統指示、送模型、收串流、跑工具、核准關卡、長對話摘要 |
| `providers.rs` | 各家模型的 API(OpenAI 相容、Anthropic、OpenCode Go…)、串流、重試、看不到圖時的看圖小幫手 |
| `tools.rs` | 檔案、指令、背景指令;指令在 macOS 沙盒裡跑(見「安全」) |
| `policy.rs` | 哪些動作直接做、先問、拒絕 |
| `cloudbox.rs`、`box/` | Bot 自己的電腦:一個 Docker 容器(有桌面、瀏覽器) |
| `computer.rs`、`desktop.rs`、`axread.rs` | 看畫面、滑鼠鍵盤;讀 App 的按鈕清單(agent-desktop) |
| `botbrowser.rs`、`browser.rs`、`web.rs` | AnyBot 自己的瀏覽器(Chrome DevTools 協定)、讀你的瀏覽器、抓網頁 |
| `routines.rs`、`triggers.rs` | 例行任務和觸發 |
| `channels.rs` | 訊息管道(Slack、Discord、Telegram) |
| `remote.rs` | 手機 API、帳號登入、裝置連結、大腦同步的請求端 |
| `tunnel.rs`、`wire.rs` | 和帳號伺服器之間的長連線(WebSocket) |
| `app/mirror_ui.rs` | 大腦和螢幕之間的同步 |
| `setup.rs` | `anybot setup` |
| `httpd.rs` | 很小的 HTTP 伺服器(AnyBot 和帳號伺服器共用) |
| `mobile/index.html` | 手機網頁(單一檔案,編進程式裡) |
## 資料存放
都在資料資料夾裡(macOS `~/Library/Application Support/AnyBot/`、Linux `~/.local/share/AnyBot/`、Windows `%APPDATA%\AnyBot\`;`ANYBOT_DATA_DIR` 可以改):
| 檔案 | 內容 |
|---|---|
| `state.json` | 全部的 Bot、聊天、設定和金鑰 |
| `attachments/bot-<id>/` | 聊天附件 |
| `browser/` | AnyBot 瀏覽器的設定檔(登入狀態) |
| `brain-copy.json` | 螢幕電腦保留的大腦副本(離線時還能看) |
| `brain-files/` | 螢幕電腦從大腦下載的附件 |
| `image-notes.json` | 看圖小幫手對每張圖的描述(不用重看) |
寫入時先寫暫存檔再改名,不會寫一半。
## 安全
- **核准關卡**:工具動作照規則直接做、先問或拒絕(`policy.rs`,FEATURES.md 第 16、19 節)。「直接操作模式」可以跳過詢問。
- **指令的沙盒(macOS)**:Bot 執行的指令在 `sandbox-exec` 裡跑,讀不到也寫不到 AnyBot 的資料資料夾和 `.ssh`、`.gnupg`、鑰匙圈、`.aws` 等;只有 `attachments/` 可以讀(不能寫)。環境變數裡的模型金鑰會先拿掉。
- **Bot 的電腦**:放在容器裡,和你的電腦分開。
- **保險庫**:密碼、卡號、CVC 不會交給模型,由 AnyBot 直接填進網頁。
- **多台電腦**:金鑰只留在大腦(下面說明);手腳只接受帳號裡的 AnyBot,用配對碼加入的只接受那一個大腦。
## 多台電腦怎麼運作
### 連線
每台 AnyBot(大腦、螢幕、手腳)都保持一條 WebSocket 連到帳號伺服器(`/relay/host/ws`),雙方每 15–20 秒互相確認,45–60 秒沒聲音就重連。帳號伺服器沒有 WebSocket 時(舊版),改用 HTTP 長輪詢(`/relay/host/next`)。
- 手機的要求:手機 → 帳號伺服器(HTTPS)→ 這條連線 → AnyBot → 回覆原路回去。
- AnyBot 找另一台 AnyBot(同步、手腳):從自己的連線送 `call` 上去,伺服器轉給對方的連線,答案以 `ret` 回來。
- 連線斷掉時,等待中的請求立刻收到「斷線了」,不用等逾時;伺服器也立刻知道這台離線。
- 超過 1 KB、而且壓得小的內容用 deflate 壓縮(圖片、PDF 這類已壓縮的照原樣送)。
格式見 [protocol.md](protocol.md)。
### 大腦和螢幕的同步(`app/mirror_ui.rs`)
大腦回答「從版本 N 之後改了什麼」:
- **設定**:State 去掉聊天、拿掉金鑰後算指紋,變了才送整份。
- **聊天**:每則訊息有指紋,只送第一個不同的那則之後的部分。
- **討論串**、**誰在工作**、**等你核准的事**。
螢幕的請求會等最多 20 秒,有變化就馬上回(長輪詢,跑在長連線上)。大腦上的改動通常 1 秒內出現在螢幕上。
螢幕上的改動變成小操作送回大腦:
- 單一值(`set`、`bot_set`);
- 清單和對照表逐項修改(`patch`、`bot_patch`:新增或修改某幾項、刪掉某幾項、改順序),所以兩台同時改同一份清單的不同項目,兩邊都會保留;
- 新增、刪除、排序 Bot(`bot_add`、`bot_del`、`bot_order`)。
每個改動有編號,大腦回報收到第幾號;還沒確認的改動,會疊在大腦傳回來的新設定上,打字不會被蓋回去。所有改動和聊天動作由一個工作排隊依序送出。送失敗時,螢幕重新要一份完整資料。
聊天動作(傳訊息、停止、核准、重新產生、開新對話…)送 `act`,由大腦執行。附件先上傳到大腦。
### 金鑰只在大腦
送給螢幕的設定裡,模型金鑰、訊息管道的 Token、連接器的環境變數和標頭都換成「••••(已設定,在大腦上)」。螢幕改設定送回來時,大腦看到這個替代字,就保留原本的值(清單裡依 id 或名字對上)。螢幕要取得模型清單時,由大腦用自己的金鑰去抓。
### 手腳
大腦的 Bot 呼叫工具時可以指定 `computer`。如果是帳號裡的另一台,就透過長連線送 `/api/device/run` 給它,由它在自己身上執行(螢幕、檔案、指令、它的 AnyBot 瀏覽器),再把文字和截圖傳回來。那台要開「裝置連結」才接受;若設了只給某個大腦用(`hands_for`),帳號伺服器會附上是哪一台在問,不是那台就拒絕。
### 配對碼
已登入的手機或 AnyBot 向帳號伺服器要一組 6 位數字(10 分鐘、一次)。新電腦用這組數字換到自己的身分(主機 id 和金鑰),不經過密碼。配對碼記得它是給哪個大腦的,新電腦就只讓那個大腦使用。
### 看圖小幫手
模型拒收圖片時,AnyBot 記下這個模型看不到圖。之後送給它的請求裡,你傳的每張圖會先交給你帳號裡看得到圖的模型(依序試 xAI、Anthropic、OpenAI、Gemini、OpenRouter…,先用你設的預設模型),把描述(包括圖裡的文字)放進訊息。描述依圖片記住,同一張圖不會再看一次;失敗的模型 10 分鐘內跳過。

103
docs/install.md Normal file
View File

@ -0,0 +1,103 @@
# 從原始碼建置和安裝
## 需要準備
| 平台 | 需要 |
|---|---|
| 全部 | Rust(stable,用 rustup 安裝)、git、make |
| macOS 12 以後 | Xcode Command Line Tools(`xcode-select --install`) |
| Linux | 編譯用:`build-essential pkg-config libasound2-dev`;執行用的系統套件見下面,`make deps` 會一次裝好 |
| Windows | Visual Studio Build Tools(C++)。可以 `cargo build --release`,安裝包還沒做 |
安裝 Rust:
```sh
curl --proto '=https' -sSf https://sh.rustup.rs | sh
```
## 步驟
```sh
git clone git@git.30cm.net:daniel.w/AnyBot.git
cd AnyBot
make deps # Linux:安裝系統套件(會問 sudo 密碼);Mac:檢查 Xcode 工具
make # 建置 AnyBot 和帳號伺服器(第一次約 5–15 分鐘)
make install
```
`make install` 做的事:
- **macOS**:做出 `AnyBot.app`(含圖示),放到 `~/Applications/`。沒有 Apple 開發者簽章,第一次打開請按右鍵 › 打開。想放到別的地方:`make install APPDIR=/Applications`。
- **Linux**:程式放在 `~/.local/bin/anybot`,選單項目和圖示放在 `~/.local/share/`。不需要 root。
不想安裝、只想試:`make run`。
## Linux 的系統套件
`make deps` 依發行版(apt、dnf、pacman)安裝這些,全部都是選用的,缺了 App 會說明少了什麼:
| 套件 | 用途 |
|---|---|
| libasound2、libxkbcommon-x11-0、libgl1、xdg-utils | 視窗和聲音(必要) |
| libnotify-bin | 通知 |
| libsecret-tools | 保險庫存在系統鑰匙圈 |
| espeak-ng | 唸出回覆 |
| zenity | 選檔案的視窗 |
| xclip / wl-clipboard | 貼上圖片;GNOME/KDE Wayland 上打中文 |
| ffmpeg | 影片、語音、示範錄影 |
| docker.io | Bot 自己的電腦 |
| fonts-noto-cjk | 更多中文字形(內建的粉圓體已經夠用) |
沒有螢幕的 Linux(只當手腳或大腦):只需要 `libasound2`。要讓 Bot 看得到畫面,再執行 `make hands`,會裝好虛擬螢幕(Xvfb)、視窗管理員和輔助使用服務。
## 更新
```sh
git pull
make install
```
你的 Bot、聊天和設定不在程式裡,更新不會動到它們。
## 移除
```sh
make uninstall
```
只移除程式。要連資料一起刪,刪掉資料資料夾:
| 平台 | 資料在哪 |
|---|---|
| macOS | `~/Library/Application Support/AnyBot/` |
| Linux | `~/.local/share/AnyBot/` |
| Windows | `%APPDATA%\AnyBot\` |
設成開機自動執行的(`anybot setup` 裝的)也要停掉:Linux 執行 `systemctl --user disable --now anybot`。
## 其他執行方式
| 指令 | 用途 |
|---|---|
| `make serve`(`anybot --serve`) | 不開視窗:Bot、例行任務、訊息管道一直運作,用手機操作 |
| `make setup`(`anybot setup`) | 把這台接到你的 AnyBot(大腦或手腳),見 [multi-device.md](multi-device.md) |
| `make relay-run` | 在這台跑帳號伺服器(測試用,埠 8080) |
| `make server` | 在伺服器上用 Docker 架帳號伺服器、大腦和 HTTPS |
| `ANYBOT_DATA_DIR=/某個資料夾 anybot` | 用另一份資料(測試、同一台跑兩份) |
## 常見問題
**Mac 說「無法打開,因為無法驗證開發者」**
按右鍵 › 打開,再按一次「打開」。只有第一次需要。
**Mac 上 Bot 看不到螢幕、不能點擊**
系統設定 › 隱私權與安全性:「螢幕錄製」和「輔助使用」要打開 AnyBot。App 的 設定 › 電腦 有按鈕直接開到那一頁。
**Linux 啟動時說找不到 libasound.so.2**
`sudo apt-get install libasound2`(Ubuntu 24.04 以後叫 `libasound2t64`)。
**編譯時找不到 alsa 或 pkg-config**
Linux 少了編譯用的套件:`sudo apt-get install build-essential pkg-config libasound2-dev`。
**第一次 `make` 很久**
要下載並編譯所有套件,第一次需要 5–15 分鐘,之後只會重編有改的部分。

109
docs/multi-device.md Normal file
View File

@ -0,0 +1,109 @@
# 多台電腦:大腦、手腳、螢幕
帳號裡選一台 AnyBot 當**大腦**,其他電腦和手機都連到它。
| 角色 | 是什麼 | 怎麼設 |
|---|---|---|
| 大腦 | 保存所有 Bot、聊天、記憶和例行任務,由它思考和排程。要一直開著 | 登入帳號就是(預設) |
| 螢幕 | 有視窗的電腦:看到的、改的都是大腦的 Bot;同時也是大腦的手腳 | 設定 › 手機與帳號 › 大腦 › 「改用它當大腦」 |
| 手腳 | 讓大腦的 Bot 操作這台:畫面、點擊打字、檔案和指令、這台的瀏覽器 | 「加一台電腦」的那一行,或 `anybot setup` |
| 手機 | 網頁版,打開帳號伺服器的網址 | 登入同一個帳號 |
所有電腦都透過**帳號伺服器**互相連絡。每台 AnyBot 主動連過去、保持一條一直開著的連線,所以電腦不用開放連接埠,也不用固定 IP。
## 一、架帳號伺服器和大腦
### 做法 A:放在伺服器上(推薦,Mac 關機時大腦也還在)
需要:一台有 Docker 的 Linux 主機,以及一個指到它的網域。
```sh
git clone git@git.30cm.net:daniel.w/AnyBot.git && cd AnyBot
make server
```
只問三件事:網域、帳號、密碼。會自動申請 HTTPS 憑證、建立帳號、啟動大腦。完成畫面會告訴你手機和電腦怎麼連。之後再執行一次 `make server`,可以選「用現有設定,更新並重新啟動」。
不想在伺服器上編譯:下載發行檔 `AnyBot-<版本>-server-x86_64.tar.gz`,解開後 `cd deploy/server && ./setup.sh`,用的是已經編好的程式。
### 做法 B:先用一台 Mac 當大腦(家裡用 Tailscale)
1. 跑帳號伺服器,並設成開機自動啟動:
```sh
make relay
mkdir -p "$HOME/Library/Application Support/AnyBot/relay"
cp relay/target/release/anybot-relay "$HOME/Library/Application Support/AnyBot/relay/"
cp deploy/mac/com.anybot.relay.plist ~/Library/LaunchAgents/ # 先把裡面的網址改成你的
launchctl load ~/Library/LaunchAgents/com.anybot.relay.plist
```
2. 用 Tailscale 給它 HTTPS 網址:`tailscale serve --bg 8780`(網址像 `https://mac-mini.xxxx.ts.net`)。
3. 打開 AnyBot › 設定 › 手機與帳號,填這個網址,建立帳號並登入。這台就是大腦。
只有同一個 Tailscale 網路裡的手機和電腦連得到。
## 二、接上其他電腦
### 有視窗的電腦(螢幕)
打開 AnyBot › 設定 › 手機與帳號,登入同一個帳號,在「大腦」那一欄按某一台旁邊的「改用它當大腦」。
- 這台原本的 Bot 會留著不刪,可以按「把這台的 Bot 搬過去」複製到大腦(連同聊天、金鑰、技能和連接器;已經在大腦上的不會重複搬)。
- 隨時可以按「改回用這台自己的 Bot」。
- 選大腦時也會打開這台的「裝置連結」:大腦的 Bot 可以用這台當手腳,每個動作仍照大腦的規則先問你。
### 沒有螢幕的 Linux(手腳)
1. 在手機 AnyBot 右上「⋯ › 加一台電腦」,或電腦 AnyBot 的 設定 › 手機與帳號 › 大腦 › 加一台電腦 › 產生配對碼。
2. 會出現一行(10 分鐘內有效,只能用一次):
```sh
anybot setup https://anybot.example.com 469615
```
3. 在那台電腦裝好 AnyBot(`make install`,或發行檔 `AnyBot-<版本>-headless-x86_64.tar.gz` 裡的 `./install.sh`),再貼上這一行執行。
它會:加入帳號(不用密碼)、只讓產生配對碼的那個大腦使用、設成開機自動執行(systemd 使用者服務 `anybot`)、沒有螢幕時開一個 1440×900 的虛擬螢幕,最後確認大腦看得到它。大腦約一分鐘內就會把它列進 Bot 可以用的電腦。
想讓 Bot 看得到這台的畫面,先執行 `make hands`(裝 Xvfb、視窗管理員、輔助使用服務)。
只打 `anybot setup` 會出現一個畫面:填網址和配對碼(或改用帳號密碼)。給程式用:
```sh
ANYBOT_ACCOUNT_PASSWORD=… anybot setup --server https://… --user 帳號 [--brain-name 雲端大腦] [--no-service]
```
登出後也要繼續跑:`sudo loginctl enable-linger $USER`(完成畫面會提醒)。
### 手機
打開帳號伺服器的網址,登入。可以「加入主畫面」當 App 用、開通知。帳號裡有螢幕電腦時,手機會直接打開它們用的大腦。
## 三、連不上時
畫面上方會說是哪一段連不到:
| 看到 | 意思 | 怎麼辦 |
|---|---|---|
| 這台電腦沒有連上網路 | 這台斷網 | 檢查 Wi-Fi 或網路線 |
| 連不到帳號伺服器:伺服器沒有開 | 帳號伺服器沒在跑 | 伺服器上 `docker compose ps`;Mac 上看 `launchctl list | grep anybot` |
| 帳號伺服器暫時無法使用 | 正在重開或 HTTPS 憑證還沒好 | 等一下,會自動重連 |
| 大腦目前離線 | 大腦關機、睡眠或 AnyBot 沒開 | 打開大腦;這段時間還能看之前的對話 |
| 大腦收到了,但太久沒有回應 | 大腦太忙或網路很慢 | 稍後再試 |
| 這台電腦的帳號登入失效了 | 帳號被登出或金鑰換了 | 重新登入 |
| 這台電腦只讓大腦「…」使用 | 別台想用這台當手腳 | 正常:配對時就限定了 |
大腦連不到時:之前的對話還能看,不能傳訊息;連回來會自動恢復,不用重開。
**忘記帳號密碼**:在帳號伺服器那台,先停掉它,再執行(輸入兩次新密碼,畫面不顯示):
```sh
anybot-relay --data <資料夾> --reset-password <帳號>
```
舊的加密備份是用舊密碼鎖的,改完之後在電腦上按一次「立刻備份」。
## 四、安全
- 模型金鑰、訊息管道的 Token、連接器的密碼只在大腦上。螢幕電腦看到的是「已設定,在大腦上」,在螢幕上改設定也不會洗掉真的金鑰。
- 手腳只接受帳號裡的 AnyBot,用配對碼加入的只接受那一個大腦。
- 每個動作照大腦的規則先問你(或照你設的「一律允許」)。
- 配對碼 10 分鐘有效、只能用一次,連續猜錯會被暫停 5 分鐘。
- 技術細節見 [architecture.md](architecture.md) 和 [protocol.md](protocol.md)。

117
docs/protocol.md Normal file
View File

@ -0,0 +1,117 @@
# 介面與長連線格式
給要改帳號伺服器或寫其他用戶端的人。所有回覆都是 JSON;錯誤是 `{"error": "白話的說明"}` 加上對應的 HTTP 狀態碼。
## 身分
| 誰 | 怎麼證明 | 從哪來 |
|---|---|---|
| 人(手機) | `Authorization: Bearer <session>` | `POST /relay/login` |
| 一台 AnyBot(主機) | `Authorization: Bearer <host token>` | `POST /relay/hosts`(登入後)或 `POST /relay/pair/claim`(配對碼) |
伺服器只存密碼和金鑰的雜湊值。
## 帳號伺服器(relay)
### 帳號
| 方法 | 路徑 | 說明 |
|---|---|---|
| POST | `/relay/signup` | `{user, password, invite}`。第一個帳號不用邀請碼,之後要 `ANYBOT_RELAY_INVITE` |
| POST | `/relay/login` | `{user, password, device}` → `{token}`。錯 8 次停 5 分鐘 |
| POST | `/relay/logout` | 登出這個 session |
| GET | `/relay/me` | 帳號、可用的 AnyBot(含 `online`、`uses`、`hands`)、登入過的裝置、有沒有備份 |
### 主機
| 方法 | 路徑 | 說明 |
|---|---|---|
| POST | `/relay/hosts` | (人)`{name}` → `{id, token}`。同名的主機會換新金鑰 |
| POST | `/relay/hosts/<id>/remove` | (人)移除主機 |
| GET | `/relay/host/ws` | (主機)長連線,見下面 |
| GET | `/relay/host/next` | (主機)舊式長輪詢:有要求就回 job,25 秒沒有回 204 |
| POST | `/relay/host/reply/<job id>` | (主機)回答舊式 job |
| GET | `/relay/host/peers` | (主機)帳號裡的其他主機 |
| GET/POST | `/relay/host/members` | (主機)團隊成員和角色(admin、member) |
| POST | `/relay/host/notify` | (主機)推播通知到帳號的手機 |
| POST | `/relay/host/sites/<name>` | (主機)發布 Bot 做的網頁,公開在 `/s/<name>/` |
| PUT/GET | `/relay/backup` | 加密備份(伺服器打不開) |
### 配對碼
| 方法 | 路徑 | 說明 |
|---|---|---|
| POST | `/relay/pair` | (人或主機)`{for: 大腦的主機 id}` → `{code, expires_in: 600}` |
| POST | `/relay/pair/claim` | (不用登入)`{code, name}` → `{id, token, user, for, for_name}`。只能用一次;同一個位址錯 8 次停 5 分鐘 |
### 轉送
| 路徑 | 誰 | 轉成 |
|---|---|---|
| `/h/<host>/api/...` | 手機 | 那台 AnyBot 的 `/api/...`,角色是 owner/admin/member |
| `/relay/hosts/<host>/api/sync[/...]` | 同帳號的主機 | 大腦的 `/api/sync...`,角色 `mirror` |
| `/relay/hosts/<host>/api/device/run` | 同帳號的主機 | 手腳的 `/api/device/run`,角色 `device` |
轉送時伺服器附上 `user`、`role` 和 `from`(問的那台主機 id)。對方不在線時立刻回 503「目前離線」;收到了但 35 秒內沒回答,回 504。
## AnyBot 提供的介面(`remote::handle`)
只有經過帳號伺服器(或同一個網路、用手機密碼登入)才到得了。
| 方法 | 路徑 | 角色 | 說明 |
|---|---|---|---|
| GET | `/api/state?since=` | 手機 | Bot 清單、誰在工作、等核准的事(有變化才回,最多等 20 秒) |
| GET | `/api/bots/<id>?since=` | 手機 | 一個 Bot 的訊息 |
| POST | `/api/bots/<id>/<動作>` | 手機 | `send`、`stop`、`answer`、`read`、`new`、`upload`… |
| GET/POST | `/api/screen/<box\|mac>` | 手機 | 看畫面、點擊打字 |
| POST | `/api/device/run` | device | `{tool, args, who}` → `{text, ok, image}`:在這台跑一個工具 |
| GET | `/api/sync?since=&client=` | mirror | 大腦的變化(見 architecture.md) |
| POST | `/api/sync/edit` | mirror | `{ops, client, seq}` → `{ok, applied}` |
| POST | `/api/sync/act` | mirror | `{bot, action, …}`:send、stop、answer、read、new、regenerate、reply、restore |
| POST | `/api/sync/upload/<bot>?name=` | mirror | 原始檔案內容 → `{path}` |
| GET | `/api/sync/file?path=` | mirror | 大腦上的附件(只限 `attachments/` 裡的檔案) |
| POST | `/api/sync/models` | mirror | `{provider}` → `{models}`:用大腦的金鑰取得模型清單 |
## 長連線的格式(`src/wire.rs`)
WebSocket 的二進位訊息,每則是:
```
[4 位元組,表頭長度,big-endian] [表頭 JSON] [內容的原始位元組]
```
內容不做 base64。`z: true` 表示內容用 deflate 壓過;`z_ok: true` 表示問的人看得懂壓過的回答。
| t | 方向 | 表頭欄位 | 意思 |
|---|---|---|---|
| `hello` | AnyBot → 伺服器 | `brain`、`hands` | 這台在看哪個大腦、是不是手腳(手機和設定畫面用) |
| `job` | 伺服器 → AnyBot | `id, method, path, query, user, role, from, z, z_ok` | 給這台的要求 |
| `reply` | AnyBot → 伺服器 | `id, status, ctype, headers, z` | 回答 job |
| `call` | AnyBot → 伺服器 | `id, to, method, path, query, z` | 要問帳號裡的另一台(只能是 `/api/sync…` 或 `/api/device/run`) |
| `ret` | 伺服器 → AnyBot | `id, status, ctype, z` | call 的回答 |
心跳用 WebSocket 的 ping/pong。連線斷掉時,伺服器把還在等的 job 都回 503「斷線了」,AnyBot 把還在等的 call 都回「和帳號伺服器的連線斷了」。
## 同步的改動(`/api/sync/edit` 的 ops)
| op | 欄位 | 說明 |
|---|---|---|
| `set` | `key, value` | 換掉一個設定(`next_*` 計數只會變大) |
| `patch` | `key, patch` | 清單或對照表逐項修改 |
| `merge` | `key, value` | 搬移時補上大腦沒有的(providers、skills、mcp_servers) |
| `bot_add` | `bot` | 新增 Bot(id 撞到會換) |
| `bot_del` | `id` | 刪除(主要 Bot 不能刪) |
| `bot_set` | `id, key, value` | 改 Bot 的一個欄位 |
| `bot_patch` | `id, key, patch` | 逐項修改 Bot 的清單欄位(例行任務保留大腦記錄的執行時間) |
| `bot_read` | `id` | 標為已讀 |
| `bot_order` | `ids` | Bot 的順序 |
`patch` 的格式:
```json
{"value": …} 換掉整個值
{"map": {"put": {"鍵": 值}, "del": ["鍵"]}} 對照表
{"list": {"put": [項目], "del": [鍵], "order": [鍵]}} 清單;鍵是項目的 id,沒有就用 name,純值就是值本身
```
清單裡的項目沒有 id 或名字、或鍵重複時,送整個值。

106
docs/release.md Normal file
View File

@ -0,0 +1,106 @@
# 發行
## 自己打包
```sh
make dist # 版本號取自 git tag(沒有就用 commit 編號)
make dist VERSION=1.2.0 # 指定版本號
```
| 在哪打包 | 產生 |
|---|---|
| Mac | `dist/AnyBot-<版本>-macos-arm64.zip`:AnyBot.app(含圖示,ad hoc 簽章) |
| Linux | `dist/AnyBot-<版本>-ubuntu-<架構>.tar.gz`:有視窗的版本,`./install.sh` 安裝 |
| | `dist/AnyBot-<版本>-headless-<架構>.tar.gz`:沒有螢幕的 Linux(手腳或大腦),附 `hands.sh` |
| | `dist/AnyBot-<版本>-server-<架構>.tar.gz`:帳號伺服器+大腦+HTTPS,用已編好的程式建 Docker 映像,`deploy/server/setup.sh` |
Linux 版要在 Ubuntu 22.04(glibc 2.35)上編譯,才能在較新的 Ubuntu、Debian 12 等直接跑;用 `deploy/ci/Dockerfile.linux` 這個環境就是。
打包腳本:`deploy/mac/bundle.sh`、`deploy/ci/package-linux.sh`。
## 推 tag 自動建置(Gitea Actions)
`.gitea/workflows/release.yml`:推一個 `v` 開頭的 tag,兩個 runner 各自建置,把檔案掛到那個 tag 的 Release 上(沒有 Release 會自動建立;重建時會換掉同名的檔案)。
```sh
git tag v1.2.0
git push origin v1.2.0
```
| 工作 | runner 標籤 | 做什麼 |
|---|---|---|
| linux | `anybot-linux` | 在 `anybot-builder:22.04` 容器裡編譯、跑測試、打三個 Linux 包、上傳 |
| macos | `macos-arm64` | 在 Mac 上直接編譯、做 AnyBot.app、上傳 |
上傳用 `secrets.RELEASE_TOKEN`;沒設時用 Gitea 自動給的 token。如果自動的 token 沒有建立 Release 的權限,到 repo › 設定 › Actions › Secrets 加一個 `RELEASE_TOKEN`(有這個 repo 寫入權限的 access token)。
### 設定 Linux runner
在一台有 Docker 的 Linux(x86_64)上:
1. 在 Gitea 打開 Actions(管理員:`app.ini` 的 `[actions] ENABLED = true`),到 repo › 設定 › Actions › Runners › 建立新的 runner,複製註冊權杖。
2. 安裝 runner([gitea/runner](https://gitea.com/gitea/runner/releases),下載後核對 `.sha256`):
```sh
V=5.3.0
curl -fsSLO https://gitea.com/gitea/runner/releases/download/v$V/gitea-runner-$V-linux-amd64.xz
curl -fsSLO https://gitea.com/gitea/runner/releases/download/v$V/gitea-runner-$V-linux-amd64.xz.sha256
sha256sum -c gitea-runner-$V-linux-amd64.xz.sha256 && xz -d gitea-runner-$V-linux-amd64.xz
install -m 755 gitea-runner-$V-linux-amd64 ~/.local/bin/gitea-runner
```
3. 建好建置環境(只要一次;更新 Rust 時再建一次):
```sh
docker build -t anybot-builder:22.04 -f deploy/ci/Dockerfile.linux deploy/ci
```
4. 設定和註冊:
```sh
mkdir -p ~/.config/gitea-runner
cp deploy/ci/runner-linux.yaml ~/.config/gitea-runner/config.yaml # 檔案路徑裡的使用者名稱照你的改
cd ~/.config/gitea-runner
gitea-runner register --no-interactive --instance https://你的-gitea --token 註冊權杖 \
--name anybot-linux --labels "anybot-linux:docker://anybot-builder:22.04" -c config.yaml
```
5. 一直跑(systemd 使用者服務):
```ini
# ~/.config/systemd/user/gitea-runner.service
[Unit]
Description=Gitea runner (AnyBot releases)
After=network-online.target docker.service
[Service]
WorkingDirectory=%h/.config/gitea-runner
ExecStart=%h/.local/bin/gitea-runner daemon -c %h/.config/gitea-runner/config.yaml
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
```
```sh
systemctl --user daemon-reload && systemctl --user enable --now gitea-runner
sudo loginctl enable-linger $USER # 登出後也繼續跑
```
`runner-linux.yaml` 用兩個 Docker 磁碟區保留下載的套件(`anybot-cargo-registry`)和編譯結果(`anybot-ci-target`),之後每次發行只重編有改的部分。
### 設定 Mac runner
Apple 的 App 只能在 Mac 上編譯。在一台裝好 Rust 和 Xcode Command Line Tools 的 Mac 上:
```sh
V=5.3.0
curl -fsSLO https://gitea.com/gitea/runner/releases/download/v$V/gitea-runner-$V-darwin-arm64.xz
curl -fsSLO https://gitea.com/gitea/runner/releases/download/v$V/gitea-runner-$V-darwin-arm64.xz.sha256
shasum -a 256 -c gitea-runner-$V-darwin-arm64.xz.sha256 && xz -d gitea-runner-$V-darwin-arm64.xz
install -m 755 gitea-runner-$V-darwin-arm64 ~/.local/bin/gitea-runner
mkdir -p ~/.config/gitea-runner && cd ~/.config/gitea-runner
gitea-runner register --no-interactive --instance https://你的-gitea --token 註冊權杖 \
--name anybot-mac --labels "macos-arm64:host"
gitea-runner daemon # 或做成 launchd 代理,開機自動執行
```
`host` 表示工作直接在這台 Mac 上跑(不用容器)。編譯結果保留在工作資料夾旁的 `anybot-target/`。
### 注意
- runner 要連得到 Gitea 的網址(HTTPS),也要能從 GitHub 下載 `actions/checkout`(Gitea 預設的 action 來源)。
- Mac 版用 ad hoc 簽章,沒有公證;使用者第一次打開要按右鍵 › 打開。要正式簽章和公證需要 Apple 開發者帳號。

View File

@ -132,6 +132,11 @@ header .sub{font-size:12px;color:var(--text3);font-weight:400}
.sheet button{display:block;width:100%;text-align:left;padding:14px 16px;border-radius:12px;font-size:16px}
.sheet button:active{background:var(--raise)}
.sheet .no{color:var(--danger)}
.pair{padding:18px 16px calc(env(safe-area-inset-bottom) + 12px)}
.pair-t{font-size:18px;font-weight:600;margin:2px 4px 6px}
.pair-code{font:600 34px/1.2 ui-monospace,Menlo,monospace;letter-spacing:6px;text-align:center;margin:16px 0 10px}
.pair-line{display:block;font:13px/1.5 ui-monospace,Menlo,monospace;background:var(--raise);border-radius:10px;padding:10px 12px;word-break:break-all;margin-bottom:12px;user-select:all}
.sheet .pair-copy,.pair .btn.main{text-align:center;padding:13px}
.sheet .note{color:var(--text3);font-size:12.5px;padding:4px 16px 8px}
.toast{position:fixed;left:50%;bottom:calc(env(safe-area-inset-bottom) + 90px);transform:translateX(-50%);background:var(--text);color:var(--bg);padding:9px 16px;border-radius:12px;font-size:14px;opacity:0;transition:opacity .2s;pointer-events:none;max-width:88vw;text-align:center}
.toast.on{opacity:.95}
@ -217,11 +222,22 @@ header .sub{font-size:12px;color:var(--text3);font-weight:400}
<div class="sheet-bg" id="sheet"><div class="sheet">
<button id="m-notify">開啟通知</button>
<div class="note" id="m-note">Bot 需要你允許、或在你不在時做完事,手機會跳通知。</div>
<button id="m-pair" style="display:none">加一台電腦(Linux、伺服器)</button>
<button id="m-host" style="display:none">切換到別台 AnyBot</button>
<button class="no" id="m-out">登出這支手機</button>
<button id="m-cancel">取消</button>
</div></div>
<div class="sheet-bg" id="pair"><div class="sheet pair">
<div class="pair-t">加一台電腦</div>
<div class="note">在那台電腦的終端機貼上這一行執行,它就會變成「<b id="pair-for"></b>」的手腳,開機自動執行。不用帳號密碼。</div>
<div class="pair-code" id="pair-code">······</div>
<code class="pair-line" id="pair-line"></code>
<button class="btn main" id="pair-copy">複製這一行</button>
<div class="note" id="pair-left"></div>
<button id="pair-close">關閉</button>
</div></div>
<script>
"use strict";
// Lucide icons (ISC licence), the same set as the app.
@ -298,11 +314,20 @@ $("b-out1").onclick = () => signOut();
async function pickHost() {
const me = await api("relay/me");
const hosts = me.hosts || [];
const all = me.hosts || [];
// A computer that shows another one's bots (大腦) leads to that brain.
const byId = Object.fromEntries(all.map((h) => [h.id, h]));
const target = (id) => (byId[id] && byId[id].uses && byId[byId[id].uses]) ? byId[id].uses : id;
const hosts = all.filter((h) => !(h.uses && byId[h.uses]));
const sharers = (id) => all.filter((h) => h.uses === id).map((h) => h.name);
const live = hosts.filter((h) => h.online);
if (S.host && hosts.some((h) => h.id === S.host && h.online)) return start();
if (S.host) {
const t = target(S.host);
if (byId[t] && byId[t].online) { S.host = t; localStorage.setItem("anybot.host", t); return start(); }
}
if (live.length === 1) { S.host = live[0].id; localStorage.setItem("anybot.host", S.host); return start(); }
$("hosts").innerHTML = hosts.length ? hosts.map((h) => `<div class="row" data-h="${esc(h.id)}"><div class="av" style="background:${h.online ? "var(--ok)" : "#888"}">${esc((h.name || "?")[0])}</div><div class="mid"><div class="name">${esc(h.name)}</div><div class="last">${h.online ? "在線上" : "離線(" + esc(h.last_seen_text || "很久沒連線") + ")"}</div></div></div>`).join("")
const note = (h) => { const n = sharers(h.id); return n.length ? " · " + esc(n.join("、")) + " 也用這個大腦" : ""; };
$("hosts").innerHTML = hosts.length ? hosts.map((h) => `<div class="row" data-h="${esc(h.id)}"><div class="av" style="background:${h.online ? "var(--ok)" : "#888"}">${esc((h.name || "?")[0])}</div><div class="mid"><div class="name">${esc(h.name)}</div><div class="last">${h.online ? "在線上" : "離線(" + esc(h.last_seen_text || "很久沒連線") + ")"}${note(h)}</div></div></div>`).join("")
: `<div class="empty">這個帳號還沒有連上任何 AnyBot。<br>在電腦上打開 AnyBot › 設定 › 手機與帳號,登入同一個帳號。</div>`;
document.querySelectorAll("#hosts .row").forEach((r) => r.onclick = () => { S.host = r.dataset.h; localStorage.setItem("anybot.host", S.host); start(); });
show("hosts");
@ -517,11 +542,38 @@ $("typein").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.isC
$("k-right").onclick = () => { S.right = !S.right; $("k-right").textContent = "右鍵:" + (S.right ? "開" : "關"); };
$("k-wake").onclick = () => { toast("正在叫醒 Bot 的電腦…"); input2({kind: "wake"}); };
// ---------- menu, notifications ----------
$("b-more").onclick = () => { $("m-host").style.display = S.relay ? "" : "none"; notifyLabel(); $("sheet").classList.add("on"); };
$("b-more").onclick = () => { $("m-host").style.display = S.relay ? "" : "none"; $("m-pair").style.display = S.relay ? "" : "none"; notifyLabel(); $("sheet").classList.add("on"); };
$("sheet").onclick = (e) => { if (e.target.id === "sheet") $("sheet").classList.remove("on"); };
$("m-cancel").onclick = () => $("sheet").classList.remove("on");
$("m-out").onclick = () => { $("sheet").classList.remove("on"); if (confirm("要登出這支手機嗎?")) signOut(); };
$("m-host").onclick = () => { $("sheet").classList.remove("on"); S.host = ""; localStorage.removeItem("anybot.host"); S.version = 0; pickHost(); };
// 加一台電腦: a code for a new computer (ten minutes, once), as hands for this AnyBot.
$("m-pair").onclick = async () => {
$("sheet").classList.remove("on");
$("pair-for").textContent = $("host-name").textContent || "這台 AnyBot";
$("pair-code").textContent = "······"; $("pair-line").textContent = "正在產生…"; $("pair-left").textContent = "";
$("pair").classList.add("on");
try {
const v = await api("relay/pair", {json: {for: S.host}});
const line = `anybot setup ${location.origin} ${v.code}`;
$("pair-code").textContent = v.code.slice(0, 3) + " " + v.code.slice(3);
$("pair-line").textContent = line;
const end = Date.now() + (v.expires_in || 600) * 1000;
clearInterval(S.pairTimer);
S.pairTimer = setInterval(() => {
const left = Math.max(0, Math.round((end - Date.now()) / 1000));
$("pair-left").textContent = left ? `還有 ${Math.floor(left / 60)} 分 ${String(left % 60).padStart(2, "0")} 秒,只能用一次` : "過期了,關掉再按一次「加一台電腦」";
if (!left) clearInterval(S.pairTimer);
}, 1000);
} catch (e) { $("pair-line").textContent = "沒有拿到配對碼:" + e.message; }
};
$("pair-copy").onclick = async () => {
const line = $("pair-line").textContent;
if (!line.startsWith("anybot")) return;
try { await navigator.clipboard.writeText(line); toast("已複製"); } catch (_) { toast("請長按上面那一行複製"); }
};
$("pair-close").onclick = () => { clearInterval(S.pairTimer); $("pair").classList.remove("on"); };
$("pair").onclick = (e) => { if (e.target.id === "pair") $("pair-close").click(); };
const pushOK = () => S.relay && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
async function notifyLabel() {
const b = $("m-notify"), n = $("m-note");

View File

@ -3,9 +3,12 @@
anybot.example.com {
encode gzip
reverse_proxy relay:8080 {
# The phone's "anything new?" request waits up to 35 s.
# The phone's "anything new?" request waits up to 35 s. Each AnyBot also keeps one
# WebSocket open here (pinged every 15–20 s, so it never sits idle for 60 s).
transport http {
read_timeout 60s
}
# Reloading this file doesn't cut those connections at once.
stream_close_delay 5m
}
}

161
relay/Cargo.lock generated
View File

@ -26,6 +26,7 @@ dependencies = [
"ring",
"serde",
"serde_json",
"tungstenite",
"ureq",
]
@ -47,6 +48,15 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@ -75,6 +85,17 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures",
"rand_core",
]
[[package]]
name = "chrono"
version = "0.4.45"
@ -88,12 +109,27 @@ dependencies = [
"windows-link",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.4"
@ -103,6 +139,32 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.15"
@ -155,6 +217,18 @@ dependencies = [
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"rand_core",
]
[[package]]
name = "http"
version = "1.5.0"
@ -171,6 +245,15 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@ -285,6 +368,29 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "ring"
version = "0.17.14"
@ -293,7 +399,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys",
@ -383,6 +489,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
@ -429,6 +546,48 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "tungstenite"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"sha1",
"thiserror",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.27"

View File

@ -12,6 +12,7 @@ ring = "0.17"
base64 = "0.22"
chrono = "0.4"
ureq = "3"
tungstenite = { version = "0.30", default-features = false, features = ["handshake"] }
[profile.release]

View File

@ -3,6 +3,7 @@
FROM rust:1-bookworm AS build
WORKDIR /src
COPY src/httpd.rs src/httpd.rs
COPY src/wire.rs src/wire.rs
COPY mobile mobile
COPY relay relay
RUN cd relay && cargo build --release

View File

@ -14,6 +14,8 @@
#[path = "../../src/httpd.rs"]
mod httpd;
#[path = "../../src/wire.rs"]
mod wire;
mod push;
use httpd::{Req, Resp};
@ -22,6 +24,7 @@ use serde_json::{json, Value};
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::sync::mpsc::{channel, Sender};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
@ -33,6 +36,15 @@ const ICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 9
const HOST_POLL_SECS: u64 = 25;
/// How long a phone's request waits for the AnyBot to answer (its own long-poll is 20 s).
const FORWARD_SECS: u64 = 35;
/// How long a 配對碼 lasts.
const PAIR_SECS: u64 = 600;
/// How long a request may wait to be picked up before the host counts as gone.
const PICKUP_SECS: u64 = 8;
/// The lasting connection: a ping this often, and given up after this long without a word.
const WS_PING_SECS: u64 = 20;
const WS_DEAD_SECS: u64 = 60;
const GONE: &str = "那台 AnyBot 斷線了(網路中斷,或 AnyBot 關掉了)";
const OFFLINE: &str = "那台 AnyBot 目前離線(電腦可能關機、睡眠、斷網,或 AnyBot 沒開)";
/// A host counts as online when it asked within this many seconds.
const ONLINE_SECS: u64 = 60;
/// Sessions last this long without use.
@ -122,6 +134,51 @@ self.addEventListener("notificationclick", (e) => {
});
"#;
/// A forwarded request's answer.
struct Answer {
status: u16,
ctype: String,
headers: Vec<(String, String)>,
body: Vec<u8>,
/// The body is deflate-compressed (only when the asker said it can read that).
z: bool,
}
impl Answer {
fn error(status: u16, msg: &str) -> Answer {
Answer { status, ctype: "application/json; charset=utf-8".into(), headers: vec![], body: json!({"error": msg}).to_string().into_bytes(), z: false }
}
fn from_json(v: &Value) -> Answer {
use base64::Engine;
Answer {
status: v["status"].as_u64().unwrap_or(502) as u16,
ctype: v["ctype"].as_str().unwrap_or("application/octet-stream").to_string(),
headers: v["headers"].as_array().into_iter().flatten().filter_map(|h| Some((h[0].as_str()?.to_string(), h[1].as_str()?.to_string()))).collect(),
body: base64::engine::general_purpose::STANDARD.decode(v["body"].as_str().unwrap_or("")).unwrap_or_default(),
z: v["z"].as_bool().unwrap_or(false),
}
}
fn into_resp(self) -> Resp {
let mut resp = Resp::bytes(&self.ctype, self.body);
resp.status = self.status;
for (k, v) in self.headers {
// Only harmless headers pass through.
if k.eq_ignore_ascii_case("x-size") {
resp.headers.push((k, v));
}
}
resp
}
}
/// An AnyBot's lasting connection: frames to write to it.
struct Sock {
tx: Sender<Vec<u8>>,
serial: u64,
}
static SERIAL: AtomicU64 = AtomicU64::new(1);
struct Relay {
dir: PathBuf,
data: Mutex<Data>,
@ -129,7 +186,15 @@ struct Relay {
queues: Mutex<HashMap<String, VecDeque<Value>>>,
arrived: Condvar,
/// Where each forwarded request's answer goes.
waiting: Mutex<HashMap<String, Sender<Value>>>,
waiting: Mutex<HashMap<String, Sender<Answer>>>,
/// AnyBots connected the lasting way (WebSocket), and which requests went over which.
sockets: Mutex<HashMap<String, Sock>>,
flights: Mutex<HashMap<String, (String, u64)>>,
/// The brain each AnyBot shows (when it is a screen of another), and which lend their hands.
uses: Mutex<HashMap<String, String>>,
hands: Mutex<std::collections::HashSet<String>>,
/// 配對碼: code → (account, the AnyBot it is for, when made). Ten minutes, once.
pairs: Mutex<HashMap<String, (String, String, Instant)>>,
/// When each host last asked (in memory; saved now and then).
seen: Mutex<HashMap<String, Instant>>,
fails: Mutex<HashMap<String, (u32, Instant)>>,
@ -153,6 +218,11 @@ impl Relay {
queues: Mutex::new(HashMap::new()),
arrived: Condvar::new(),
waiting: Mutex::new(HashMap::new()),
sockets: Mutex::new(HashMap::new()),
flights: Mutex::new(HashMap::new()),
uses: Mutex::new(HashMap::new()),
hands: Mutex::new(std::collections::HashSet::new()),
pairs: Mutex::new(HashMap::new()),
seen: Mutex::new(HashMap::new()),
fails: Mutex::new(HashMap::new()),
invite: std::env::var("ANYBOT_RELAY_INVITE").unwrap_or_default(),
@ -199,7 +269,7 @@ impl Relay {
}
fn online(&self, id: &str) -> bool {
self.seen.lock().unwrap().get(id).map(|t| t.elapsed().as_secs() < ONLINE_SECS).unwrap_or(false)
self.sockets.lock().unwrap().contains_key(id) || self.seen.lock().unwrap().get(id).map(|t| t.elapsed().as_secs() < ONLINE_SECS).unwrap_or(false)
}
fn too_many_fails(&self, key: &str) -> bool {
@ -219,37 +289,189 @@ impl Relay {
/// Hand a request to a host and wait for its answer.
fn forward(&self, host_id: &str, method: &str, path: &str, query: &HashMap<String, String>, body: &[u8], who: (&str, &str)) -> Resp {
self.forward_raw(host_id, method, path, query, body, false, who, false, "").into_resp()
}
/// `z`: the body is compressed; `z_ok`: the asker reads a compressed answer.
#[allow(clippy::too_many_arguments)]
/// `from`: the AnyBot of the account asking (empty for the phone).
fn forward_raw(&self, host_id: &str, method: &str, path: &str, query: &HashMap<String, String>, body: &[u8], z: bool, who: (&str, &str), z_ok: bool, from: &str) -> Answer {
use base64::Engine;
if !self.online(host_id) {
return Resp::error(503, "那台 AnyBot 目前離線(電腦關機、睡眠,或 AnyBot 沒開)");
return Answer::error(503, OFFLINE);
}
let id = httpd::random_hex(12);
let (tx, rx) = channel();
self.waiting.lock().unwrap().insert(id.clone(), tx);
let job = json!({"id": id, "method": method, "path": path, "query": query, "body": base64::engine::general_purpose::STANDARD.encode(body), "user": who.0, "role": who.1});
let head = json!({"t": "job", "id": id, "method": method, "path": path, "query": query, "user": who.0, "role": who.1, "z": z, "z_ok": z_ok, "from": from});
// Connected the lasting way: straight down that connection.
let sock = self.sockets.lock().unwrap().get(host_id).map(|s| (s.tx.clone(), s.serial));
if let Some((stx, serial)) = sock {
self.flights.lock().unwrap().insert(id.clone(), (host_id.to_string(), serial));
let sent = stx.send(wire::pack(&head, body)).is_ok();
let r = if sent { rx.recv_timeout(Duration::from_secs(FORWARD_SECS)).ok() } else { None };
self.waiting.lock().unwrap().remove(&id);
self.flights.lock().unwrap().remove(&id);
return match r {
Some(a) => a,
None if !sent => Answer::error(503, GONE),
None => Answer::error(504, "那台 AnyBot 收到了,但太久沒有回應(可能太忙,或網路很慢)"),
};
}
let mut job = head;
job["body"] = json!(base64::engine::general_purpose::STANDARD.encode(body));
self.queues.lock().unwrap().entry(host_id.to_string()).or_default().push_back(job);
self.arrived.notify_all();
let r = rx.recv_timeout(Duration::from_secs(FORWARD_SECS));
// A running AnyBot always has a worker waiting, so it picks a request up within a moment.
// Still in the queue after a few seconds means it went away (closed, asleep, offline).
let mut r = rx.recv_timeout(Duration::from_secs(PICKUP_SECS));
if r.is_err() {
let queued = self.queues.lock().unwrap().get(host_id).map(|q| q.iter().any(|j| j["id"].as_str() == Some(id.as_str()))).unwrap_or(false);
if !queued {
r = rx.recv_timeout(Duration::from_secs(FORWARD_SECS - PICKUP_SECS));
}
}
self.waiting.lock().unwrap().remove(&id);
// Not picked up in time: drop it so it doesn't run late.
if let Some(q) = self.queues.lock().unwrap().get_mut(host_id) {
let was_queued = self.queues.lock().unwrap().get_mut(host_id).map(|q| {
let n = q.len();
q.retain(|j| j["id"].as_str() != Some(id.as_str()));
}
n != q.len()
});
match r {
Ok(v) => {
let mut resp = Resp::bytes(v["ctype"].as_str().unwrap_or("application/octet-stream"), base64::engine::general_purpose::STANDARD.decode(v["body"].as_str().unwrap_or("")).unwrap_or_default());
resp.status = v["status"].as_u64().unwrap_or(502) as u16;
for h in v["headers"].as_array().into_iter().flatten() {
if let (Some(k), Some(val)) = (h[0].as_str(), h[1].as_str()) {
// Only harmless headers pass through.
if k.eq_ignore_ascii_case("x-size") {
resp.headers.push((k.into(), val.into()));
}
}
Ok(a) => a,
Err(_) if was_queued == Some(true) => Answer::error(503, OFFLINE),
Err(_) => Answer::error(504, "那台 AnyBot 收到了,但太久沒有回應(可能太忙,或網路很慢)"),
}
}
/// 長連線: an AnyBot keeps one WebSocket open; requests for it come down it at once, and its
/// own requests to the account's other AnyBots go up it.
fn ws(self: &Arc<Self>, req: Req, mut c: std::net::TcpStream) {
use std::io::Write;
use tungstenite::{protocol::Role, Message, WebSocket};
if req.path != "/relay/host/ws" {
return httpd::write_response(&mut c, &Resp::error(404, "沒有這個網址"));
}
let Some(h) = self.host(&req) else { return httpd::write_response(&mut c, &Resp::error(401, "這台電腦的金鑰無效")) };
let key = req.header("sec-websocket-key").to_string();
if key.is_empty() {
return httpd::write_response(&mut c, &Resp::error(400, "bad request"));
}
let accept = tungstenite::handshake::derive_accept_key(key.as_bytes());
if c.write_all(format!("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n").as_bytes()).is_err() {
return;
}
let _ = c.set_read_timeout(Some(Duration::from_millis(40)));
let _ = c.set_nodelay(true);
let cfg = tungstenite::protocol::WebSocketConfig::default().max_message_size(Some(httpd::MAX_BODY * 2)).max_frame_size(Some(httpd::MAX_BODY * 2));
let mut ws = WebSocket::from_raw_socket(c, Role::Server, Some(cfg));
let serial = SERIAL.fetch_add(1, Ordering::SeqCst);
let (tx, rx) = channel::<Vec<u8>>();
self.sockets.lock().unwrap().insert(h.id.clone(), Sock { tx: tx.clone(), serial });
self.seen.lock().unwrap().insert(h.id.clone(), Instant::now());
let (mut heard, mut pinged, mut marked) = (Instant::now(), Instant::now(), Instant::now());
'live: loop {
match ws.read() {
Ok(Message::Binary(b)) => {
heard = Instant::now();
self.on_frame(&h, &b, &tx);
}
resp
Ok(Message::Close(_)) => break,
Ok(_) => heard = Instant::now(),
Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {}
Err(_) => break,
}
Err(_) => Resp::error(504, "那台 AnyBot 沒有及時回應"),
while let Ok(f) = rx.try_recv() {
if ws.write(Message::Binary(f.into())).is_err() {
break 'live;
}
}
if pinged.elapsed().as_secs() >= WS_PING_SECS {
pinged = Instant::now();
if ws.write(Message::Ping(Vec::new().into())).is_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(()) => {}
}
if heard.elapsed().as_secs() >= WS_DEAD_SECS {
break;
}
if marked.elapsed().as_secs() >= 10 {
marked = Instant::now();
self.seen.lock().unwrap().insert(h.id.clone(), Instant::now());
}
}
// Gone: whoever waits on it hears so now, not after a timeout.
{
let mut socks = self.sockets.lock().unwrap();
if socks.get(&h.id).map(|s| s.serial) == Some(serial) {
socks.remove(&h.id);
self.seen.lock().unwrap().remove(&h.id);
}
}
let lost: Vec<String> = self.flights.lock().unwrap().iter().filter(|(_, (host, s))| *host == h.id && *s == serial).map(|(id, _)| id.clone()).collect();
for id in lost {
if let Some(w) = self.waiting.lock().unwrap().remove(&id) {
let _ = w.send(Answer::error(503, GONE));
}
}
}
fn on_frame(self: &Arc<Self>, h: &Host, b: &[u8], tx: &Sender<Vec<u8>>) {
let Some((head, body)) = wire::unpack(b) else { return };
let s = |k: &str| head[k].as_str().unwrap_or("").to_string();
match head["t"].as_str().unwrap_or("") {
"reply" => {
if let Some(w) = self.waiting.lock().unwrap().remove(&s("id")) {
let headers = head["headers"].as_array().into_iter().flatten().filter_map(|x| Some((x[0].as_str()?.to_string(), x[1].as_str()?.to_string()))).collect();
let _ = w.send(Answer { status: head["status"].as_u64().unwrap_or(502) as u16, ctype: s("ctype"), headers, body, z: head["z"].as_bool().unwrap_or(false) });
}
}
// One AnyBot asks another of the same account (大腦 sync, 裝置連結).
"call" => {
let (id, to, path) = (s("id"), s("to"), s("path"));
let role = if path == "/api/device/run" {
"device"
} else if path == "/api/sync" || path.starts_with("/api/sync/") {
"mirror"
} else {
let a = Answer::error(404, "沒有這個網址");
let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype}), &a.body));
return;
};
let same = self.data.lock().unwrap().hosts.iter().any(|x| x.id == to && x.user == h.user);
if !same || to == h.id {
let a = Answer::error(403, "不是同一個帳號的電腦");
let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype}), &a.body));
return;
}
let query: HashMap<String, String> = head["query"].as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default();
let (me, tx, user, method, z, from) = (self.clone(), tx.clone(), h.user.clone(), s("method"), head["z"].as_bool().unwrap_or(false), h.id.clone());
std::thread::spawn(move || {
let a = me.forward_raw(&to, &method, &path, &query, &body, z, (&user, role), true, &from);
let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype, "z": a.z}), &a.body));
});
}
"hello" => {
if head["hands"].as_bool().unwrap_or(false) {
self.hands.lock().unwrap().insert(h.id.clone());
} else {
self.hands.lock().unwrap().remove(&h.id);
}
let brain = s("brain");
let mut u = self.uses.lock().unwrap();
if brain.is_empty() {
u.remove(&h.id);
} else {
u.insert(h.id.clone(), brain);
}
}
_ => {}
}
}
@ -338,19 +560,47 @@ impl Relay {
("POST", ["relay", "hosts"]) => {
let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") };
let name: String = req.json()["name"].as_str().unwrap_or("AnyBot").chars().take(60).collect();
let token = httpd::random_hex(32);
let h = Host { id: httpd::random_hex(8), name: name.clone(), user: user.clone(), token_hash: httpd::sha256_hex(&token), created: now(), last_seen: 0, members: vec![] };
let mut d = self.data.lock().unwrap();
// Signing the same computer in again replaces its old key (keeping who it's shared with).
let mut h = h;
if let Some(old) = d.hosts.iter().find(|x| x.user == user && x.name == name) {
h.members = old.members.clone();
}
d.hosts.retain(|x| !(x.user == user && x.name == name));
d.hosts.push(h.clone());
self.save(&d);
let (h, token) = self.add_host(&user, &name);
Resp::json(&json!({"id": h.id, "token": token}))
}
// 加一台電腦: a signed-in phone or AnyBot asks for a code; the new computer gives it back.
("POST", ["relay", "pair"]) => {
let Some(user) = self.session(&req).or_else(|| self.host(&req).map(|h| h.user)) else { return Resp::error(401, "請先登入") };
let want = req.json()["for"].as_str().unwrap_or("").to_string();
let for_host = if self.data.lock().unwrap().hosts.iter().any(|h| h.id == want && h.user == user) { want } else { String::new() };
let mut pairs = self.pairs.lock().unwrap();
pairs.retain(|_, p| p.2.elapsed().as_secs() < PAIR_SECS);
let code = loop {
let n = u32::from_str_radix(&httpd::random_hex(4), 16).unwrap_or(0) % 1_000_000;
let c = format!("{n:06}");
if !pairs.contains_key(&c) {
break c;
}
};
pairs.insert(code.clone(), (user, for_host, Instant::now()));
Resp::json(&json!({"code": code, "expires_in": PAIR_SECS}))
}
("POST", ["relay", "pair", "claim"]) => {
let key = format!("pair:{}", req.peer);
if self.too_many_fails(&key) {
return Resp::error(429, "配對碼錯太多次,請 5 分鐘後再試");
}
let v = req.json();
let code: String = v["code"].as_str().unwrap_or("").chars().filter(|c| c.is_ascii_digit()).collect();
let got = {
let mut pairs = self.pairs.lock().unwrap();
pairs.retain(|_, p| p.2.elapsed().as_secs() < PAIR_SECS);
pairs.remove(&code)
};
let Some((user, for_host, _)) = got else {
self.failed(&key);
return Resp::error(404, "配對碼不對,或已經過期(10 分鐘內有效,只能用一次)");
};
let name: String = v["name"].as_str().unwrap_or("AnyBot").chars().take(60).collect();
let (h, token) = self.add_host(&user, &name);
let for_name = self.data.lock().unwrap().hosts.iter().find(|x| x.id == for_host).map(|x| x.name.clone()).unwrap_or_default();
Resp::json(&json!({"id": h.id, "token": token, "user": user, "for": for_host, "for_name": for_name}))
}
("POST", ["relay", "hosts", id, "remove"]) => {
let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") };
let mut d = self.data.lock().unwrap();
@ -374,7 +624,7 @@ impl Relay {
return Resp::error(401, "這台電腦的金鑰無效");
}
if let Some(tx) = self.waiting.lock().unwrap().remove(*id) {
let _ = tx.send(req.json());
let _ = tx.send(Answer::from_json(&req.json()));
}
Resp::json(&json!({"ok": true}))
}
@ -497,7 +747,17 @@ impl Relay {
if !same || *target == me.id {
return Resp::error(403, "不是同一個帳號的電腦");
}
self.forward(target, "POST", "/api/device/run", &HashMap::new(), &req.body, (&me.user, "device"))
self.forward_raw(target, "POST", "/api/device/run", &HashMap::new(), &req.body, false, (&me.user, "device"), false, &me.id).into_resp()
}
// 大腦: one AnyBot of the account shows (and edits) another one's bots and chats.
(m, ["relay", "hosts", target, "api", "sync", rest @ ..]) => {
let Some(me) = self.host(&req) else { return Resp::error(401, "這台電腦的金鑰無效") };
let same = self.data.lock().unwrap().hosts.iter().any(|h| h.id == *target && h.user == me.user);
if !same || *target == me.id {
return Resp::error(403, "不是同一個帳號的電腦");
}
let path = if rest.is_empty() { "/api/sync".to_string() } else { format!("/api/sync/{}", rest.join("/")) };
self.forward_raw(target, m, &path, &req.query, &req.body, false, (&me.user, "mirror"), false, &me.id).into_resp()
}
// The phone, through to one of the account's AnyBots.
(m, ["h", host, rest @ ..]) => {
@ -515,9 +775,24 @@ impl Relay {
}
}
/// Register an AnyBot of `user`. The same name again replaces its old key (keeping who it's
/// shared with).
fn add_host(&self, user: &str, name: &str) -> (Host, String) {
let token = httpd::random_hex(32);
let mut h = Host { id: httpd::random_hex(8), name: name.to_string(), user: user.to_string(), token_hash: httpd::sha256_hex(&token), created: now(), last_seen: 0, members: vec![] };
let mut d = self.data.lock().unwrap();
if let Some(old) = d.hosts.iter().find(|x| x.user == user && x.name == name) {
h.members = old.members.clone();
}
d.hosts.retain(|x| !(x.user == user && x.name == name));
d.hosts.push(h.clone());
self.save(&d);
(h, token)
}
fn host_json(&self, h: &Host) -> Value {
let online = self.online(&h.id);
json!({"id": h.id, "name": h.name, "online": online, "last_seen_text": if online { "在線上".to_string() } else if h.last_seen > 0 { chrono::DateTime::from_timestamp(h.last_seen, 0).map(|t| t.format("%m/%d %H:%M UTC").to_string()).unwrap_or_default() } else { "從未連線".into() }})
json!({"id": h.id, "name": h.name, "online": online, "uses": self.uses.lock().unwrap().get(&h.id).cloned().unwrap_or_default(), "hands": self.hands.lock().unwrap().contains(&h.id), "last_seen_text": if online { "在線上".to_string() } else if h.last_seen > 0 { chrono::DateTime::from_timestamp(h.last_seen, 0).map(|t| t.format("%m/%d %H:%M UTC").to_string()).unwrap_or_default() } else { "從未連線".into() }})
}
fn signup(&self, req: &Req) -> Resp {
@ -591,6 +866,10 @@ fn main() {
let port: u16 = arg("--port").and_then(|p| p.parse().ok()).or_else(|| std::env::var("PORT").ok().and_then(|p| p.parse().ok())).unwrap_or(8080);
let bind = arg("--bind").unwrap_or_else(|| "0.0.0.0".into());
let dir = PathBuf::from(arg("--data").unwrap_or_else(|| "relay-data".into()));
// Forgot the password: only someone on this computer can set a new one.
if let Some(user) = arg("--reset-password") {
std::process::exit(reset_password(&dir, &user.trim().to_lowercase(), port));
}
let relay = Arc::new(Relay::load(dir.clone()));
let listener = std::net::TcpListener::bind((bind.as_str(), port)).unwrap_or_else(|e| {
eprintln!("無法使用連接埠 {port}:{e}");
@ -605,8 +884,54 @@ fn main() {
std::thread::sleep(Duration::from_secs(60));
r2.save_seen();
});
let r = relay.clone();
httpd::serve(listener, Arc::new(move |req| r.handle(req)), Arc::new(|| true));
let (r, w) = (relay.clone(), relay.clone());
httpd::serve_upgrade(listener, Arc::new(move |req| r.handle(req)), Some(Arc::new(move |req, c| w.ws(req, c))), Arc::new(|| true));
}
/// `anybot-relay --data DIR --reset-password NAME`: a new password for an account (asked twice,
/// not shown). The server must be stopped first, or it would write its own copy back.
fn reset_password(dir: &std::path::Path, user: &str, port: u16) -> i32 {
if std::net::TcpStream::connect_timeout(&std::net::SocketAddr::from(([127, 0, 0, 1], port)), Duration::from_millis(500)).is_ok() {
eprintln!("帳號伺服器還在執行(埠 {port})。先停掉它再重設密碼,之後再啟動。");
return 1;
}
let relay = Relay::load(dir.to_path_buf());
if !relay.data.lock().unwrap().users.iter().any(|u| u.name == user) {
eprintln!("{} 裡沒有帳號「{user}」。", dir.display());
return 1;
}
let ask = |prompt: &str| -> String {
use std::io::Write;
eprint!("{prompt}");
let _ = std::io::stderr().flush();
let hide = std::process::Command::new("stty").arg("-echo").stdin(std::process::Stdio::inherit()).stderr(std::process::Stdio::null()).status().map(|s| s.success()).unwrap_or(false);
let mut s = String::new();
let _ = std::io::stdin().read_line(&mut s);
if hide {
let _ = std::process::Command::new("stty").arg("echo").stdin(std::process::Stdio::inherit()).stderr(std::process::Stdio::null()).status();
}
eprintln!();
s.trim_end_matches(['\n', '\r']).to_string()
};
let pw = ask(&format!("「{user}」的新密碼(至少 8 個字):"));
if pw.chars().count() < 8 {
eprintln!("密碼至少 8 個字,沒有改。");
return 1;
}
if ask("再輸入一次:") != pw {
eprintln!("兩次不一樣,沒有改。");
return 1;
}
let mut d = relay.data.lock().unwrap();
if let Some(u) = d.users.iter_mut().find(|u| u.name == user) {
u.pass = httpd::hash_password(&pw);
}
// Phones signed in with the old password sign in again.
d.sessions.retain(|s| s.user != user);
relay.save(&d);
eprintln!("已改好「{user}」的密碼。啟動帳號伺服器後,用新密碼登入。");
eprintln!("(帳號備份是用舊密碼加密的,之後要先在電腦上按一次「立刻備份」。)");
0
}
#[cfg(test)]
@ -759,4 +1084,41 @@ mod tests {
assert_eq!(post(port, "/relay/host/sites/Bad_Name", &ht, json!({})).0, 400);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_pairing_code_adds_a_computer_once() {
let dir = std::env::temp_dir().join(format!("anybot-relay-pair-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let relay = Arc::new(Relay::load(dir.clone()));
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
let r = relay.clone();
std::thread::spawn(move || httpd::serve(l, Arc::new(move |req| r.handle(req)), Arc::new(|| true)));
post(port, "/relay/signup", "", json!({"user": "amy", "password": "12345678"}));
let (_, v) = post(port, "/relay/login", "", json!({"user": "amy", "password": "12345678", "device": "phone"}));
let session = v["token"].as_str().unwrap().to_string();
let (_, brain) = post(port, "/relay/hosts", &session, json!({"name": "雲端大腦"}));
// Asked from the phone, for the brain.
let (st, p) = post(port, "/relay/pair", &session, json!({"for": brain["id"]}));
assert_eq!(st, 200);
let code = p["code"].as_str().unwrap().to_string();
assert_eq!(code.len(), 6);
// Without signing in: no code.
assert_eq!(post(port, "/relay/pair", "", json!({})).0, 401);
let (st, h) = post(port, "/relay/pair/claim", "", json!({"code": format!("{} {}", &code[..3], &code[3..]), "name": "書房電腦"}));
assert_eq!(st, 200, "{h}");
assert_eq!(h["user"], "amy");
assert_eq!(h["for_name"], "雲端大腦");
// The new computer's key works.
let (st, peers) = get(port, "/relay/host/peers", h["token"].as_str().unwrap());
assert_eq!(st, 200);
assert!(String::from_utf8_lossy(&peers).contains("雲端大腦"));
// Once only; a wrong code is refused, and guessing is stopped.
assert_eq!(post(port, "/relay/pair/claim", "", json!({"code": code, "name": "x"})).0, 404);
for _ in 0..10 {
post(port, "/relay/pair/claim", "", json!({"code": "000000", "name": "x"}));
}
assert_eq!(post(port, "/relay/pair/claim", "", json!({"code": "000000", "name": "x"})).0, 429);
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -114,6 +114,16 @@ fn target_for(job: &Job, args: &str) -> Result<tools::Target, String> {
})
}
/// The other computer (裝置連結) a call names, if it names one.
fn device_named(job: &Job, args: &str) -> Option<String> {
let v = tools::args_of(args);
let n = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty())?;
job.computers.iter().find(|t| t.name == n).and_then(|t| match &t.place {
tools::Place::Device { host } => Some(host.clone()),
_ => None,
})
}
/// Rough token count: about 4 Latin characters or 1 Chinese character per token (estimated).
/// Which screen a computer_use call is for: the one named, else the bot's default when it has a
/// screen, else the bot's own desktop when there is one, else this Mac.
@ -321,7 +331,14 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
// …and neither does AnyBot's own browser (like Grok Bot's own browser).
if call.name == "web_fetch" || (web::is_web_tool(&call.name) && !web::uses_this_computer(&call.name, &call.args)) {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
let (output, ok, img) = web::run_full(&call.name, &call.args, &cancel);
// 裝置連結: the AnyBot browser of another computer (its own logins).
let (output, ok, img) = match device_named(&job, &call.args).filter(|_| call.name != "web_fetch") {
Some(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) {
Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false), v["image"].as_str().map(String::from)),
Err(e) => (e, false, None),
},
None => web::run_full(&call.name, &call.args, &cancel),
};
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let mut result = Message::tool_result(&call.id, &call.name, &output);
result.images.extend(img);

View File

@ -33,6 +33,7 @@ mod sidebar_ui;
mod menu_ui;
mod vault_ui;
mod markdown_ui;
mod mirror_ui;
// Sidebar rules from index.eager-app: defaults `FT={expandedWidth:280}` (@912025),
// clamp `jT(..,240,400)` (@914394), and the drag rule in `QJe` (@969523):
@ -197,11 +198,16 @@ pub struct App {
group_menu_for: Option<String>,
/// When the launch greeting started.
splash_at: Option<f64>,
/// 大腦: this window shows another AnyBot's bots (a screen), or answers screens (the brain).
mir: mirror_ui::Mirror,
scr: mirror_ui::Screens,
ctx_handle: Option<egui::Context>,
}
impl App {
pub fn new(ctx: &egui::Context, screenshot: Option<PathBuf>) -> Self {
let demo = screenshot.is_some();
// ANYBOT_SHOT_REAL=1: a screenshot of the real data (and connections), not the demo.
let demo = screenshot.is_some() && std::env::var("ANYBOT_SHOT_REAL").is_err();
let state = if demo { State::demo() } else { State::load() };
let (tx, rx) = channel();
let mut app = App {
@ -279,6 +285,9 @@ impl App {
renaming: None,
group_menu_for: None,
splash_at: None,
mir: Default::default(),
scr: Default::default(),
ctx_handle: None,
};
crate::i18n::set(&app.state.language);
app.computers_start();
@ -509,6 +518,11 @@ impl App {
}
}
app.apply_theme(ctx);
// Real-data screenshots can open 設定 › 手機與帳號 (to press its buttons with ANYBOT_SHOT_CLICK).
if app.screenshot.is_some() && !app.demo && std::env::var("ANYBOT_SHOT_VIEW").as_deref() == Ok("remote") {
app.show_settings = true;
app.settings_tab = SettingsTab::Remote;
}
app
}
@ -593,8 +607,14 @@ impl App {
let quoted: String = q.lines().take(8).map(|l| format!("> {l}\n")).collect();
text = format!("{quoted}\n{}", text.trim());
}
self.drafts.insert(bot_id, String::new());
let attachments = self.take_attachments(bot_id);
if self.mir.on {
if self.mirror_send(bot_id, text.trim().to_string(), attachments) {
self.drafts.insert(bot_id, String::new());
}
return;
}
self.drafts.insert(bot_id, String::new());
// While it works, a new message steers the running turn (the original's `turnSteer`:
// it doesn't count as a new turn) and reaches the model at its next step.
if let Some(l) = self.streams.get(&bot_id) {
@ -609,6 +629,10 @@ impl App {
}
fn start_reply(&mut self, ctx: &egui::Context, bot_id: u64) {
if self.mir.on {
self.mirror_act(serde_json::json!({"bot": bot_id, "action": "reply"}));
return;
}
let Some(conv) = self.state.bot(bot_id).cloned() else { return };
// 群組聊天: the next member answers, with its own model, instructions and computers.
let speaker = if conv.members.is_empty() {
@ -642,7 +666,8 @@ impl App {
let default_computer = if names.contains(&b.default_computer) { b.default_computer.clone() } else { crate::model::BOT_COMPUTER_NAME.to_string() };
let mut tool_specs = appctl::specs();
tool_specs.extend(tools::specs_for(&names, &default_computer));
tool_specs.extend(web::specs());
let devices: Vec<String> = computers.iter().filter(|t| matches!(t.place, tools::Place::Device { .. })).map(|t| t.name.clone()).collect();
tool_specs.extend(web::specs_for(&devices));
// Screen tools: the bot's own desktop (when it has one) and this Mac.
let mut screens = vec![];
if crate::cloudbox::available() {
@ -682,6 +707,8 @@ impl App {
let default_screen = if screens.contains(&default_computer) { default_computer.clone() } else { screens[0].clone() };
tool_specs.extend(crate::computer::specs_for(&screens, &default_screen));
}
// 看圖小幫手: the person's models that may see pictures, for a bot whose model can't.
providers::set_eyes(self.eyes());
let req = ChatRequest {
kind: b.provider,
base_url: providers::resolve_base(b.provider, &ps.base_url),
@ -873,6 +900,12 @@ impl App {
}
fn stop(&mut self, bot_id: u64) {
if self.mir.on {
if self.streams.contains_key(&bot_id) || self.approvals.contains_key(&bot_id) {
self.mirror_act(serde_json::json!({"bot": bot_id, "action": "stop"}));
}
return;
}
self.approvals.remove(&bot_id);
if let Some(call) = self.app_pending.remove(&bot_id) {
let _ = call.reply.send(("已停止".into(), false));
@ -961,6 +994,17 @@ impl App {
}
fn answer(&mut self, ctx: &egui::Context, bot_id: u64, answer: ApprovalAnswer) {
if self.mir.on {
let a = match answer {
ApprovalAnswer::Once => "once",
ApprovalAnswer::Always(_) => "always",
ApprovalAnswer::Reject => "reject",
};
self.approvals.remove(&bot_id);
self.popup_hidden.remove(&bot_id);
self.mirror_act(serde_json::json!({"bot": bot_id, "action": "answer", "answer": a}));
return;
}
if self.want_direct.replace(false) {
self.enable_direct_mode();
}
@ -1004,6 +1048,10 @@ impl App {
}
fn regenerate(&mut self, ctx: &egui::Context, bot_id: u64) {
if self.mir.on {
self.mirror_act(serde_json::json!({"bot": bot_id, "action": "regenerate"}));
return;
}
if self.streams.contains_key(&bot_id) {
return;
}
@ -1204,6 +1252,20 @@ impl App {
}
fn fetch_models(&mut self, kind: ProviderKind) {
// A screen of a brain: the brain has the key, so it asks.
if self.mir.on {
let (tx, rx) = channel();
let s = self.state.remote.clone();
std::thread::spawn(move || {
let name = serde_json::to_value(kind).unwrap_or_default();
let r = crate::remote::sync_post(&s, "models", &serde_json::json!({"provider": name}))
.and_then(|v| v["error"].as_str().map(|e| Err(e.to_string())).unwrap_or_else(|| Ok(v["models"].as_array().into_iter().flatten().filter_map(|m| m.as_str().map(String::from)).collect())));
let _ = tx.send(r);
});
self.model_fetch.insert(kind, rx);
self.fetch_status.insert(kind, "正在請大腦取得模型清單…".into());
return;
}
let ps = self.state.provider(kind);
let base = providers::resolve_base(kind, &ps.base_url);
let key = providers::resolve_key(kind, &ps.api_key);
@ -1237,6 +1299,34 @@ impl App {
}
}
/// Models to describe pictures with, best first: each provider with a key, its own default
/// model, then models of it known to see pictures.
fn eyes(&self) -> Vec<providers::Eyes> {
use ProviderKind as K;
let mut v = vec![];
for kind in [K::Xai, K::Anthropic, K::OpenAi, K::Gemini, K::OpenRouter, K::OpenCodeGo, K::Custom, K::Ollama] {
let ps = self.state.provider(kind);
let key = providers::resolve_key(kind, &ps.api_key);
if kind.needs_key() && key.is_empty() {
continue;
}
if matches!(kind, K::Custom | K::Ollama) && ps.base_url.trim().is_empty() {
continue;
}
let base = providers::resolve_base(kind, &ps.base_url);
let mut models: Vec<String> = vec![];
if !ps.default_model.trim().is_empty() {
models.push(ps.default_model.trim().to_string());
}
models.extend(providers::seeing_models(kind).iter().map(|m| m.to_string()));
models.dedup();
for m in models {
v.push(providers::Eyes { kind, base_url: base.clone(), api_key: key.clone(), model: m });
}
}
v
}
fn models_for(&self, kind: ProviderKind) -> Vec<String> {
let ps = self.state.provider(kind);
let mut v: Vec<String> = if ps.known_models.is_empty() {
@ -1448,7 +1538,9 @@ impl App {
// 討論串: put this conversation aside and start fresh; earlier ones can come back.
if menu_ui::item(ui, p, Icon::NewChat, "開始新對話").clicked() {
self.stop(id);
if let Some(b) = self.state.bot_mut(id) {
if self.mir.on {
self.mirror_act(serde_json::json!({"bot": id, "action": "new"}));
} else if let Some(b) = self.state.bot_mut(id) {
if !b.messages.is_empty() {
let title = b.messages.iter().find(|m| m.role == Role::User).map(|m| first_line(&m.text, 24)).unwrap_or_else(|| "對話".into());
let msgs = std::mem::take(&mut b.messages);
@ -1465,7 +1557,9 @@ impl App {
for (i, title) in threads.iter().enumerate() {
if menu_ui::item_text(ui, p, None, title).clicked() {
self.stop(id);
if let Some(b) = self.state.bot_mut(id) {
if self.mir.on {
self.mirror_act(serde_json::json!({"bot": id, "action": "restore", "index": i}));
} else if let Some(b) = self.state.bot_mut(id) {
let (_, msgs) = b.threads.remove(i);
if !b.messages.is_empty() {
let cur_title = b.messages.iter().find(|m| m.role == Role::User).map(|m| first_line(&m.text, 24)).unwrap_or_else(|| "對話".into());
@ -1564,6 +1658,7 @@ impl App {
return;
};
self.header(ui, &bot);
self.mirror_banner(ui);
// The composer grows with a long draft: keep room for last frame's height.
let h_id = egui::Id::unique("composer-height");
let composer_h: f32 = ui.data(|d| d.get_temp(h_id)).unwrap_or(116.0);
@ -1964,7 +2059,7 @@ impl App {
});
let right_w = 28.0 * 2.0 + 8.0 + 4.0;
let text_w = (ui.available_width() - right_w).max(60.0);
let hint = t("傳訊息給 {name}").replace("{name}", &bot.name);
let hint = if self.mir.on && !self.mir.online { t("連不到大腦,現在只能看") } else { t("傳訊息給 {name}").replace("{name}", &bot.name) };
ui.allocate_ui_with_layout(vec2(text_w, 28.0), Layout::top_down(Align::Min), |ui| {
egui::ScrollArea::vertical().id_salt(("composer_scroll", bot.id)).max_height(160.0).show(ui, |ui| {
ui.add_space(4.0);
@ -2957,11 +3052,18 @@ impl App {
/// Everything that keeps bots working, without drawing (the window runs it every frame;
/// `anybot --serve` runs it on its own).
fn background(&mut self, ctx: &egui::Context) {
self.remote_start(ctx);
self.mirror_tick();
// A screen of another AnyBot's brain: its bots work there, not here.
if self.mir.on {
self.remote_tick(ctx);
self.poll_fetches();
return;
}
self.drain_events(ctx);
if !self.demo {
self.channels_poll(ctx);
}
self.remote_start(ctx);
self.remote_tick(ctx);
self.tick_routines(ctx);
self.watchdog();
@ -2971,7 +3073,11 @@ impl App {
fn save_if_dirty(&mut self) {
if self.dirty && !self.demo && (self.streams.is_empty() || self.last_save.elapsed().as_secs() >= 2) {
self.state.save();
if self.mir.on {
self.mirror_save();
} else {
self.state.save();
}
self.dirty = false;
self.last_save = Instant::now();
}
@ -2985,7 +3091,16 @@ impl App {
return;
}
let Some((url, user)) = acct.rsplit_once('|') else { return };
match crate::remote::relay_join(&self.state.remote, url, user, &pw, false, "") {
// ANYBOT_ACCOUNT_CREATE=1 (the server's own brain, first start): make the account if it isn't there.
let mut r = crate::remote::relay_join(&self.state.remote, url, user, &pw, false, "");
if r.is_err() && std::env::var("ANYBOT_ACCOUNT_CREATE").map(|v| v == "1").unwrap_or(false) {
let invite = std::env::var("ANYBOT_RELAY_INVITE").unwrap_or_default();
if let Ok(s) = crate::remote::relay_join(&self.state.remote, url, user, &pw, true, &invite) {
eprintln!("AnyBot:已建立帳號 {user}");
r = Ok(s);
}
}
match r {
Ok(s) => {
self.state.remote = s;
self.dirty = true;
@ -3033,7 +3148,7 @@ impl App {
}
app.drain_events(ctx);
app.watchdog();
let _ = ctx.run_ui(Default::default(), |_| {});
ctx.run_ui(Default::default(), |_| {}).textures_delta.clear();
std::thread::sleep(std::time::Duration::from_millis(100));
}
let secs = t0.elapsed().as_secs();
@ -3081,6 +3196,11 @@ impl App {
app.dirty = true;
}
}
// 手腳: a computer the brain's bots may operate (screen, files, browser).
if std::env::var("ANYBOT_DEVICE_LINK").map(|v| v == "1").unwrap_or(false) && !app.state.remote.device_link {
app.state.remote.device_link = true;
app.dirty = true;
}
app.serve_join();
app.background(ctx);
if let Some(port) = crate::remote::lan_port() {
@ -3095,16 +3215,28 @@ impl App {
eprintln!("AnyBot:還沒有設定手機密碼或帳號。用 ANYBOT_PHONE_PASSWORD=… 或 ANYBOT_ACCOUNT=網址|帳號 + ANYBOT_ACCOUNT_PASSWORD=… 啟動。");
}
let mut last_try = Instant::now();
let mut last_brain = Instant::now() - std::time::Duration::from_secs(60);
loop {
// The account server may not be up yet (or the account not made yet): keep trying.
if app.state.remote.host_token.is_empty() && last_try.elapsed().as_secs() >= 30 {
last_try = Instant::now();
app.serve_join();
}
// ANYBOT_BRAIN=名稱: show that AnyBot's bots instead of running its own.
if let Ok(want) = std::env::var("ANYBOT_BRAIN") {
if !want.trim().is_empty() && !app.state.remote.host_token.is_empty() && app.state.remote.brain_name != want && last_brain.elapsed().as_secs() >= 20 {
last_brain = Instant::now();
if let Some((id, name, _)) = crate::remote::devices_online(&app.state.remote).into_iter().find(|(_, n, _)| n == want.trim()) {
eprintln!("AnyBot:用「{name}」當大腦,這台當手腳");
app.mir.want = Some((id, name));
app.state.remote.device_link = true;
}
}
}
app.background(ctx);
app.save_if_dirty();
// egui wants a frame now and then to forget old state; nothing is drawn.
let _ = ctx.run_ui(Default::default(), |_| {});
ctx.run_ui(Default::default(), |_| {}).textures_delta.clear();
std::thread::sleep(std::time::Duration::from_millis(if app.streams.is_empty() { 250 } else { 60 }));
}
}
@ -3114,7 +3246,7 @@ impl eframe::App for App {
/// Screenshot mode only: ANYBOT_SHOT_CLICK="x,y@frame[;x,y@frame…][:right]" clicks there at that
/// frame (to check buttons really respond).
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
if !self.demo {
if self.screenshot.is_none() {
return;
}
// ANYBOT_SHOT_DRAG="x1,y1>x2,y2@frame": press, move over 12 frames, release.
@ -3216,6 +3348,10 @@ impl eframe::App for App {
fn on_exit(&mut self) {
// Closing mid-reply ends that turn as cancelled, so it isn't left looking stuck.
// (On a screen of a brain the bots keep working there.)
if self.mir.on {
self.streams.clear();
}
let ids: Vec<u64> = self.streams.keys().copied().collect();
for id in ids {
self.stop(id);
@ -3223,7 +3359,11 @@ impl eframe::App for App {
tools::stop_all();
crate::cloudbox::shutdown();
if !self.demo {
self.state.save();
if self.mir.on {
self.mirror_save();
} else {
self.state.save();
}
}
}
}

View File

@ -8,7 +8,7 @@ use crate::attach::{self, Attachment};
pub struct AttachUi {
pub pending: HashMap<u64, Vec<Attachment>>,
picked: Option<Receiver<(u64, Vec<PathBuf>)>>,
thumbs: HashMap<String, Option<egui::TextureHandle>>,
pub(super) thumbs: HashMap<String, Option<egui::TextureHandle>>,
pub error: Option<String>,
voice: Option<(u64, crate::voice::Recording)>,
voice_rx: Option<Receiver<(u64, Result<String, String>)>>,

View File

@ -351,7 +351,14 @@ impl App {
// ── AnyBot Computer Use: seeing and using this computer's screen ──
self.mac_permissions_card(ui);
// ── Bot 的電腦 ──
// ── Bot 的電腦 ── (on a screen of a brain, it is the brain's)
if self.mir.on {
sec_title(ui, "Bot 的電腦", p, |_| {});
sec_card(ui, p, |ui| {
let name = self.state.remote.brain_name.clone();
sec_row(ui, p, "在大腦上", &format!("Bot 用的電腦在大腦「{name}」上,要在那台管理。這台電腦是大腦的手腳:上面的權限決定 Bot 能不能看這台的畫面、操作這台。"), |_| {});
});
} else {
sec_title(ui, "Bot 的電腦", p, |ui| status_pill(ui, &run, p));
#[derive(PartialEq)]
enum Act {
@ -656,6 +663,8 @@ impl App {
}
}
}
// ── 示範教學、瀏覽器 ──
ui.add_space(SEC_GAP * 0.5);
ui.spacing_mut().item_spacing.y = 6.0;

1318
src/app/mirror_ui.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -51,9 +51,11 @@ impl App {
return;
}
self.rm.started = true;
self.ctx_handle = Some(ctx.clone());
let c = ctx.clone();
remote::hub().set_repaint(Arc::new(move || c.request_repaint()));
self.remote_apply_settings();
self.mirror_boot();
}
/// Hand changed settings to the server threads (and restart what depends on them).
@ -84,8 +86,14 @@ impl App {
}
self.remote_apply_settings();
for c in remote::hub().take_cmds() {
// A screen of another brain has no bots of its own to run phone requests on.
if self.mir.on && !matches!(c, Cmd::DeviceAdded(_) | Cmd::DeviceSeen(_)) {
continue;
}
self.remote_cmd(ctx, c);
}
self.brain_serve();
self.brain_watch();
if let Some(rx) = &self.rm.join_rx {
if let Ok(r) = rx.try_recv() {
self.rm.join_rx = None;
@ -118,7 +126,7 @@ impl App {
}
// 自動備份: once a day while signed in.
let s = &self.state.remote;
if s.auto_backup && !s.backup_key.is_empty() && !s.relay_url.is_empty() && chrono::Utc::now().timestamp() - s.last_backup > 86_400 && !self.rm.busy.load(Ordering::SeqCst) {
if !self.mir.on && s.auto_backup && !s.backup_key.is_empty() && !s.relay_url.is_empty() && chrono::Utc::now().timestamp() - s.last_backup > 86_400 && !self.rm.busy.load(Ordering::SeqCst) {
self.remote_backup();
}
// 裝置連結: keep the list of this account's computers fresh (once a minute).
@ -132,18 +140,75 @@ impl App {
}
});
}
if !self.rm.last_publish.map(|t| t.elapsed().as_millis() < 120).unwrap_or(false) {
if !self.mir.on && !self.rm.last_publish.map(|t| t.elapsed().as_millis() < 120).unwrap_or(false) {
self.remote_publish();
}
}
/// 加一台電腦: one line to run on the new computer (a Linux box, a server); it joins as hands for
/// the brain, with no password and nothing to answer.
fn pair_section(&mut self, ui: &mut egui::Ui) {
let p = self.pal;
ui.add_space(6.0);
ui.separator();
ui.horizontal(|ui| {
ui.label(RichText::new(t("加一台電腦")).size(13.5).strong());
help_mark(ui, "讓另一台電腦(例如沒有螢幕的 Linux)也當大腦的手腳:在那台的終端機貼上這一行就好,不用帳號密碼、不用回答問題。它會設成開機自動執行,只讓這個大腦使用。", p);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(secondary_button("產生配對碼")).clicked() {
let (s, slot) = (self.state.remote.clone(), self.mir.pair.clone());
let for_host = if self.mir.on { s.brain_id.clone() } else { s.host_id.clone() };
*slot.lock().unwrap() = (t("正在產生…"), false, None);
std::thread::spawn(move || {
*slot.lock().unwrap() = match crate::remote::pair_code(&s, &for_host) {
Ok(code) => (format!("anybot setup {} {code}", s.relay_url), true, Some(Instant::now())),
Err(e) => (e, false, None),
};
});
}
});
});
let (line, ok, at) = self.mir.pair.lock().unwrap().clone();
if line.is_empty() {
return;
}
let left = at.map(|t| 600u64.saturating_sub(t.elapsed().as_secs())).unwrap_or(0);
if ok && left == 0 {
hint(ui, "配對碼過期了,再按一次「產生配對碼」。", p);
return;
}
if !ok {
let c = if line.ends_with('…') { p.text2 } else { p.danger };
ui.label(RichText::new(line).size(12.5).color(c));
return;
}
Frame::new().fill(p.hover).corner_radius(8).inner_margin(Margin::symmetric(10, 8)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.add(egui::Label::new(RichText::new(&line).monospace().size(13.0)).selectable(true).wrap());
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(secondary_button("複製")).clicked() {
ui.ctx().copy_text(line.clone());
self.notice = Some(t("已複製,貼到那台電腦的終端機執行"));
}
});
});
});
ui.label(RichText::new(format!("{}(還有 {} 分 {:02} 秒,只能用一次)", t("在那台電腦的終端機貼上執行"), left / 60, left % 60)).size(12.0).color(p.text3));
// Just made: bring it into view.
if at.map(|t| t.elapsed().as_millis() < 800).unwrap_or(false) {
ui.scroll_to_cursor(Some(Align::Max));
}
ui.ctx().request_repaint_after(std::time::Duration::from_secs(1));
}
fn remote_signature(&self) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
self.state.remote.host_name.hash(&mut h);
self.scr.cfg_hash.hash(&mut h);
for b in &self.state.bots {
(b.id, &b.name, &b.color, b.unread, b.messages.len()).hash(&mut h);
if let Some(m) = b.messages.last() {
(m.text.len(), m.tool_calls.len(), m.error).hash(&mut h);
(m.text.len(), m.thinking.len(), m.tool_calls.len(), m.error).hash(&mut h);
for c in &m.tool_calls {
(c.status as u8).hash(&mut h);
}
@ -214,6 +279,27 @@ impl App {
self.start_reply(ctx, bot);
}
Cmd::Stop { bot } => self.stop(bot),
Cmd::Regenerate { bot } => self.regenerate(ctx, bot),
Cmd::Reply { bot } => {
if !self.streams.contains_key(&bot) && self.state.bot(bot).and_then(|b| b.messages.last()).map(|m| m.role == Role::User).unwrap_or(false) {
self.start_reply(ctx, bot);
}
}
Cmd::RestoreThread { bot, index } => {
self.stop(bot);
if let Some(b) = self.state.bot_mut(bot) {
if index < b.threads.len() {
let (_, msgs) = b.threads.remove(index);
if !b.messages.is_empty() {
let title = b.messages.iter().find(|m| m.role == Role::User).map(|m| first_line(&m.text, 24)).unwrap_or_else(|| "對話".into());
let cur = std::mem::take(&mut b.messages);
b.threads.insert(0, (title, cur));
}
b.messages = msgs;
}
}
self.mark();
}
Cmd::Answer { bot, answer } => {
let grant = self.approvals.get(&bot).map(|a| a.grant.clone()).unwrap_or_default();
let a = match answer.as_str() {
@ -380,6 +466,61 @@ impl App {
}
});
// ── 大腦 ──
if !self.state.remote.host_token.is_empty() {
setting_card(ui, p, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(t("大腦")).size(FONT_BASE).strong());
help_mark(ui, "帳號裡選一台 AnyBot 當大腦(通常是跟帳號伺服器放在一起、一直開著的那台)。所有 Bot、聊天、記憶和例行任務都放在大腦上,由它思考和排程。其他電腦打開 AnyBot 看到的都是同一份,同時也是大腦的手腳:大腦的 Bot 可以透過裝置連結操作它們。大腦連不到時,這裡還能看之前的對話,但不能傳訊息,連回來就恢復。", p);
});
let s = &self.state.remote;
let status = self.mir.status.lock().unwrap().clone();
if self.mir.on {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover());
ui.painter().circle_filled(r.center(), 4.0, if self.mir.online { Color32::from_rgb(0x14, 0xc3, 0x70) } else { p.text3 });
ui.label(RichText::new(status).size(13.0).color(p.text2));
});
ui.horizontal(|ui| {
let busy = self.mir.moving.load(Ordering::SeqCst);
if ui.add_enabled(self.mir.online && !busy, secondary_button("把這台的 Bot 搬過去")).on_hover_text(t("把這台電腦原本的 Bot(連同聊天)、模型金鑰、技能和連接器複製到大腦。這台的原始資料不會刪掉。")).clicked() {
self.mirror_move_local();
}
if ui.add(secondary_button("改回用這台自己的 Bot")).clicked() {
self.mir.leave = true;
}
});
let note = self.mir.move_note.lock().unwrap().clone();
if !note.is_empty() {
ui.label(RichText::new(note).size(12.5).color(p.text2));
}
} else {
ui.label(RichText::new(t("這台就是大腦:Bot 在這台電腦上思考和工作。")).size(13.0).color(p.text2));
let me = s.host_id.clone();
let peers: Vec<(String, String, bool)> = self.rm.peers.lock().unwrap().iter().filter(|(id, _, _)| !id.is_empty() && *id != me && !remote::is_hands(id)).cloned().collect();
if peers.is_empty() {
hint(ui, "帳號裡還沒有別台可以當大腦的 AnyBot。", p);
}
for (id, name, online) in peers {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover());
ui.painter().circle_filled(r.center(), 4.0, if online { Color32::from_rgb(0x14, 0xc3, 0x70) } else { p.text3 });
ui.label(RichText::new(&name).size(13.5));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add_enabled(online, secondary_button("改用它當大腦")).clicked() {
self.mir.want = Some((id.clone(), name.clone()));
// The brain's bots may use this computer (each action still asks).
self.state.remote.device_link = true;
changed = true;
}
});
});
}
}
self.pair_section(ui);
});
}
// ── 同一個網路 ──
setting_card(ui, p, |ui| {
setting_title(ui, "同一個網路直接連", "手機和這台電腦在同一個 Wi-Fi,或都裝了 Tailscale 時使用。", p);

View File

@ -178,9 +178,16 @@ pub fn write_response(c: &mut TcpStream, r: &Resp) {
}
pub type Handler = Arc<dyn Fn(Req) -> Resp + Send + Sync>;
/// Takes over a connection that asked to become a WebSocket (it answers the handshake itself).
pub type Upgrade = Arc<dyn Fn(Req, TcpStream) + Send + Sync>;
/// Answer every connection on this listener with `handler` (each on its own thread).
pub fn serve(listener: TcpListener, handler: Handler, alive: Arc<dyn Fn() -> bool + Send + Sync>) {
serve_upgrade(listener, handler, None, alive)
}
/// Like `serve`, and connections asking for a WebSocket go to `upgrade`.
pub fn serve_upgrade(listener: TcpListener, handler: Handler, upgrade: Option<Upgrade>, alive: Arc<dyn Fn() -> bool + Send + Sync>) {
listener.set_nonblocking(true).ok();
loop {
if !alive() {
@ -190,7 +197,13 @@ pub fn serve(listener: TcpListener, handler: Handler, alive: Arc<dyn Fn() -> boo
Ok((mut c, _)) => {
c.set_nonblocking(false).ok();
let h = handler.clone();
let up = upgrade.clone();
std::thread::spawn(move || match read_request(&mut c) {
Ok(req) if req.header("upgrade").eq_ignore_ascii_case("websocket") && up.is_some() => {
if let Some(u) = up {
u(req, c)
}
}
Ok(req) => {
let resp = h(req);
write_response(&mut c, &resp);

View File

@ -37,6 +37,7 @@ mod preview;
mod providers;
mod remote;
mod routines;
mod setup;
mod teach;
mod share;
mod teams;
@ -46,7 +47,9 @@ mod triggers;
mod vault;
mod vault_store;
mod voice;
mod tunnel;
mod web;
mod wire;
use eframe::egui;
use std::path::PathBuf;
@ -216,6 +219,10 @@ fn main() -> eframe::Result<()> {
}
std::process::exit(0);
}
// `anybot setup`: make this computer a brain, or hands for a brain (in a terminal).
if args.get(1).map(String::as_str) == Some("setup") {
std::process::exit(setup::run(&args[2..]));
}
if args.get(1).map(String::as_str) == Some("--ask") {
let text = args[2..].join(" ");
let ctx = egui::Context::default();

View File

@ -371,9 +371,10 @@ pub fn run_step(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEven
let req = &repaired;
let mut blind_copy: Option<ChatRequest> = None;
loop {
// A model that can't see pictures gets the request without them.
// A model that can't see pictures gets the request without them: the person's pictures
// described by a model that can (看圖小幫手), screenshots as a short note.
if !sees_images(req) && blind_copy.is_none() {
blind_copy = Some(without_images(req));
blind_copy = Some(if DESCRIBING.with(|d| d.get()) { without_images(req) } else { with_descriptions(req, cancel, send) });
}
let req = blind_copy.as_ref().unwrap_or(req);
let result = match req.kind {
@ -519,6 +520,142 @@ pub fn repair_history(history: &[Message]) -> Vec<Message> {
out
}
// ---------- 看圖小幫手 ----------
/// A model of the person's that sees pictures (another provider, or another model).
#[derive(Clone, Debug, PartialEq)]
pub struct Eyes {
pub kind: ProviderKind,
pub base_url: String,
pub api_key: String,
pub model: String,
}
static EYES: std::sync::Mutex<Vec<Eyes>> = std::sync::Mutex::new(Vec::new());
thread_local! {
/// Inside a helper's own call (it never asks a helper itself).
static DESCRIBING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// The models to ask, best first (the app sets this from the person's providers).
pub fn set_eyes(v: Vec<Eyes>) {
*EYES.lock().unwrap() = v;
}
/// Models likely to see pictures, by provider.
pub fn seeing_models(kind: ProviderKind) -> &'static [&'static str] {
match kind {
ProviderKind::Xai => &["grok-4", "grok-4-fast"],
ProviderKind::Anthropic => &["claude-sonnet-5-5", "claude-haiku-5-5"],
ProviderKind::OpenAi => &["gpt-5-mini", "gpt-4.1"],
ProviderKind::Gemini => &["gemini-2.5-flash"],
ProviderKind::OpenRouter => &["openrouter/auto"],
_ => &[],
}
}
const EYES_PROMPT: &str = "你是「看圖小幫手」:替一個看不到圖片的 AI 助理看圖。用繁體中文把圖片完整描述出來,讓它不用看圖也能幫使用者:畫面是什麼、主要的東西和位置、所有看得到的文字(原文照抄,包括數字、表格、按鈕和錯誤訊息)、顏色或狀態等重點。只描述,不評論、不問問題、不說你是誰。";
/// Descriptions already made (by the picture), kept across restarts.
fn notes_path() -> std::path::PathBuf {
crate::model::State::path().with_file_name("image-notes.json")
}
fn notes() -> &'static std::sync::Mutex<std::collections::HashMap<String, (String, String)>> {
static N: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, (String, String)>>> = std::sync::OnceLock::new();
N.get_or_init(|| std::sync::Mutex::new(if cfg!(test) { Default::default() } else { std::fs::read(notes_path()).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() }))
}
/// Helpers that just failed (wrong model name, no credit, down…), skipped for a while.
fn failed_eyes() -> &'static std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>> {
static F: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>> = std::sync::OnceLock::new();
F.get_or_init(Default::default)
}
fn picture_key(b64: &str) -> String {
let d = ring::digest::digest(&ring::digest::SHA256, b64.as_bytes());
d.as_ref()[..12].iter().map(|b| format!("{b:02x}")).collect()
}
/// Ask the first helper that can see pictures to describe this one: (description, model).
fn describe(picture: &str, ask: &str, not: &ChatRequest, cancel: &AtomicBool) -> Option<(String, String)> {
let key = picture_key(picture);
if let Some(n) = notes().lock().unwrap().get(&key) {
return Some(n.clone());
}
let helpers: Vec<Eyes> = EYES.lock().unwrap().clone();
for e in helpers {
let req = ChatRequest {
kind: e.kind,
base_url: e.base_url.clone(),
api_key: e.api_key.clone(),
model: e.model.clone(),
system: EYES_PROMPT.into(),
history: vec![Message { images: vec![picture.to_string()], ..Message::user(ask) }],
tools: vec![],
session: String::new(),
};
let id = vision_key(&req);
let resting = failed_eyes().lock().unwrap().get(&id).map(|t| t.elapsed().as_secs() < 600).unwrap_or(false);
if (req.base_url == not.base_url && req.model == not.model) || !sees_images(&req) || resting || cancel.load(Ordering::Relaxed) {
continue;
}
DESCRIBING.with(|d| d.set(true));
let out = run_step(&req, cancel, &|_| {});
DESCRIBING.with(|d| d.set(false));
match out {
// It may have turned out not to see pictures after all (and answered without).
Ok(o) if sees_images(&req) && !o.text.trim().is_empty() => {
let note = (o.text.trim().to_string(), e.model.clone());
let mut all = notes().lock().unwrap();
if all.len() > 500 {
all.clear();
}
all.insert(key, note.clone());
if let (false, Ok(b)) = (cfg!(test), serde_json::to_vec(&*all)) {
let _ = std::fs::write(notes_path(), b);
}
return Some(note);
}
_ => {
failed_eyes().lock().unwrap().insert(id, std::time::Instant::now());
}
}
}
None
}
/// The request for a model that can't see pictures: each of the person's pictures replaced by
/// what a helper saw in it; screenshots by a short note.
fn with_descriptions(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEvent)) -> ChatRequest {
let mut r = req.clone();
let mut told = false;
for m in r.history.iter_mut().filter(|m| !m.images.is_empty()) {
if m.role == Role::Tool {
m.images.clear();
m.text = if m.text.is_empty() { BLIND_SCREENSHOT_NOTE.to_string() } else { format!("{}\n{BLIND_SCREENSHOT_NOTE}", m.text) };
continue;
}
let pictures = std::mem::take(&mut m.images);
let ask = format!("請描述這張圖片。使用者附上它時說:「{}」", m.text.chars().take(300).collect::<String>());
let mut added = String::new();
for (i, p) in pictures.iter().enumerate() {
if !told && !notes().lock().unwrap().contains_key(&picture_key(p)) {
send(StreamEvent::Retrying("這個模型看不到圖片,正在請另一個看得懂圖的模型先看一遍…".into()));
told = true;
}
let which = if pictures.len() > 1 { format!("第 {} 張圖片", i + 1) } else { "這張圖片".to_string() };
added.push_str(&match describe(p, &ask, req, cancel) {
Some((d, model)) => format!("\n\n(你看不到圖片;以下是「{model}」看過{which}後的描述)\n{d}"),
None => format!("\n\n{BLIND_ATTACHMENT_NOTE}(帳號裡也沒有看得懂圖片的模型可以代看。可以請使用者到「設定 › 模型」加一個,例如 Grok、Claude、GPT 或 Gemini。)"),
});
}
m.text.push_str(&added);
}
r
}
/// The same request with pictures replaced by a short note.
fn without_images(req: &ChatRequest) -> ChatRequest {
let mut r = req.clone();
@ -1231,6 +1368,82 @@ mod tests {
assert!(!sees_images(&req));
}
/// A fake model: refuses pictures when `blind`, else answers `reply`; hands over each request.
fn fake_model(blind: bool, reply: &'static str) -> (String, std::sync::mpsc::Receiver<String>) {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
let (tx, rx) = std::sync::mpsc::channel::<String>();
std::thread::spawn(move || {
for s in l.incoming().flatten() {
let mut s = s;
let mut req = Vec::new();
let mut buf = [0u8; 65536];
loop {
let k = s.read(&mut buf).unwrap_or(0);
if k == 0 {
break;
}
req.extend_from_slice(&buf[..k]);
let text = String::from_utf8_lossy(&req).to_string();
if let Some(end) = text.find("\r\n\r\n") {
let len = text[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
if req.len() >= end + 4 + len {
break;
}
}
}
let text = String::from_utf8_lossy(&req).to_string();
let _ = tx.send(text.clone());
if blind && text.contains("image_url") {
let body = r#"{"error":{"message":"This model does not support image input"}}"#;
let _ = write!(s, "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
} else {
let chunk = serde_json::json!({"choices": [{"delta": {"content": reply}}]});
let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\ndata: {chunk}\n\ndata: [DONE]\n\n");
}
}
});
(format!("http://127.0.0.1:{port}/v1"), rx)
}
#[test]
fn a_model_that_sees_describes_pictures_for_one_that_cannot() {
let (blind_url, blind_rx) = fake_model(true, "好的,我看到描述了");
let (eyes_url, eyes_rx) = fake_model(false, "一張報表截圖,上面寫著「十月營收 42 萬」。");
set_eyes(vec![
// Not there (a typo in a model name, a dead server…): the next one is asked.
Eyes { kind: ProviderKind::Custom, base_url: "http://127.0.0.1:9/v1".into(), api_key: String::new(), model: "gone".into() },
Eyes { kind: ProviderKind::Custom, base_url: eyes_url, api_key: String::new(), model: "eyes-model".into() },
]);
let mut photo = Message::user("幫我看這張圖");
photo.images = vec!["UElDVFVSRS1PTkU=".into()];
let req = ChatRequest { kind: ProviderKind::Custom, base_url: blind_url, api_key: String::new(), model: "text-only".into(), system: String::new(), history: vec![photo], tools: vec![], session: String::new() };
let said = std::sync::Mutex::new(vec![]);
let out = run_step(&req, &AtomicBool::new(false), &|e| {
if let StreamEvent::Retrying(m) = e {
said.lock().unwrap().push(m);
}
})
.unwrap();
assert_eq!(out.text, "好的,我看到描述了");
// The helper got the picture and what the person said with it.
let helper = eyes_rx.recv().unwrap();
assert!(helper.contains("UElDVFVSRS1PTkU=") && helper.contains("幫我看這張圖"));
// The blind model got the description instead of the picture.
let _refused = blind_rx.recv().unwrap();
let again = blind_rx.recv().unwrap();
assert!(!again.contains("UElDVFVSRS1PTkU=") && again.contains("十月營收 42 萬") && again.contains("eyes-model"), "{again}");
assert!(said.lock().unwrap().iter().any(|m| m.contains("看得懂圖")));
// The same picture later: described from memory, no second call to the helper.
let mut photo2 = Message::user("再看一次");
photo2.images = vec!["UElDVFVSRS1PTkU=".into()];
let req2 = ChatRequest { history: vec![photo2], ..req.clone() };
run_step(&req2, &AtomicBool::new(false), &|_| {}).unwrap();
assert!(blind_rx.recv().unwrap().contains("十月營收 42 萬"));
assert!(eyes_rx.recv_timeout(std::time::Duration::from_millis(300)).is_err());
set_eyes(vec![]);
}
#[test]
fn steps_without_results_are_closed() {
let mut a = Message::assistant("", "");

View File

@ -72,11 +72,22 @@ pub struct Settings {
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 }
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() }
}
}
@ -103,6 +114,11 @@ pub enum Cmd {
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),
@ -126,11 +142,25 @@ pub struct Hub {
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()) })
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);
@ -141,6 +171,24 @@ impl Hub {
*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())
}
@ -327,7 +375,61 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
}
("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),
("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}")));
@ -342,9 +444,12 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
/// 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,
"owner" | "admin" => !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,
}
@ -473,10 +578,13 @@ fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp {
/// 裝置連結: 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) -> Resp {
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");
@ -484,6 +592,15 @@ fn device_run(v: &Value, s: &Settings, via_relay: bool) -> Resp {
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}));
@ -554,8 +671,56 @@ pub fn addresses(port: u16) -> Vec<String> {
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 {
ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(secs))).http_status_as_error(false).build().into()
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> {
@ -563,12 +728,12 @@ pub fn relay_post(url: &str, bearer: &str, body: &Value) -> Result<Value, String
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!("連不到帳號伺服器:{e}"))?;
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(v["error"].as_str().map(String::from).unwrap_or(format!("HTTP {st}")));
return Err(why_refused(st, &v, "帳號伺服器"));
}
Ok(v)
}
@ -591,7 +756,16 @@ pub fn relay_join(base_settings: &Settings, url: &str, user: &str, password: &st
return Err("帳號伺服器要用 https:// 開頭的網址(這台電腦或 Tailscale 位址可以用 http://)".into());
}
if create {
relay_post(&format!("{url}/relay/signup"), "", &json!({"user": user, "password": password, "invite": invite}))?;
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();
@ -619,11 +793,17 @@ pub fn relay_restart(s: &Settings) {
}
let (url, token) = (s.relay_url.clone(), s.host_token.clone());
hub().set_relay_status("連線中…");
// 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));
}
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 {
@ -637,7 +817,7 @@ fn relay_worker(url: &str, token: &str, gen: u64) {
let mut resp = match r {
Ok(r) => r,
Err(e) => {
hub().set_relay_status(&format!("連不上帳號伺服器:{e}"));
hub().set_relay_status(&format!("連不上帳號伺服器:{}", why_unreachable(&e)));
std::thread::sleep(Duration::from_secs(backoff));
backoff = (backoff * 2).min(30);
continue;
@ -655,7 +835,9 @@ fn relay_worker(url: &str, token: &str, gen: u64) {
}
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("已連線");
@ -678,8 +860,15 @@ pub fn req_from_json(job: &Value) -> Req {
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"))].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
body: base64::engine::general_purpose::STANDARD.decode(job["body"].as_str().unwrap_or("")).unwrap_or_default(),
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(),
}
}
@ -737,22 +926,107 @@ pub fn run_on_device(host_id: &str, tool: &str, args: &str, who: &str) -> Result
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!("連不到那台電腦:{e}"))?;
.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(v["error"].as_str().map(String::from).unwrap_or(format!("那台電腦沒有回應(HTTP {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();
@ -825,12 +1099,79 @@ pub fn set_member(s: &Settings, user: &str, role: &str) -> Result<(), String> {
}
/// 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()
}

1154
src/setup.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -427,6 +427,12 @@ pub fn protected_dirs() -> Vec<PathBuf> {
v
}
/// Inside the protected folders, what commands may still read: the chat's attachments (a picture
/// the person sent is the bot's to look at; the keys next to it are not).
pub fn readable_dirs() -> Vec<PathBuf> {
crate::model::State::path().parent().map(|d| vec![d.join("attachments")]).unwrap_or_default()
}
fn sandbox_exec() -> Option<&'static str> {
let p = "/usr/bin/sandbox-exec";
(cfg!(target_os = "macos") && Path::new(p).exists()).then_some(p)
@ -440,12 +446,13 @@ fn real(p: &Path) -> String {
/// The shell command, wrapped in the sandbox when the system has one.
#[cfg(test)]
pub fn build_command(cmd: &str, workdir: &Path, protected: &[PathBuf], conversation: &str, request: &str) -> Command {
build_command_sealed(cmd, workdir, protected, None, conversation, request)
build_command_sealed(cmd, workdir, protected, &[], None, conversation, request)
}
/// `seal = Some(home)`: the bot's own computer. Nothing under the person's home folder is
/// visible or writable except the bot's folder (`workdir`), like a separate machine.
pub fn build_command_sealed(cmd: &str, workdir: &Path, protected: &[PathBuf], seal: Option<&Path>, conversation: &str, request: &str) -> Command {
/// `readable`: folders inside `protected` that may still be read (never written).
pub fn build_command_sealed(cmd: &str, workdir: &Path, protected: &[PathBuf], readable: &[PathBuf], seal: Option<&Path>, conversation: &str, request: &str) -> Command {
let mut c = match sandbox_exec() {
Some(se) if !protected.is_empty() || seal.is_some() => {
let rules: String = (0..protected.len()).map(|i| format!(" (subpath (param \"P{i}\"))")).collect();
@ -453,10 +460,18 @@ pub fn build_command_sealed(cmd: &str, workdir: &Path, protected: &[PathBuf], se
if seal.is_some() {
profile.push_str("(deny file-read* file-write* (with no-report) (require-all (subpath (param \"HOME\")) (require-not (subpath (param \"ROOT\")))))");
}
// Later rules win: reading these is allowed again (writing stays denied).
let open: Vec<&PathBuf> = readable.iter().filter(|r| protected.iter().any(|p| r.starts_with(p))).collect();
for i in 0..open.len() {
profile.push_str(&format!("(allow file-read* (subpath (param \"R{i}\")))"));
}
let mut c = Command::new(se);
for (i, p) in protected.iter().enumerate() {
c.arg("-D").arg(format!("P{i}={}", real(p)));
}
for (i, r) in open.iter().enumerate() {
c.arg("-D").arg(format!("R{i}={}", real(r)));
}
if let Some(home) = seal {
c.arg("-D").arg(format!("HOME={}", real(home))).arg("-D").arg(format!("ROOT={}", real(workdir)));
}
@ -962,7 +977,7 @@ fn edit_file(p: &Path, old: &str, new: &str) -> Result<String, String> {
}
fn shell(cmd: &str, workdir: &Path, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, seal: Option<&Path>) -> Result<String, String> {
run_command(build_command_sealed(cmd, workdir, &protected_dirs(), seal, &ids.conversation, &ids.request), cmd, timeout_ms, cancel, ids)
run_command(build_command_sealed(cmd, workdir, &protected_dirs(), &readable_dirs(), seal, &ids.conversation, &ids.request), cmd, timeout_ms, cancel, ids)
}
/// Run a wrapper command (ssh / docker exec) the same way: background hand-off, stop, output.
@ -1185,6 +1200,16 @@ mod tests {
assert!(!String::from_utf8_lossy(&out.stdout).contains("top-secret"));
let out = build_command(&format!("cat '{}/k.txt'", secret.display()), &w, &[], "1", "r").output().unwrap();
assert!(String::from_utf8_lossy(&out.stdout).contains("top-secret"));
// The chat's attachments inside it: readable, not writable; the keys next to them: hidden.
let att = secret.join("attachments");
std::fs::create_dir_all(&att).unwrap();
std::fs::write(att.join("photo.txt"), "the-photo").unwrap();
let run = |cmd: String| build_command_sealed(&cmd, &w, &[secret.clone()], &[att.clone()], None, "1", "r").output().unwrap();
assert!(String::from_utf8_lossy(&run(format!("cat '{}/photo.txt'", att.display())).stdout).contains("the-photo"));
assert!(!String::from_utf8_lossy(&run(format!("cat '{}/k.txt'", secret.display())).stdout).contains("top-secret"));
run(format!("echo changed > '{}/photo.txt'", att.display()));
assert_eq!(std::fs::read_to_string(att.join("photo.txt")).unwrap(), "the-photo");
let _ = std::fs::remove_dir_all(&secret);
}
#[test]

366
src/tunnel.rs Normal file
View File

@ -0,0 +1,366 @@
// 長連線: one lasting WebSocket from this AnyBot to the account server. Requests for this AnyBot
// (the phone, a screen of this brain, a brain using this computer's hands) come down it at once;
// this AnyBot's own requests to the account's other AnyBots go up it. Big bodies are compressed.
// A ping every 15 s keeps it (and any proxy on the way) awake; 45 s without a word and it is
// opened again. If the account server is older and has no WebSocket, the old way (asking
// "anything for me?" over and over) is used instead.
use crate::httpd::Req;
use crate::wire;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::io::{ErrorKind, Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::sync::mpsc::{channel, Sender};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use tungstenite::{Message, WebSocket};
const PING_SECS: u64 = 15;
const DEAD_SECS: u64 = 45;
/// Bodies bigger than this are compressed.
const ZIP_OVER: usize = 1024;
const CUT: &str = "和帳號伺服器的連線斷了,正在重新連線";
/// Another AnyBot's answer to a call.
pub struct Answer {
pub status: u16,
pub body: Vec<u8>,
}
type Calls = Arc<Mutex<HashMap<String, Sender<Result<Answer, String>>>>>;
struct Link {
tx: Sender<Vec<u8>>,
calls: Calls,
serial: u64,
}
fn link() -> &'static Mutex<Option<Link>> {
static L: OnceLock<Mutex<Option<Link>>> = OnceLock::new();
L.get_or_init(|| Mutex::new(None))
}
pub fn zip(b: &[u8]) -> Vec<u8> {
let mut e = flate2::write::DeflateEncoder::new(Vec::with_capacity(b.len() / 3), flate2::Compression::fast());
let _ = e.write_all(b);
e.finish().unwrap_or_default()
}
/// The body to send and whether it is compressed: only when that saves a good part of it
/// (pictures and PDFs are compressed already).
fn maybe_zip(b: Vec<u8>) -> (Vec<u8>, bool) {
if b.len() <= ZIP_OVER {
return (b, false);
}
let z = zip(&b);
if z.len() * 10 < b.len() * 9 {
(z, true)
} else {
(b, false)
}
}
pub fn unzip(b: &[u8]) -> Result<Vec<u8>, String> {
let mut out = vec![];
flate2::read::DeflateDecoder::new(b).take(512 * 1024 * 1024).read_to_end(&mut out).map_err(|_| "收到的資料壞掉了".to_string())?;
Ok(out)
}
// ---------- the connection ----------
enum Stream {
Plain(TcpStream),
Tls(Box<rustls::StreamOwned<rustls::ClientConnection, TcpStream>>),
}
impl Stream {
fn tcp(&self) -> &TcpStream {
match self {
Stream::Plain(t) => t,
Stream::Tls(s) => &s.sock,
}
}
}
impl Read for Stream {
fn read(&mut self, b: &mut [u8]) -> std::io::Result<usize> {
match self {
Stream::Plain(t) => t.read(b),
Stream::Tls(s) => s.read(b),
}
}
}
impl Write for Stream {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
match self {
Stream::Plain(t) => t.write(b),
Stream::Tls(s) => s.write(b),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
Stream::Plain(t) => t.flush(),
Stream::Tls(s) => s.flush(),
}
}
}
fn tls_config() -> Arc<rustls::ClientConfig> {
static C: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new();
C.get_or_init(|| {
let roots = rustls::RootCertStore { roots: webpki_roots::TLS_SERVER_ROOTS.to_vec() };
Arc::new(
rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.expect("TLS versions")
.with_root_certificates(roots)
.with_no_client_auth(),
)
})
.clone()
}
fn would_block(e: &tungstenite::Error) -> bool {
matches!(e, tungstenite::Error::Io(io) if matches!(io.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut))
}
/// Why it couldn't connect; `old` = the account server has no WebSocket (use the old way).
struct NoLink {
old: bool,
why: String,
}
fn open(url: &str, token: &str) -> Result<WebSocket<Stream>, NoLink> {
use tungstenite::client::IntoClientRequest;
let fail = |why: &str| NoLink { old: false, why: why.to_string() };
let (tls, rest) = match (url.strip_prefix("https://"), url.strip_prefix("http://")) {
(Some(r), _) => (true, r),
(_, Some(r)) => (false, r),
_ => return Err(fail("帳號伺服器的網址不對")),
};
let hostport = rest.split('/').next().unwrap_or("");
let (host, port) = match hostport.rsplit_once(':') {
Some((h, p)) if p.parse::<u16>().is_ok() => (h, p.parse().unwrap_or(443)),
_ => (hostport, if tls { 443 } else { 80 }),
};
let addrs: Vec<_> = (host, port).to_socket_addrs().map_err(|_| fail("找不到伺服器:這台電腦可能沒連上網路,或伺服器網址打錯了"))?.collect();
// Each address in turn ("localhost" is both ::1 and 127.0.0.1).
let mut last = None;
let mut tcp = None;
for a in &addrs {
match TcpStream::connect_timeout(a, Duration::from_secs(10)) {
Ok(t) => {
tcp = Some(t);
break;
}
Err(e) => last = Some(e.kind()),
}
}
let tcp = tcp.ok_or_else(|| {
fail(match last {
None => "找不到伺服器",
Some(ErrorKind::ConnectionRefused) => "伺服器沒有開(連線被拒絕)",
Some(ErrorKind::TimedOut | ErrorKind::WouldBlock) => "伺服器沒有回應:可能沒開機,或網路把連線擋住了",
_ => "這台電腦沒有連上網路",
})
})?;
let _ = tcp.set_nodelay(true);
let _ = tcp.set_read_timeout(Some(Duration::from_secs(15)));
let _ = tcp.set_write_timeout(Some(Duration::from_secs(30)));
let stream = if tls {
let name = rustls::pki_types::ServerName::try_from(host.to_string()).map_err(|_| fail("帳號伺服器的網址不對"))?;
let conn = rustls::ClientConnection::new(tls_config(), name).map_err(|_| fail("和伺服器的安全連線失敗"))?;
Stream::Tls(Box::new(rustls::StreamOwned::new(conn, tcp)))
} else {
Stream::Plain(tcp)
};
let mut req = format!("{}://{hostport}/relay/host/ws", if tls { "wss" } else { "ws" }).into_client_request().map_err(|_| fail("帳號伺服器的網址不對"))?;
if let Ok(v) = format!("Bearer {token}").parse() {
req.headers_mut().insert("Authorization", v);
}
let cfg = tungstenite::protocol::WebSocketConfig::default().max_message_size(Some(512 << 20)).max_frame_size(Some(512 << 20));
match tungstenite::client::client_with_config(req, stream, Some(cfg)) {
Ok((ws, _)) => Ok(ws),
Err(tungstenite::HandshakeError::Failure(tungstenite::Error::Http(r))) => match r.status().as_u16() {
401 | 403 => Err(fail("這台電腦的帳號登入失效了,請到「設定 › 手機與帳號」重新登入")),
400 | 404 | 405 | 426 => Err(NoLink { old: true, why: String::new() }),
_ => Err(fail("帳號伺服器暫時無法使用,稍後會自動再試")),
},
Err(tungstenite::HandshakeError::Failure(e)) if e.to_string().to_lowercase().contains("certificate") || e.to_string().to_lowercase().contains("tls") => Err(fail("和伺服器的安全連線失敗(伺服器的憑證有問題)")),
Err(_) => Err(fail("連線到一半斷掉了")),
}
}
/// Keep the connection for as long as `alive()`. Returns false when the account server has no
/// WebSocket (the caller then uses the old way).
pub fn run(url: &str, token: &str, alive: &dyn Fn() -> bool, status: &dyn Fn(&str)) -> bool {
static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
let mut backoff = 1u64;
while alive() {
match open(url, token) {
Err(NoLink { old: true, .. }) => return false,
Err(e) => {
status(&format!("連不上帳號伺服器:{}", e.why));
// Wait, but notice at once when told to stop.
let until = Instant::now() + Duration::from_secs(backoff);
while Instant::now() < until && alive() {
std::thread::sleep(Duration::from_millis(200));
}
backoff = (backoff * 2).min(30);
}
Ok(ws) => {
backoff = 1;
status("已連線");
let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
keep(ws, serial, alive);
if alive() {
status("連線中斷,正在重新連線…");
}
}
}
}
true
}
fn keep(mut ws: WebSocket<Stream>, serial: u64, alive: &dyn Fn() -> bool) {
let _ = ws.get_ref().tcp().set_read_timeout(Some(Duration::from_millis(40)));
let (tx, rx) = channel::<Vec<u8>>();
let calls: Calls = Arc::default();
*link().lock().unwrap() = Some(Link { tx: tx.clone(), calls: calls.clone(), serial });
let s = crate::remote::hub().settings();
let _ = tx.send(wire::pack(&json!({"t": "hello", "brain": s.brain_id, "hands": s.device_link}), &[]));
let (mut heard, mut pinged) = (Instant::now(), Instant::now());
'live: while alive() {
match ws.read() {
Ok(Message::Binary(b)) => {
heard = Instant::now();
if let Some((head, body)) = wire::unpack(&b) {
on_frame(head, body, &tx, &calls);
}
}
Ok(Message::Close(_)) => break,
Ok(_) => heard = Instant::now(),
Err(e) if would_block(&e) => {}
Err(_) => break,
}
while let Ok(f) = rx.try_recv() {
if ws.write(Message::Binary(f.into())).is_err() {
break 'live;
}
}
if pinged.elapsed().as_secs() >= PING_SECS {
pinged = Instant::now();
if ws.write(Message::Ping(Vec::new().into())).is_err() {
break;
}
}
match ws.flush() {
Err(e) if !would_block(&e) => break,
_ => {}
}
if heard.elapsed().as_secs() >= DEAD_SECS {
break;
}
}
let _ = ws.close(None);
let _ = ws.flush();
let mut l = link().lock().unwrap();
if l.as_ref().map(|l| l.serial) == Some(serial) {
*l = None;
}
drop(l);
// Whoever waits on an answer hears now, not after a timeout.
for (_, w) in calls.lock().unwrap().drain() {
let _ = w.send(Err(CUT.into()));
}
}
fn on_frame(head: Value, body: Vec<u8>, tx: &Sender<Vec<u8>>, calls: &Calls) {
match head["t"].as_str().unwrap_or("") {
"job" => {
let tx = tx.clone();
std::thread::spawn(move || {
let s = |k: &str| head[k].as_str().unwrap_or("").to_string();
let id = s("id");
let body = if head["z"].as_bool().unwrap_or(false) { unzip(&body).unwrap_or_default() } else { body };
let req = Req {
method: s("method"),
path: s("path"),
query: head["query"].as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default(),
headers: [("x-anybot-user", s("user")), ("x-anybot-role", if head["role"].is_string() { s("role") } else { "owner".into() }), ("x-anybot-from", s("from"))].into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
body,
peer: "relay".into(),
};
let resp = crate::remote::handle(req, true);
let (out, z) = if head["z_ok"].as_bool().unwrap_or(false) { maybe_zip(resp.body) } else { (resp.body, false) };
let _ = tx.send(wire::pack(&json!({"t": "reply", "id": id, "status": resp.status, "ctype": resp.ctype, "headers": resp.headers, "z": z}), &out));
});
}
"ret" => {
if let Some(w) = calls.lock().unwrap().remove(head["id"].as_str().unwrap_or("")) {
let body = if head["z"].as_bool().unwrap_or(false) { unzip(&body) } else { Ok(body) };
let _ = w.send(body.map(|body| Answer { status: head["status"].as_u64().unwrap_or(502) as u16, body }));
}
}
_ => {}
}
}
/// Tell the account server which brain this AnyBot shows (so the phone can go there).
pub fn hello(brain: &str, hands: bool) {
if let Some(l) = link().lock().unwrap().as_ref() {
let _ = l.tx.send(wire::pack(&json!({"t": "hello", "brain": brain, "hands": hands}), &[]));
}
}
/// Ask another AnyBot of this account over the lasting connection. None when it isn't open
/// (the caller then uses an ordinary request).
pub fn call(to: &str, method: &str, path: &str, query: &[(&str, String)], body: &[u8], secs: u64) -> Option<Result<Answer, String>> {
let (tx, calls) = {
let l = link().lock().unwrap();
let l = l.as_ref()?;
(l.tx.clone(), l.calls.clone())
};
let id = crate::httpd::random_hex(8);
let (rtx, rrx) = channel();
calls.lock().unwrap().insert(id.clone(), rtx);
let (out, z) = maybe_zip(body.to_vec());
let q: serde_json::Map<String, Value> = query.iter().map(|(k, v)| (k.to_string(), json!(v))).collect();
if tx.send(wire::pack(&json!({"t": "call", "id": id, "to": to, "method": method, "path": path, "query": q, "z": z}), &out)).is_err() {
calls.lock().unwrap().remove(&id);
return None;
}
Some(match rrx.recv_timeout(Duration::from_secs(secs)) {
Ok(r) => r,
Err(_) => {
calls.lock().unwrap().remove(&id);
Err("等太久沒有回應:網路很慢或斷斷續續".into())
}
})
}
#[cfg(test)]
mod tests {
#[test]
fn compression_round_trips_and_shrinks_text() {
let text = "你好,這是一段會重複的聊天內容。".repeat(400);
let z = super::zip(text.as_bytes());
assert!(z.len() * 5 < text.len());
assert_eq!(super::unzip(&z).unwrap(), text.as_bytes());
// Already-compressed bytes go as they are.
let mut x = 88172645463325252u64;
let noise: Vec<u8> = (0..5000)
.map(|_| {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
(x >> 24) as u8
})
.collect();
assert!(!super::maybe_zip(noise).1);
assert!(super::maybe_zip(text.into_bytes()).1);
}
}

View File

@ -59,7 +59,28 @@ pub fn read_needs_permission(args: &str) -> bool {
uses_person_browser(args)
}
pub fn specs() -> Vec<Value> {
/// The browser tools, also when the browser is on another computer of the account (裝置連結):
/// then each takes a `computer`, since each computer's AnyBot browser has its own logins.
pub fn specs_for(devices: &[String]) -> Vec<Value> {
let local = crate::botbrowser::available();
let mut v = specs_inner(local || !devices.is_empty());
if !devices.is_empty() {
let mut names: Vec<String> = devices.to_vec();
if local {
names.insert(0, crate::model::THIS_COMPUTER_NAME.to_string());
}
let desc = format!(
"Which computer's AnyBot browser to use. Each computer's browser has its own logins (a site logged in on one isn't on another). {}",
if local { format!("Leave out for {}.", crate::model::THIS_COMPUTER_NAME) } else { "This computer has no browser: always name one.".to_string() }
);
for t in v.iter_mut().filter(|t| t["name"].as_str().map(|n| n.starts_with("browser_")).unwrap_or(false)) {
t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": names, "description": desc});
}
}
v
}
fn specs_inner(browser: bool) -> Vec<Value> {
let (names, current) = if crate::botbrowser::available() {
(crate::botbrowser::choices().join(", "), crate::botbrowser::current())
} else {
@ -76,7 +97,7 @@ pub fn specs() -> Vec<Value> {
json!({"name": "browser_profiles", "description": "List, add or switch AnyBot browser profiles (設定檔/profile). Use when the person wants another profile or account: 'list' shows them, 'add' makes a new empty one (they log in once there) and switches to it, 'use' switches. Switching changes which profile browser_open/browser_read use.",
"parameters": {"type": "object", "properties": {"action": {"type": "string", "enum": ["list", "add", "use"]}, "name": {"type": "string", "description": "Profile name for add/use."}}, "required": ["action"]}}),
];
if crate::botbrowser::available() {
if browser {
v.extend(crate::browser::specs(&profile));
}
v

42
src/wire.rs Normal file
View File

@ -0,0 +1,42 @@
// (Shared with relay/.)
#![allow(dead_code)]
// One frame on the lasting connection between an AnyBot and the account server (a WebSocket):
// a small JSON header, then the body as raw bytes (no base64), in one binary message.
//
// Headers ("t" says which):
// server → AnyBot job {id, method, path, query, user, role, z, z_ok} a request for this AnyBot
// AnyBot → server reply {id, status, ctype, headers, z} its answer
// AnyBot → server call {id, to, method, path, query, z} a request for another AnyBot of the account
// server → AnyBot ret {id, status, ctype, z} that one's answer
// AnyBot → server hello {brain} what this AnyBot shows
// "z": the body is deflate-compressed. "z_ok": the asker can read a compressed answer.
use serde_json::Value;
pub fn pack(head: &Value, body: &[u8]) -> Vec<u8> {
let h = head.to_string();
let mut out = Vec::with_capacity(4 + h.len() + body.len());
out.extend_from_slice(&(h.len() as u32).to_be_bytes());
out.extend_from_slice(h.as_bytes());
out.extend_from_slice(body);
out
}
pub fn unpack(b: &[u8]) -> Option<(Value, Vec<u8>)> {
let n = u32::from_be_bytes(b.get(..4)?.try_into().ok()?) as usize;
let head = serde_json::from_slice(b.get(4..4 + n)?).ok()?;
Some((head, b[4 + n..].to_vec()))
}
#[cfg(test)]
mod tests {
#[test]
fn frames_round_trip() {
let head = serde_json::json!({"t": "job", "id": "a"});
let f = super::pack(&head, b"\x00\x01body");
let (h, b) = super::unpack(&f).unwrap();
assert_eq!(h, head);
assert_eq!(b, b"\x00\x01body");
assert!(super::unpack(&f[..3]).is_none());
}
}