Compare commits
9 Commits
c248387f00
...
867732bd73
| Author | SHA1 | Date |
|---|---|---|
|
|
867732bd73 | |
|
|
87be66b016 | |
|
|
64e67673c8 | |
|
|
dd27ed7c4a | |
|
|
365dc87e54 | |
|
|
cb922da3fe | |
|
|
b153051e58 | |
|
|
a70d29fa51 | |
|
|
5841d797be |
|
|
@ -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
|
||||||
|
|
@ -8,7 +8,12 @@
|
||||||
|
|
||||||
# Local secrets (deploy/server/.env.example is the template)
|
# Local secrets (deploy/server/.env.example is the template)
|
||||||
.env
|
.env
|
||||||
|
# Written by deploy/server/setup.sh
|
||||||
|
/deploy/server/Caddyfile
|
||||||
*.env.local
|
*.env.local
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Release packages (deploy/ci, deploy/mac/bundle.sh)
|
||||||
|
/dist/
|
||||||
|
|
|
||||||
|
|
@ -343,8 +343,10 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"chrono-tz",
|
"chrono-tz",
|
||||||
"cpal",
|
"cpal",
|
||||||
|
"crossterm",
|
||||||
"dirs",
|
"dirs",
|
||||||
"eframe",
|
"eframe",
|
||||||
|
"flate2",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"hayro",
|
"hayro",
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
|
|
@ -356,15 +358,18 @@ dependencies = [
|
||||||
"resvg",
|
"resvg",
|
||||||
"ring",
|
"ring",
|
||||||
"rodio",
|
"rodio",
|
||||||
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tungstenite",
|
"tungstenite",
|
||||||
"unicode-normalization",
|
"unicode-normalization",
|
||||||
|
"unicode-width",
|
||||||
"ureq",
|
"ureq",
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
"wayland-protocols",
|
"wayland-protocols",
|
||||||
"wayland-protocols-misc",
|
"wayland-protocols-misc",
|
||||||
"wayland-protocols-wlr",
|
"wayland-protocols-wlr",
|
||||||
|
"webpki-roots",
|
||||||
"x11rb 0.13.2",
|
"x11rb 0.13.2",
|
||||||
"zbus",
|
"zbus",
|
||||||
"zhconv",
|
"zhconv",
|
||||||
|
|
@ -393,7 +398,7 @@ dependencies = [
|
||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
"wl-clipboard-rs",
|
"wl-clipboard-rs",
|
||||||
"x11rb 0.13.2",
|
"x11rb 0.13.2",
|
||||||
]
|
]
|
||||||
|
|
@ -1029,6 +1034,15 @@ version = "0.10.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
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]]
|
[[package]]
|
||||||
name = "cookie"
|
name = "cookie"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
|
|
@ -1224,6 +1238,33 @@ version = "0.8.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
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]]
|
[[package]]
|
||||||
name = "crunchy"
|
name = "crunchy"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
|
|
@ -1291,6 +1332,28 @@ version = "0.5.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
|
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]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
|
|
@ -1641,7 +1704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2876,6 +2939,18 @@ dependencies = [
|
||||||
"simd-adler32",
|
"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]]
|
[[package]]
|
||||||
name = "moxcms"
|
name = "moxcms"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
|
|
@ -3496,7 +3571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -4202,7 +4277,7 @@ dependencies = [
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -4389,6 +4464,27 @@ version = "2.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
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]]
|
[[package]]
|
||||||
name = "signal-hook-registry"
|
name = "signal-hook-registry"
|
||||||
version = "1.4.8"
|
version = "1.4.8"
|
||||||
|
|
@ -4852,10 +4948,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.5",
|
"rustix 1.1.5",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -5901,15 +5997,37 @@ dependencies = [
|
||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "winapi-util"
|
name = "winapi-util"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,13 @@ path = "src/main.rs"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
eframe = "0.37"
|
eframe = "0.37"
|
||||||
ureq = { version = "3", features = ["json"] }
|
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 = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
dirs = "7"
|
dirs = "7"
|
||||||
|
|
|
||||||
|
|
@ -2074,10 +2074,17 @@ computer_use_screenshot、click、mouse_move、mouse_button(按住/放開)
|
||||||
| 邊說邊插話的即時語音 | 原版用 xAI 即時語音服務;AnyBot 的通話模式是輪流說話 |
|
| 邊說邊插話的即時語音 | 原版用 xAI 即時語音服務;AnyBot 的通話模式是輪流說話 |
|
||||||
| 帳單、方案 | 不需要:AnyBot 用你自己的 AI 金鑰 |
|
| 帳單、方案 | 不需要:AnyBot 用你自己的 AI 金鑰 |
|
||||||
| 代你購物與付款 | 刻意不做:會動到你的錢和卡片 |
|
| 代你購物與付款 | 刻意不做:會動到你的錢和卡片 |
|
||||||
| 即時同步(同一個帳號的兩台電腦同時改同一個 Bot) | 帳號只做每日加密備份和還原;要 24 小時運作,請把 AnyBot 放在一直開著的主機(第 36 節) |
|
|
||||||
| 原生 iPhone/Android App | 用手機網頁版,可加到主畫面、收推播通知 |
|
| 原生 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)
|
## 側邊欄:釘選與分區(照原版重做,2026-11-04)
|
||||||
|
|
||||||
- 釘選的 Bot 放在最上面的大頭像格子裡(主要 Bot 第一個,有星星)。右鍵「釘選/取消釘選」,或把 Bot 拖進格子(第一次拖時出現「拖曳到此處以釘選」);格子內可拖曳排序;拖到分區會取消釘選並移過去。
|
- 釘選的 Bot 放在最上面的大頭像格子裡(主要 Bot 第一個,有星星)。右鍵「釘選/取消釘選」,或把 Bot 拖進格子(第一次拖時出現「拖曳到此處以釘選」);格子內可拖曳排序;拖到分區會取消釘選並移過去。
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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/ 技術文件
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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 按「加一台電腦」,把那一行貼到這裡執行。"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
#
|
#
|
||||||
# ./deploy/linux/install.sh build from source, then install
|
# ./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 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
|
# 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.
|
# 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"
|
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"
|
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
|
if command -v apt-get >/dev/null; then
|
||||||
sudo apt-get update && sudo apt-get install -y $DEPS_DEBIAN $BUILD_DEBIAN
|
sudo apt-get update && sudo apt-get install -y $DEPS_DEBIAN $BUILD_DEBIAN
|
||||||
elif command -v dnf >/dev/null; then
|
elif command -v dnf >/dev/null; then
|
||||||
|
|
@ -37,6 +38,8 @@ if [ "$1" = "--deps" ]; then
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
[ "$1" = "--deps-only" ] && exit 0
|
||||||
|
|
||||||
OUT="${CARGO_TARGET_DIR:-target}/release/anybot"
|
OUT="${CARGO_TARGET_DIR:-target}/release/anybot"
|
||||||
if [ -x "$OUT" ] && [ "$1" != "--rebuild" ]; then
|
if [ -x "$OUT" ] && [ "$1" != "--rebuild" ]; then
|
||||||
echo "Using the existing build ($OUT)."
|
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
|
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 "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
|
case ":$PATH:" in *":$BIN:"*) ;; *) echo "(Add $BIN to your PATH to start it from a terminal.)" ;; esac
|
||||||
if [ "${XDG_SESSION_TYPE:-}" = "wayland" ]; then
|
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:"
|
echo "Wayland: bots can see and use your screen. On GNOME/KDE the first time, a system window asks:"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
# Everything on one small server: the account server (relay), HTTPS (Caddy), and AnyBot itself.
|
# 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).
|
# 2. Put your account name and password in .env (see .env.example).
|
||||||
# 3. docker compose up -d
|
# 3. docker compose up -d
|
||||||
# 4. On the phone open https://your-domain, create the account (first time), and use it.
|
# 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
|
# 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).
|
# 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:
|
services:
|
||||||
relay:
|
relay:
|
||||||
build:
|
build:
|
||||||
|
|
@ -22,7 +30,8 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports: ["80:80", "443:443"]
|
ports: ["80:80", "443:443"]
|
||||||
volumes:
|
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
|
- caddy-data:/data
|
||||||
depends_on: [relay]
|
depends_on: [relay]
|
||||||
anybot:
|
anybot:
|
||||||
|
|
@ -35,6 +44,9 @@ services:
|
||||||
ANYBOT_ACCOUNT: "http://relay:8080|${ANYBOT_USER}"
|
ANYBOT_ACCOUNT: "http://relay:8080|${ANYBOT_USER}"
|
||||||
ANYBOT_ACCOUNT_PASSWORD: "${ANYBOT_PASSWORD}"
|
ANYBOT_ACCOUNT_PASSWORD: "${ANYBOT_PASSWORD}"
|
||||||
ANYBOT_HOST_NAME: "${ANYBOT_HOST_NAME:-雲端 AnyBot}"
|
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:-}"
|
XAI_API_KEY: "${XAI_API_KEY:-}"
|
||||||
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
||||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -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 分鐘內跳過。
|
||||||
|
|
@ -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 分鐘,之後只會重編有改的部分。
|
||||||
|
|
@ -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)。
|
||||||
|
|
@ -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 或名字、或鍵重複時,送整個值。
|
||||||
|
|
@ -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 開發者帳號。
|
||||||
|
|
@ -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{display:block;width:100%;text-align:left;padding:14px 16px;border-radius:12px;font-size:16px}
|
||||||
.sheet button:active{background:var(--raise)}
|
.sheet button:active{background:var(--raise)}
|
||||||
.sheet .no{color:var(--danger)}
|
.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}
|
.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{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}
|
.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">
|
<div class="sheet-bg" id="sheet"><div class="sheet">
|
||||||
<button id="m-notify">開啟通知</button>
|
<button id="m-notify">開啟通知</button>
|
||||||
<div class="note" id="m-note">Bot 需要你允許、或在你不在時做完事,手機會跳通知。</div>
|
<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 id="m-host" style="display:none">切換到別台 AnyBot</button>
|
||||||
<button class="no" id="m-out">登出這支手機</button>
|
<button class="no" id="m-out">登出這支手機</button>
|
||||||
<button id="m-cancel">取消</button>
|
<button id="m-cancel">取消</button>
|
||||||
</div></div>
|
</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>
|
<script>
|
||||||
"use strict";
|
"use strict";
|
||||||
// Lucide icons (ISC licence), the same set as the app.
|
// Lucide icons (ISC licence), the same set as the app.
|
||||||
|
|
@ -298,11 +314,20 @@ $("b-out1").onclick = () => signOut();
|
||||||
|
|
||||||
async function pickHost() {
|
async function pickHost() {
|
||||||
const me = await api("relay/me");
|
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);
|
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(); }
|
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>`;
|
: `<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(); });
|
document.querySelectorAll("#hosts .row").forEach((r) => r.onclick = () => { S.host = r.dataset.h; localStorage.setItem("anybot.host", S.host); start(); });
|
||||||
show("hosts");
|
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-right").onclick = () => { S.right = !S.right; $("k-right").textContent = "右鍵:" + (S.right ? "開" : "關"); };
|
||||||
$("k-wake").onclick = () => { toast("正在叫醒 Bot 的電腦…"); input2({kind: "wake"}); };
|
$("k-wake").onclick = () => { toast("正在叫醒 Bot 的電腦…"); input2({kind: "wake"}); };
|
||||||
// ---------- menu, notifications ----------
|
// ---------- 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"); };
|
$("sheet").onclick = (e) => { if (e.target.id === "sheet") $("sheet").classList.remove("on"); };
|
||||||
$("m-cancel").onclick = () => $("sheet").classList.remove("on");
|
$("m-cancel").onclick = () => $("sheet").classList.remove("on");
|
||||||
$("m-out").onclick = () => { $("sheet").classList.remove("on"); if (confirm("要登出這支手機嗎?")) signOut(); };
|
$("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(); };
|
$("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;
|
const pushOK = () => S.relay && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||||
async function notifyLabel() {
|
async function notifyLabel() {
|
||||||
const b = $("m-notify"), n = $("m-note");
|
const b = $("m-notify"), n = $("m-note");
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,12 @@
|
||||||
anybot.example.com {
|
anybot.example.com {
|
||||||
encode gzip
|
encode gzip
|
||||||
reverse_proxy relay:8080 {
|
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 {
|
transport http {
|
||||||
read_timeout 60s
|
read_timeout 60s
|
||||||
}
|
}
|
||||||
|
# Reloading this file doesn't cut those connections at once.
|
||||||
|
stream_close_delay 5m
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ dependencies = [
|
||||||
"ring",
|
"ring",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tungstenite",
|
||||||
"ureq",
|
"ureq",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -47,6 +48,15 @@ version = "0.23.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
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]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
|
|
@ -75,6 +85,17 @@ version = "1.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
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]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
|
|
@ -88,12 +109,27 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.4"
|
version = "1.5.4"
|
||||||
|
|
@ -103,6 +139,32 @@ dependencies = [
|
||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.15"
|
version = "0.1.15"
|
||||||
|
|
@ -155,6 +217,18 @@ dependencies = [
|
||||||
"wasi",
|
"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]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
|
|
@ -171,6 +245,15 @@ version = "1.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
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]]
|
[[package]]
|
||||||
name = "iana-time-zone"
|
name = "iana-time-zone"
|
||||||
version = "0.1.65"
|
version = "0.1.65"
|
||||||
|
|
@ -285,6 +368,29 @@ dependencies = [
|
||||||
"proc-macro2",
|
"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]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
|
|
@ -293,7 +399,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"getrandom",
|
"getrandom 0.2.17",
|
||||||
"libc",
|
"libc",
|
||||||
"untrusted",
|
"untrusted",
|
||||||
"windows-sys",
|
"windows-sys",
|
||||||
|
|
@ -383,6 +489,17 @@ dependencies = [
|
||||||
"zmij",
|
"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]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
|
|
@ -429,6 +546,48 @@ dependencies = [
|
||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ ring = "0.17"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
ureq = "3"
|
ureq = "3"
|
||||||
|
tungstenite = { version = "0.30", default-features = false, features = ["handshake"] }
|
||||||
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
FROM rust:1-bookworm AS build
|
FROM rust:1-bookworm AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY src/httpd.rs src/httpd.rs
|
COPY src/httpd.rs src/httpd.rs
|
||||||
|
COPY src/wire.rs src/wire.rs
|
||||||
COPY mobile mobile
|
COPY mobile mobile
|
||||||
COPY relay relay
|
COPY relay relay
|
||||||
RUN cd relay && cargo build --release
|
RUN cd relay && cargo build --release
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
|
|
||||||
#[path = "../../src/httpd.rs"]
|
#[path = "../../src/httpd.rs"]
|
||||||
mod httpd;
|
mod httpd;
|
||||||
|
#[path = "../../src/wire.rs"]
|
||||||
|
mod wire;
|
||||||
mod push;
|
mod push;
|
||||||
|
|
||||||
use httpd::{Req, Resp};
|
use httpd::{Req, Resp};
|
||||||
|
|
@ -22,6 +24,7 @@ use serde_json::{json, Value};
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::mpsc::{channel, Sender};
|
use std::sync::mpsc::{channel, Sender};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Condvar, Mutex};
|
use std::sync::{Arc, Condvar, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
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;
|
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).
|
/// How long a phone's request waits for the AnyBot to answer (its own long-poll is 20 s).
|
||||||
const FORWARD_SECS: u64 = 35;
|
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.
|
/// A host counts as online when it asked within this many seconds.
|
||||||
const ONLINE_SECS: u64 = 60;
|
const ONLINE_SECS: u64 = 60;
|
||||||
/// Sessions last this long without use.
|
/// 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 {
|
struct Relay {
|
||||||
dir: PathBuf,
|
dir: PathBuf,
|
||||||
data: Mutex<Data>,
|
data: Mutex<Data>,
|
||||||
|
|
@ -129,7 +186,15 @@ struct Relay {
|
||||||
queues: Mutex<HashMap<String, VecDeque<Value>>>,
|
queues: Mutex<HashMap<String, VecDeque<Value>>>,
|
||||||
arrived: Condvar,
|
arrived: Condvar,
|
||||||
/// Where each forwarded request's answer goes.
|
/// 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).
|
/// When each host last asked (in memory; saved now and then).
|
||||||
seen: Mutex<HashMap<String, Instant>>,
|
seen: Mutex<HashMap<String, Instant>>,
|
||||||
fails: Mutex<HashMap<String, (u32, Instant)>>,
|
fails: Mutex<HashMap<String, (u32, Instant)>>,
|
||||||
|
|
@ -153,6 +218,11 @@ impl Relay {
|
||||||
queues: Mutex::new(HashMap::new()),
|
queues: Mutex::new(HashMap::new()),
|
||||||
arrived: Condvar::new(),
|
arrived: Condvar::new(),
|
||||||
waiting: Mutex::new(HashMap::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()),
|
seen: Mutex::new(HashMap::new()),
|
||||||
fails: Mutex::new(HashMap::new()),
|
fails: Mutex::new(HashMap::new()),
|
||||||
invite: std::env::var("ANYBOT_RELAY_INVITE").unwrap_or_default(),
|
invite: std::env::var("ANYBOT_RELAY_INVITE").unwrap_or_default(),
|
||||||
|
|
@ -199,7 +269,7 @@ impl Relay {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn online(&self, id: &str) -> bool {
|
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 {
|
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.
|
/// 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 {
|
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;
|
use base64::Engine;
|
||||||
if !self.online(host_id) {
|
if !self.online(host_id) {
|
||||||
return Resp::error(503, "那台 AnyBot 目前離線(電腦關機、睡眠,或 AnyBot 沒開)");
|
return Answer::error(503, OFFLINE);
|
||||||
}
|
}
|
||||||
let id = httpd::random_hex(12);
|
let id = httpd::random_hex(12);
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
self.waiting.lock().unwrap().insert(id.clone(), tx);
|
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.queues.lock().unwrap().entry(host_id.to_string()).or_default().push_back(job);
|
||||||
self.arrived.notify_all();
|
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);
|
self.waiting.lock().unwrap().remove(&id);
|
||||||
// Not picked up in time: drop it so it doesn't run late.
|
// 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()));
|
q.retain(|j| j["id"].as_str() != Some(id.as_str()));
|
||||||
}
|
n != q.len()
|
||||||
|
});
|
||||||
match r {
|
match r {
|
||||||
Ok(v) => {
|
Ok(a) => a,
|
||||||
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());
|
Err(_) if was_queued == Some(true) => Answer::error(503, OFFLINE),
|
||||||
resp.status = v["status"].as_u64().unwrap_or(502) as u16;
|
Err(_) => Answer::error(504, "那台 AnyBot 收到了,但太久沒有回應(可能太忙,或網路很慢)"),
|
||||||
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") {
|
/// 長連線: an AnyBot keeps one WebSocket open; requests for it come down it at once, and its
|
||||||
resp.headers.push((k.into(), val.into()));
|
/// 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);
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resp
|
|
||||||
|
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) });
|
||||||
}
|
}
|
||||||
Err(_) => Resp::error(504, "那台 AnyBot 沒有及時回應"),
|
}
|
||||||
|
// 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"]) => {
|
("POST", ["relay", "hosts"]) => {
|
||||||
let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") };
|
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 name: String = req.json()["name"].as_str().unwrap_or("AnyBot").chars().take(60).collect();
|
||||||
let token = httpd::random_hex(32);
|
let (h, token) = self.add_host(&user, &name);
|
||||||
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);
|
|
||||||
Resp::json(&json!({"id": h.id, "token": token}))
|
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"]) => {
|
("POST", ["relay", "hosts", id, "remove"]) => {
|
||||||
let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") };
|
let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") };
|
||||||
let mut d = self.data.lock().unwrap();
|
let mut d = self.data.lock().unwrap();
|
||||||
|
|
@ -374,7 +624,7 @@ impl Relay {
|
||||||
return Resp::error(401, "這台電腦的金鑰無效");
|
return Resp::error(401, "這台電腦的金鑰無效");
|
||||||
}
|
}
|
||||||
if let Some(tx) = self.waiting.lock().unwrap().remove(*id) {
|
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}))
|
Resp::json(&json!({"ok": true}))
|
||||||
}
|
}
|
||||||
|
|
@ -497,7 +747,17 @@ impl Relay {
|
||||||
if !same || *target == me.id {
|
if !same || *target == me.id {
|
||||||
return Resp::error(403, "不是同一個帳號的電腦");
|
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.
|
// The phone, through to one of the account's AnyBots.
|
||||||
(m, ["h", host, rest @ ..]) => {
|
(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 {
|
fn host_json(&self, h: &Host) -> Value {
|
||||||
let online = self.online(&h.id);
|
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 {
|
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 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 bind = arg("--bind").unwrap_or_else(|| "0.0.0.0".into());
|
||||||
let dir = PathBuf::from(arg("--data").unwrap_or_else(|| "relay-data".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 relay = Arc::new(Relay::load(dir.clone()));
|
||||||
let listener = std::net::TcpListener::bind((bind.as_str(), port)).unwrap_or_else(|e| {
|
let listener = std::net::TcpListener::bind((bind.as_str(), port)).unwrap_or_else(|e| {
|
||||||
eprintln!("無法使用連接埠 {port}:{e}");
|
eprintln!("無法使用連接埠 {port}:{e}");
|
||||||
|
|
@ -605,8 +884,54 @@ fn main() {
|
||||||
std::thread::sleep(Duration::from_secs(60));
|
std::thread::sleep(Duration::from_secs(60));
|
||||||
r2.save_seen();
|
r2.save_seen();
|
||||||
});
|
});
|
||||||
let r = relay.clone();
|
let (r, w) = (relay.clone(), relay.clone());
|
||||||
httpd::serve(listener, Arc::new(move |req| r.handle(req)), Arc::new(|| true));
|
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)]
|
#[cfg(test)]
|
||||||
|
|
@ -759,4 +1084,41 @@ mod tests {
|
||||||
assert_eq!(post(port, "/relay/host/sites/Bad_Name", &ht, json!({})).0, 400);
|
assert_eq!(post(port, "/relay/host/sites/Bad_Name", &ht, json!({})).0, 400);
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
src/agent.rs
19
src/agent.rs
|
|
@ -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).
|
/// 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
|
/// 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.
|
/// 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).
|
// …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)) {
|
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));
|
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 }));
|
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
|
||||||
let mut result = Message::tool_result(&call.id, &call.name, &output);
|
let mut result = Message::tool_result(&call.id, &call.name, &output);
|
||||||
result.images.extend(img);
|
result.images.extend(img);
|
||||||
|
|
|
||||||
162
src/app.rs
162
src/app.rs
|
|
@ -33,6 +33,7 @@ mod sidebar_ui;
|
||||||
mod menu_ui;
|
mod menu_ui;
|
||||||
mod vault_ui;
|
mod vault_ui;
|
||||||
mod markdown_ui;
|
mod markdown_ui;
|
||||||
|
mod mirror_ui;
|
||||||
|
|
||||||
// Sidebar rules from index.eager-app: defaults `FT={expandedWidth:280}` (@912025),
|
// Sidebar rules from index.eager-app: defaults `FT={expandedWidth:280}` (@912025),
|
||||||
// clamp `jT(..,240,400)` (@914394), and the drag rule in `QJe` (@969523):
|
// clamp `jT(..,240,400)` (@914394), and the drag rule in `QJe` (@969523):
|
||||||
|
|
@ -197,11 +198,16 @@ pub struct App {
|
||||||
group_menu_for: Option<String>,
|
group_menu_for: Option<String>,
|
||||||
/// When the launch greeting started.
|
/// When the launch greeting started.
|
||||||
splash_at: Option<f64>,
|
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 {
|
impl App {
|
||||||
pub fn new(ctx: &egui::Context, screenshot: Option<PathBuf>) -> Self {
|
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 state = if demo { State::demo() } else { State::load() };
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
let mut app = App {
|
let mut app = App {
|
||||||
|
|
@ -279,6 +285,9 @@ impl App {
|
||||||
renaming: None,
|
renaming: None,
|
||||||
group_menu_for: None,
|
group_menu_for: None,
|
||||||
splash_at: None,
|
splash_at: None,
|
||||||
|
mir: Default::default(),
|
||||||
|
scr: Default::default(),
|
||||||
|
ctx_handle: None,
|
||||||
};
|
};
|
||||||
crate::i18n::set(&app.state.language);
|
crate::i18n::set(&app.state.language);
|
||||||
app.computers_start();
|
app.computers_start();
|
||||||
|
|
@ -509,6 +518,11 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
app.apply_theme(ctx);
|
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
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -593,8 +607,14 @@ impl App {
|
||||||
let quoted: String = q.lines().take(8).map(|l| format!("> {l}\n")).collect();
|
let quoted: String = q.lines().take(8).map(|l| format!("> {l}\n")).collect();
|
||||||
text = format!("{quoted}\n{}", text.trim());
|
text = format!("{quoted}\n{}", text.trim());
|
||||||
}
|
}
|
||||||
self.drafts.insert(bot_id, String::new());
|
|
||||||
let attachments = self.take_attachments(bot_id);
|
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`:
|
// 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.
|
// 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) {
|
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) {
|
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 };
|
let Some(conv) = self.state.bot(bot_id).cloned() else { return };
|
||||||
// 群組聊天: the next member answers, with its own model, instructions and computers.
|
// 群組聊天: the next member answers, with its own model, instructions and computers.
|
||||||
let speaker = if conv.members.is_empty() {
|
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 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();
|
let mut tool_specs = appctl::specs();
|
||||||
tool_specs.extend(tools::specs_for(&names, &default_computer));
|
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.
|
// Screen tools: the bot's own desktop (when it has one) and this Mac.
|
||||||
let mut screens = vec![];
|
let mut screens = vec![];
|
||||||
if crate::cloudbox::available() {
|
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() };
|
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));
|
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 {
|
let req = ChatRequest {
|
||||||
kind: b.provider,
|
kind: b.provider,
|
||||||
base_url: providers::resolve_base(b.provider, &ps.base_url),
|
base_url: providers::resolve_base(b.provider, &ps.base_url),
|
||||||
|
|
@ -873,6 +900,12 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&mut self, bot_id: u64) {
|
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);
|
self.approvals.remove(&bot_id);
|
||||||
if let Some(call) = self.app_pending.remove(&bot_id) {
|
if let Some(call) = self.app_pending.remove(&bot_id) {
|
||||||
let _ = call.reply.send(("已停止".into(), false));
|
let _ = call.reply.send(("已停止".into(), false));
|
||||||
|
|
@ -961,6 +994,17 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn answer(&mut self, ctx: &egui::Context, bot_id: u64, answer: ApprovalAnswer) {
|
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) {
|
if self.want_direct.replace(false) {
|
||||||
self.enable_direct_mode();
|
self.enable_direct_mode();
|
||||||
}
|
}
|
||||||
|
|
@ -1004,6 +1048,10 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn regenerate(&mut self, ctx: &egui::Context, bot_id: u64) {
|
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) {
|
if self.streams.contains_key(&bot_id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1204,6 +1252,20 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_models(&mut self, kind: ProviderKind) {
|
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 ps = self.state.provider(kind);
|
||||||
let base = providers::resolve_base(kind, &ps.base_url);
|
let base = providers::resolve_base(kind, &ps.base_url);
|
||||||
let key = providers::resolve_key(kind, &ps.api_key);
|
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> {
|
fn models_for(&self, kind: ProviderKind) -> Vec<String> {
|
||||||
let ps = self.state.provider(kind);
|
let ps = self.state.provider(kind);
|
||||||
let mut v: Vec<String> = if ps.known_models.is_empty() {
|
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.
|
// 討論串: put this conversation aside and start fresh; earlier ones can come back.
|
||||||
if menu_ui::item(ui, p, Icon::NewChat, "開始新對話").clicked() {
|
if menu_ui::item(ui, p, Icon::NewChat, "開始新對話").clicked() {
|
||||||
self.stop(id);
|
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() {
|
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 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);
|
let msgs = std::mem::take(&mut b.messages);
|
||||||
|
|
@ -1465,7 +1557,9 @@ impl App {
|
||||||
for (i, title) in threads.iter().enumerate() {
|
for (i, title) in threads.iter().enumerate() {
|
||||||
if menu_ui::item_text(ui, p, None, title).clicked() {
|
if menu_ui::item_text(ui, p, None, title).clicked() {
|
||||||
self.stop(id);
|
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);
|
let (_, msgs) = b.threads.remove(i);
|
||||||
if !b.messages.is_empty() {
|
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());
|
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;
|
return;
|
||||||
};
|
};
|
||||||
self.header(ui, &bot);
|
self.header(ui, &bot);
|
||||||
|
self.mirror_banner(ui);
|
||||||
// The composer grows with a long draft: keep room for last frame's height.
|
// The composer grows with a long draft: keep room for last frame's height.
|
||||||
let h_id = egui::Id::unique("composer-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);
|
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 right_w = 28.0 * 2.0 + 8.0 + 4.0;
|
||||||
let text_w = (ui.available_width() - right_w).max(60.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| {
|
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| {
|
egui::ScrollArea::vertical().id_salt(("composer_scroll", bot.id)).max_height(160.0).show(ui, |ui| {
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
|
|
@ -2957,11 +3052,18 @@ impl App {
|
||||||
/// Everything that keeps bots working, without drawing (the window runs it every frame;
|
/// Everything that keeps bots working, without drawing (the window runs it every frame;
|
||||||
/// `anybot --serve` runs it on its own).
|
/// `anybot --serve` runs it on its own).
|
||||||
fn background(&mut self, ctx: &egui::Context) {
|
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);
|
self.drain_events(ctx);
|
||||||
if !self.demo {
|
if !self.demo {
|
||||||
self.channels_poll(ctx);
|
self.channels_poll(ctx);
|
||||||
}
|
}
|
||||||
self.remote_start(ctx);
|
|
||||||
self.remote_tick(ctx);
|
self.remote_tick(ctx);
|
||||||
self.tick_routines(ctx);
|
self.tick_routines(ctx);
|
||||||
self.watchdog();
|
self.watchdog();
|
||||||
|
|
@ -2971,7 +3073,11 @@ impl App {
|
||||||
|
|
||||||
fn save_if_dirty(&mut self) {
|
fn save_if_dirty(&mut self) {
|
||||||
if self.dirty && !self.demo && (self.streams.is_empty() || self.last_save.elapsed().as_secs() >= 2) {
|
if self.dirty && !self.demo && (self.streams.is_empty() || self.last_save.elapsed().as_secs() >= 2) {
|
||||||
|
if self.mir.on {
|
||||||
|
self.mirror_save();
|
||||||
|
} else {
|
||||||
self.state.save();
|
self.state.save();
|
||||||
|
}
|
||||||
self.dirty = false;
|
self.dirty = false;
|
||||||
self.last_save = Instant::now();
|
self.last_save = Instant::now();
|
||||||
}
|
}
|
||||||
|
|
@ -2985,7 +3091,16 @@ impl App {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some((url, user)) = acct.rsplit_once('|') else { 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) => {
|
Ok(s) => {
|
||||||
self.state.remote = s;
|
self.state.remote = s;
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
|
|
@ -3033,7 +3148,7 @@ impl App {
|
||||||
}
|
}
|
||||||
app.drain_events(ctx);
|
app.drain_events(ctx);
|
||||||
app.watchdog();
|
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));
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
let secs = t0.elapsed().as_secs();
|
let secs = t0.elapsed().as_secs();
|
||||||
|
|
@ -3081,6 +3196,11 @@ impl App {
|
||||||
app.dirty = true;
|
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.serve_join();
|
||||||
app.background(ctx);
|
app.background(ctx);
|
||||||
if let Some(port) = crate::remote::lan_port() {
|
if let Some(port) = crate::remote::lan_port() {
|
||||||
|
|
@ -3095,16 +3215,28 @@ impl App {
|
||||||
eprintln!("AnyBot:還沒有設定手機密碼或帳號。用 ANYBOT_PHONE_PASSWORD=… 或 ANYBOT_ACCOUNT=網址|帳號 + ANYBOT_ACCOUNT_PASSWORD=… 啟動。");
|
eprintln!("AnyBot:還沒有設定手機密碼或帳號。用 ANYBOT_PHONE_PASSWORD=… 或 ANYBOT_ACCOUNT=網址|帳號 + ANYBOT_ACCOUNT_PASSWORD=… 啟動。");
|
||||||
}
|
}
|
||||||
let mut last_try = Instant::now();
|
let mut last_try = Instant::now();
|
||||||
|
let mut last_brain = Instant::now() - std::time::Duration::from_secs(60);
|
||||||
loop {
|
loop {
|
||||||
// The account server may not be up yet (or the account not made yet): keep trying.
|
// 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 {
|
if app.state.remote.host_token.is_empty() && last_try.elapsed().as_secs() >= 30 {
|
||||||
last_try = Instant::now();
|
last_try = Instant::now();
|
||||||
app.serve_join();
|
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.background(ctx);
|
||||||
app.save_if_dirty();
|
app.save_if_dirty();
|
||||||
// egui wants a frame now and then to forget old state; nothing is drawn.
|
// 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 }));
|
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
|
/// Screenshot mode only: ANYBOT_SHOT_CLICK="x,y@frame[;x,y@frame…][:right]" clicks there at that
|
||||||
/// frame (to check buttons really respond).
|
/// frame (to check buttons really respond).
|
||||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||||
if !self.demo {
|
if self.screenshot.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// ANYBOT_SHOT_DRAG="x1,y1>x2,y2@frame": press, move over 12 frames, release.
|
// 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) {
|
fn on_exit(&mut self) {
|
||||||
// Closing mid-reply ends that turn as cancelled, so it isn't left looking stuck.
|
// 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();
|
let ids: Vec<u64> = self.streams.keys().copied().collect();
|
||||||
for id in ids {
|
for id in ids {
|
||||||
self.stop(id);
|
self.stop(id);
|
||||||
|
|
@ -3223,9 +3359,13 @@ impl eframe::App for App {
|
||||||
tools::stop_all();
|
tools::stop_all();
|
||||||
crate::cloudbox::shutdown();
|
crate::cloudbox::shutdown();
|
||||||
if !self.demo {
|
if !self.demo {
|
||||||
|
if self.mir.on {
|
||||||
|
self.mirror_save();
|
||||||
|
} else {
|
||||||
self.state.save();
|
self.state.save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- helpers ----------
|
// ---------- helpers ----------
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use crate::attach::{self, Attachment};
|
||||||
pub struct AttachUi {
|
pub struct AttachUi {
|
||||||
pub pending: HashMap<u64, Vec<Attachment>>,
|
pub pending: HashMap<u64, Vec<Attachment>>,
|
||||||
picked: Option<Receiver<(u64, Vec<PathBuf>)>>,
|
picked: Option<Receiver<(u64, Vec<PathBuf>)>>,
|
||||||
thumbs: HashMap<String, Option<egui::TextureHandle>>,
|
pub(super) thumbs: HashMap<String, Option<egui::TextureHandle>>,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
voice: Option<(u64, crate::voice::Recording)>,
|
voice: Option<(u64, crate::voice::Recording)>,
|
||||||
voice_rx: Option<Receiver<(u64, Result<String, String>)>>,
|
voice_rx: Option<Receiver<(u64, Result<String, String>)>>,
|
||||||
|
|
|
||||||
|
|
@ -351,7 +351,14 @@ impl App {
|
||||||
// ── AnyBot Computer Use: seeing and using this computer's screen ──
|
// ── AnyBot Computer Use: seeing and using this computer's screen ──
|
||||||
self.mac_permissions_card(ui);
|
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));
|
sec_title(ui, "Bot 的電腦", p, |ui| status_pill(ui, &run, p));
|
||||||
#[derive(PartialEq)]
|
#[derive(PartialEq)]
|
||||||
enum Act {
|
enum Act {
|
||||||
|
|
@ -656,6 +663,8 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// ── 示範教學、瀏覽器 ──
|
// ── 示範教學、瀏覽器 ──
|
||||||
ui.add_space(SEC_GAP * 0.5);
|
ui.add_space(SEC_GAP * 0.5);
|
||||||
ui.spacing_mut().item_spacing.y = 6.0;
|
ui.spacing_mut().item_spacing.y = 6.0;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -51,9 +51,11 @@ impl App {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.rm.started = true;
|
self.rm.started = true;
|
||||||
|
self.ctx_handle = Some(ctx.clone());
|
||||||
let c = ctx.clone();
|
let c = ctx.clone();
|
||||||
remote::hub().set_repaint(Arc::new(move || c.request_repaint()));
|
remote::hub().set_repaint(Arc::new(move || c.request_repaint()));
|
||||||
self.remote_apply_settings();
|
self.remote_apply_settings();
|
||||||
|
self.mirror_boot();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hand changed settings to the server threads (and restart what depends on them).
|
/// Hand changed settings to the server threads (and restart what depends on them).
|
||||||
|
|
@ -84,8 +86,14 @@ impl App {
|
||||||
}
|
}
|
||||||
self.remote_apply_settings();
|
self.remote_apply_settings();
|
||||||
for c in remote::hub().take_cmds() {
|
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.remote_cmd(ctx, c);
|
||||||
}
|
}
|
||||||
|
self.brain_serve();
|
||||||
|
self.brain_watch();
|
||||||
if let Some(rx) = &self.rm.join_rx {
|
if let Some(rx) = &self.rm.join_rx {
|
||||||
if let Ok(r) = rx.try_recv() {
|
if let Ok(r) = rx.try_recv() {
|
||||||
self.rm.join_rx = None;
|
self.rm.join_rx = None;
|
||||||
|
|
@ -118,7 +126,7 @@ impl App {
|
||||||
}
|
}
|
||||||
// 自動備份: once a day while signed in.
|
// 自動備份: once a day while signed in.
|
||||||
let s = &self.state.remote;
|
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();
|
self.remote_backup();
|
||||||
}
|
}
|
||||||
// 裝置連結: keep the list of this account's computers fresh (once a minute).
|
// 裝置連結: 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();
|
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 {
|
fn remote_signature(&self) -> u64 {
|
||||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
self.state.remote.host_name.hash(&mut h);
|
self.state.remote.host_name.hash(&mut h);
|
||||||
|
self.scr.cfg_hash.hash(&mut h);
|
||||||
for b in &self.state.bots {
|
for b in &self.state.bots {
|
||||||
(b.id, &b.name, &b.color, b.unread, b.messages.len()).hash(&mut h);
|
(b.id, &b.name, &b.color, b.unread, b.messages.len()).hash(&mut h);
|
||||||
if let Some(m) = b.messages.last() {
|
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 {
|
for c in &m.tool_calls {
|
||||||
(c.status as u8).hash(&mut h);
|
(c.status as u8).hash(&mut h);
|
||||||
}
|
}
|
||||||
|
|
@ -214,6 +279,27 @@ impl App {
|
||||||
self.start_reply(ctx, bot);
|
self.start_reply(ctx, bot);
|
||||||
}
|
}
|
||||||
Cmd::Stop { bot } => self.stop(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 } => {
|
Cmd::Answer { bot, answer } => {
|
||||||
let grant = self.approvals.get(&bot).map(|a| a.grant.clone()).unwrap_or_default();
|
let grant = self.approvals.get(&bot).map(|a| a.grant.clone()).unwrap_or_default();
|
||||||
let a = match answer.as_str() {
|
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_card(ui, p, |ui| {
|
||||||
setting_title(ui, "同一個網路直接連", "手機和這台電腦在同一個 Wi-Fi,或都裝了 Tailscale 時使用。", p);
|
setting_title(ui, "同一個網路直接連", "手機和這台電腦在同一個 Wi-Fi,或都裝了 Tailscale 時使用。", p);
|
||||||
|
|
|
||||||
13
src/httpd.rs
13
src/httpd.rs
|
|
@ -178,9 +178,16 @@ pub fn write_response(c: &mut TcpStream, r: &Resp) {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Handler = Arc<dyn Fn(Req) -> Resp + Send + Sync>;
|
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).
|
/// 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>) {
|
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();
|
listener.set_nonblocking(true).ok();
|
||||||
loop {
|
loop {
|
||||||
if !alive() {
|
if !alive() {
|
||||||
|
|
@ -190,7 +197,13 @@ pub fn serve(listener: TcpListener, handler: Handler, alive: Arc<dyn Fn() -> boo
|
||||||
Ok((mut c, _)) => {
|
Ok((mut c, _)) => {
|
||||||
c.set_nonblocking(false).ok();
|
c.set_nonblocking(false).ok();
|
||||||
let h = handler.clone();
|
let h = handler.clone();
|
||||||
|
let up = upgrade.clone();
|
||||||
std::thread::spawn(move || match read_request(&mut c) {
|
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) => {
|
Ok(req) => {
|
||||||
let resp = h(req);
|
let resp = h(req);
|
||||||
write_response(&mut c, &resp);
|
write_response(&mut c, &resp);
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ mod preview;
|
||||||
mod providers;
|
mod providers;
|
||||||
mod remote;
|
mod remote;
|
||||||
mod routines;
|
mod routines;
|
||||||
|
mod setup;
|
||||||
mod teach;
|
mod teach;
|
||||||
mod share;
|
mod share;
|
||||||
mod teams;
|
mod teams;
|
||||||
|
|
@ -46,7 +47,9 @@ mod triggers;
|
||||||
mod vault;
|
mod vault;
|
||||||
mod vault_store;
|
mod vault_store;
|
||||||
mod voice;
|
mod voice;
|
||||||
|
mod tunnel;
|
||||||
mod web;
|
mod web;
|
||||||
|
mod wire;
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
@ -216,6 +219,10 @@ fn main() -> eframe::Result<()> {
|
||||||
}
|
}
|
||||||
std::process::exit(0);
|
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") {
|
if args.get(1).map(String::as_str) == Some("--ask") {
|
||||||
let text = args[2..].join(" ");
|
let text = args[2..].join(" ");
|
||||||
let ctx = egui::Context::default();
|
let ctx = egui::Context::default();
|
||||||
|
|
|
||||||
217
src/providers.rs
217
src/providers.rs
|
|
@ -371,9 +371,10 @@ pub fn run_step(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEven
|
||||||
let req = &repaired;
|
let req = &repaired;
|
||||||
let mut blind_copy: Option<ChatRequest> = None;
|
let mut blind_copy: Option<ChatRequest> = None;
|
||||||
loop {
|
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() {
|
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 req = blind_copy.as_ref().unwrap_or(req);
|
||||||
let result = match req.kind {
|
let result = match req.kind {
|
||||||
|
|
@ -519,6 +520,142 @@ pub fn repair_history(history: &[Message]) -> Vec<Message> {
|
||||||
out
|
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.
|
/// The same request with pictures replaced by a short note.
|
||||||
fn without_images(req: &ChatRequest) -> ChatRequest {
|
fn without_images(req: &ChatRequest) -> ChatRequest {
|
||||||
let mut r = req.clone();
|
let mut r = req.clone();
|
||||||
|
|
@ -1231,6 +1368,82 @@ mod tests {
|
||||||
assert!(!sees_images(&req));
|
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]
|
#[test]
|
||||||
fn steps_without_results_are_closed() {
|
fn steps_without_results_are_closed() {
|
||||||
let mut a = Message::assistant("", "");
|
let mut a = Message::assistant("", "");
|
||||||
|
|
|
||||||
369
src/remote.rs
369
src/remote.rs
|
|
@ -72,11 +72,22 @@ pub struct Settings {
|
||||||
pub auto_backup: bool,
|
pub auto_backup: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_backup: i64,
|
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 {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
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 },
|
Answer { bot: u64, answer: String },
|
||||||
Read { bot: u64 },
|
Read { bot: u64 },
|
||||||
NewThread { 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.
|
/// A new phone signed in (to remember), or one was signed out.
|
||||||
DeviceAdded(Device),
|
DeviceAdded(Device),
|
||||||
DeviceSeen(String),
|
DeviceSeen(String),
|
||||||
|
|
@ -126,11 +142,25 @@ pub struct Hub {
|
||||||
settings: Mutex<Settings>,
|
settings: Mutex<Settings>,
|
||||||
fails: Mutex<(u32, Option<Instant>)>,
|
fails: Mutex<(u32, Option<Instant>)>,
|
||||||
relay_status: Mutex<String>,
|
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 {
|
pub fn hub() -> &'static Hub {
|
||||||
static H: OnceLock<Hub> = OnceLock::new();
|
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);
|
static VERSION: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
@ -141,6 +171,24 @@ impl Hub {
|
||||||
*self.view.lock().unwrap() = v;
|
*self.view.lock().unwrap() = v;
|
||||||
self.changed.notify_all();
|
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> {
|
pub fn take_cmds(&self) -> Vec<Cmd> {
|
||||||
std::mem::take(&mut *self.cmds.lock().unwrap())
|
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),
|
("GET", ["api", "screen", which]) => screen(which, &s),
|
||||||
("POST", ["api", "screen", which]) => screen_input(which, &req.json(), &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 => {
|
("POST", ["api", "logout"]) if !via_relay => {
|
||||||
let h = httpd::sha256_hex(&req.bearer());
|
let h = httpd::sha256_hex(&req.bearer());
|
||||||
hub().push(Cmd::DeviceSeen(format!("-{h}")));
|
hub().push(Cmd::DeviceSeen(format!("-{h}")));
|
||||||
|
|
@ -342,9 +444,12 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
|
||||||
/// account) may run tools here.
|
/// account) may run tools here.
|
||||||
pub fn role_allows(role: &str, method: &str, path: &[&str]) -> bool {
|
pub fn role_allows(role: &str, method: &str, path: &[&str]) -> bool {
|
||||||
let device = matches!(path, ["api", "device", "run"]);
|
let device = matches!(path, ["api", "device", "run"]);
|
||||||
|
let sync = matches!(path, ["api", "sync", ..]);
|
||||||
match role {
|
match role {
|
||||||
"device" => device,
|
"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"])),
|
"member" => matches!((method, path), ("GET", ["api", "state"]) | ("GET", ["api", "bots", _]) | ("POST", ["api", "bots", _, "send" | "read" | "upload"])),
|
||||||
_ => false,
|
_ => 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
|
/// 裝置連結: 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.
|
/// 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 {
|
if !via_relay || !s.device_link {
|
||||||
return Resp::error(403, "這台電腦沒有開放給帳號裡的其他 AnyBot 操作");
|
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 tool = v["tool"].as_str().unwrap_or("");
|
||||||
let args = v["args"].as_str().unwrap_or("{}");
|
let args = v["args"].as_str().unwrap_or("{}");
|
||||||
let who = v["who"].as_str().unwrap_or("遠端 AnyBot");
|
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);
|
let (text, ok, img) = crate::computer::run_by(tool, args, who);
|
||||||
return Resp::json(&json!({"text": text, "ok": ok, "image": img}));
|
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") {
|
if crate::macapps::is_mac_tool(tool) && cfg!(target_os = "macos") {
|
||||||
let (text, ok) = crate::macapps::run(tool, args);
|
let (text, ok) = crate::macapps::run(tool, args);
|
||||||
return Resp::json(&json!({"text": text, "ok": ok}));
|
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);
|
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 {
|
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> {
|
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() {
|
if !bearer.is_empty() {
|
||||||
r = r.header("Authorization", &format!("Bearer {bearer}"));
|
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 st = resp.status().as_u16();
|
||||||
let text = resp.body_mut().read_to_string().unwrap_or_default();
|
let text = resp.body_mut().read_to_string().unwrap_or_default();
|
||||||
let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
|
let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
|
||||||
if !(200..300).contains(&st) {
|
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)
|
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());
|
return Err("帳號伺服器要用 https:// 開頭的網址(這台電腦或 Tailscale 位址可以用 http://)".into());
|
||||||
}
|
}
|
||||||
if create {
|
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 v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": host_name}))?;
|
||||||
let session = v["token"].as_str().ok_or("帳號伺服器沒有給登入憑證")?.to_string();
|
let 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());
|
let (url, token) = (s.relay_url.clone(), s.host_token.clone());
|
||||||
hub().set_relay_status("連線中…");
|
hub().set_relay_status("連線中…");
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
// 長連線 first; an older account server without it gets asked over and over instead.
|
||||||
|
if crate::tunnel::run(&url, &token, &|| alive(gen), &|m| hub().set_relay_status(m)) || !alive(gen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// A few workers, so a slow request (a long-poll) doesn't block the others.
|
// A few workers, so a slow request (a long-poll) doesn't block the others.
|
||||||
for _ in 0..4 {
|
for _ in 0..4 {
|
||||||
let (url, token) = (url.clone(), token.clone());
|
let (url, token) = (url.clone(), token.clone());
|
||||||
std::thread::spawn(move || relay_worker(&url, &token, gen));
|
std::thread::spawn(move || relay_worker(&url, &token, gen));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn alive(gen: u64) -> bool {
|
fn alive(gen: u64) -> bool {
|
||||||
|
|
@ -637,7 +817,7 @@ fn relay_worker(url: &str, token: &str, gen: u64) {
|
||||||
let mut resp = match r {
|
let mut resp = match r {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
hub().set_relay_status(&format!("連不上帳號伺服器:{e}"));
|
hub().set_relay_status(&format!("連不上帳號伺服器:{}", why_unreachable(&e)));
|
||||||
std::thread::sleep(Duration::from_secs(backoff));
|
std::thread::sleep(Duration::from_secs(backoff));
|
||||||
backoff = (backoff * 2).min(30);
|
backoff = (backoff * 2).min(30);
|
||||||
continue;
|
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 text = resp.body_mut().read_to_string().unwrap_or_default();
|
||||||
let Ok(job) = serde_json::from_str::<Value>(&text) else {
|
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));
|
std::thread::sleep(Duration::from_secs(backoff));
|
||||||
|
backoff = (backoff * 2).min(30);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
hub().set_relay_status("已連線");
|
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(),
|
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(),
|
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 裝置連結).
|
// 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(),
|
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: base64::engine::general_purpose::STANDARD.decode(job["body"].as_str().unwrap_or("")).unwrap_or_default(),
|
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(),
|
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() {
|
if s.relay_url.is_empty() {
|
||||||
return Err("沒有登入帳號,連不到其他裝置".into());
|
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 url = format!("{}/relay/hosts/{host_id}/api/device/run", s.relay_url);
|
||||||
let mut resp = agent(45)
|
let mut resp = agent(45)
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header("Authorization", &format!("Bearer {}", s.host_token))
|
.header("Authorization", &format!("Bearer {}", s.host_token))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.send(json!({"tool": tool, "args": args, "who": who}).to_string().as_bytes())
|
.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 st = resp.status().as_u16();
|
||||||
let text = resp.body_mut().with_config().limit(40 * 1024 * 1024).read_to_string().unwrap_or_default();
|
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);
|
let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
|
||||||
if !(200..300).contains(&st) {
|
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)
|
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).
|
/// A notification on the person's phones (through the relay; phones that turned them on).
|
||||||
pub fn notify(title: &str, body: &str, bot: u64) {
|
pub fn notify(title: &str, body: &str, bot: u64) {
|
||||||
let s = hub().settings();
|
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 裝置連結).
|
/// 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)> {
|
pub fn devices_online(s: &Settings) -> Vec<(String, String, bool)> {
|
||||||
if s.relay_url.is_empty() {
|
if s.relay_url.is_empty() {
|
||||||
return vec![];
|
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 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);
|
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()
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
31
src/tools.rs
31
src/tools.rs
|
|
@ -427,6 +427,12 @@ pub fn protected_dirs() -> Vec<PathBuf> {
|
||||||
v
|
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> {
|
fn sandbox_exec() -> Option<&'static str> {
|
||||||
let p = "/usr/bin/sandbox-exec";
|
let p = "/usr/bin/sandbox-exec";
|
||||||
(cfg!(target_os = "macos") && Path::new(p).exists()).then_some(p)
|
(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.
|
/// The shell command, wrapped in the sandbox when the system has one.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn build_command(cmd: &str, workdir: &Path, protected: &[PathBuf], conversation: &str, request: &str) -> Command {
|
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
|
/// `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.
|
/// 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() {
|
let mut c = match sandbox_exec() {
|
||||||
Some(se) if !protected.is_empty() || seal.is_some() => {
|
Some(se) if !protected.is_empty() || seal.is_some() => {
|
||||||
let rules: String = (0..protected.len()).map(|i| format!(" (subpath (param \"P{i}\"))")).collect();
|
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() {
|
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\")))))");
|
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);
|
let mut c = Command::new(se);
|
||||||
for (i, p) in protected.iter().enumerate() {
|
for (i, p) in protected.iter().enumerate() {
|
||||||
c.arg("-D").arg(format!("P{i}={}", real(p)));
|
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 {
|
if let Some(home) = seal {
|
||||||
c.arg("-D").arg(format!("HOME={}", real(home))).arg("-D").arg(format!("ROOT={}", real(workdir)));
|
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> {
|
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.
|
/// 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"));
|
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();
|
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"));
|
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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/web.rs
25
src/web.rs
|
|
@ -59,7 +59,28 @@ pub fn read_needs_permission(args: &str) -> bool {
|
||||||
uses_person_browser(args)
|
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() {
|
let (names, current) = if crate::botbrowser::available() {
|
||||||
(crate::botbrowser::choices().join(", "), crate::botbrowser::current())
|
(crate::botbrowser::choices().join(", "), crate::botbrowser::current())
|
||||||
} else {
|
} 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.",
|
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"]}}),
|
"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.extend(crate::browser::specs(&profile));
|
||||||
}
|
}
|
||||||
v
|
v
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue