AnyBot/deploy/server/setup.sh

392 lines
20 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/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).
#
# Without questions (scripts, Ansible…): give the answers as environment variables.
# ANYBOT_SETUP_USER=me ANYBOT_SETUP_PASSWORD=… ANYBOT_SETUP_DOMAIN=anybot.example.com ./setup.sh
# leave out ANYBOT_SETUP_DOMAIN on a computer with Tailscale to use its Tailscale address.
# ANYBOT_SETUP_DOMAIN=10.0.0.25 (an IP): only the local network, with a certificate this server
# signs itself; ANYBOT_SETUP_LAN_IP=10.0.0.25 with Tailscale: both.
# An existing setup is kept (updated and restarted).
set -u
cd "$(dirname "$0")"
DRY=0
[ "${1:-}" = "--dry-run" ] && DRY=1
AUTO=0
[ -n "${ANYBOT_SETUP_USER:-}" ] && [ -n "${ANYBOT_SETUP_PASSWORD:-}" ] && AUTO=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() {
# Without questions: the first choice (keep the setup, use Tailscale).
if [ "$AUTO" = 1 ]; then printf -v "$1" '%s' 0; return; fi
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() { [ "$AUTO" = 1 ] && return; printf ' %s%s%s' "$D" "${1:-按 Enter 繼續}" "$N"; IFS= read -r _; }
die() { card_close; printf '\n'; exit 1; }
is_ip() { printf '%s' "$1" | grep -Eq '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; }
# This server's address in the local network: the one its traffic leaves from.
lan_ip() {
local a
a=$(ip route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p' | head -1)
[ -z "$a" ] && a=$(hostname -I 2>/dev/null | awk '{print $1}')
printf '%s' "$a"
}
# The socket docker-socket-proxy has to mount: the one of the daemon this script runs compose
# against. Only rootless Docker needs it spelled out (/run/user/<uid>/docker.sock): there the
# compose default /var/run/docker.sock is another daemon (the root one, or none) and every Docker
# call of the brain comes back 503. Prints nothing when the default is right.
rootless_socket() {
docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q 'name=rootless' || return 0
local h=${DOCKER_HOST:-}
[ -z "$h" ] && h=$(docker context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null)
case "$h" in unix://*) printf '%s' "${h#unix://}" ;; esac
}
# ---------- 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
# (Tailscale's own 443 on its 100.x address isn't in the way.)
busy=$(ss -ltnH 2>/dev/null | awk '{print $4}' | grep -E ':(80|443)$' | grep -v docker | grep -vE '^(100\.|\[fd7a:115c:a1e0:)' || 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:-}
LANIP=${ANYBOT_LAN_IP:-}
AT=3; SKIP=1
fi
fi
if [ "${SKIP:-0}" != 1 ]; then
# ---------- 1. the address ----------
AT=1; MSG=""; TS=0; LAN=0; LANIP=""
# On a tailnet: offer its address (HTTPS from Tailscale, no domain, nothing open to the internet).
TSNAME=$(tailscale status --json 2>/dev/null | sed -n 's/.*"DNSName": *"\([^"]*\)\.".*/\1/p' | head -1)
if [ "$AUTO" = 1 ] && [ -n "${ANYBOT_SETUP_DOMAIN:-}" ]; then
DOMAIN=${ANYBOT_SETUP_DOMAIN#https://}; TSNAME=""; TS=2
is_ip "$DOMAIN" && { LAN=1; LANIP=$DOMAIN; }
fi
if [ "$AUTO" = 1 ] && [ -n "$TSNAME" ] && [ -n "${ANYBOT_SETUP_LAN_IP:-}" ]; then LAN=1; LANIP=$ANYBOT_SETUP_LAN_IP; fi
if [ "$AUTO" = 1 ] && [ "$TS" = 0 ] && [ -z "$TSNAME" ]; then
header; card_open "網址"; bad "這台沒有 Tailscale:請加上 ANYBOT_SETUP_DOMAIN=你的網域(或區網 IP)"; die
fi
# The local network's own address: HTTPS with a certificate this server signs itself.
LANDESC="憑證是這台自己簽的:每台手機和電腦要先信任一次。位址要固定(在路由器設成固定 IP)"
if [ -n "$TSNAME" ]; then
choose HOW "手機和其他電腦用哪個網址連到這台?" \
"用 Tailscale:https://$TSNAME|只有你 Tailscale 裡的手機和電腦連得到。不用網域,也不用開 80、443 埠" \
"Tailscale + 區網 IP:https://$(lan_ip)|在同一個區網用 IP 連,出門用 Tailscale。$LANDESC" \
"只用區網 IP:https://$(lan_ip)|只有同一個區網連得到。$LANDESC" \
"用自己的網域|任何地方都連得到(網域要先指到這台)"
case "$HOW" in
0) DOMAIN=$TSNAME; TS=1 ;;
1) DOMAIN=$TSNAME; TS=1; LAN=1 ;;
2) LAN=1; TS=2 ;;
esac
elif [ "$AUTO" = 0 ]; then
choose HOW "手機和其他電腦用哪個網址連到這台?" \
"用區網 IP:https://$(lan_ip)|只有同一個區網連得到,不用網域。$LANDESC" \
"用自己的網域|任何地方都連得到(網域要先指到這台)"
[ "$HOW" = 0 ] && { LAN=1; TS=2; }
fi
while [ "$LAN" = 1 ] && [ "$AUTO" = 0 ]; do
header; card_open "這台在區網的 IP"
note "手機和電腦在同一個區網打開 https://這個 IP。${LANDESC}。"
line ""
[ -n "$MSG" ] && { printf '%s\n' "$MSG"; line ""; }
ask LANIP "IP" "${LANIP:-$(lan_ip)}"
LANIP=${LANIP#https://}; LANIP=${LANIP#http://}; LANIP=${LANIP%%/*}
if ! is_ip "$LANIP"; then MSG=$(bad "請輸入像 10.0.0.25 這樣的 IP"); continue; fi
MSG=""
break
done
[ "$LAN" = 1 ] && [ "$TS" != 1 ] && DOMAIN=$LANIP
while [ "$TS" = 0 ]; 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
[ "$TS" = 2 ] && TS=0
# ---------- 2. the account ----------
AT=2; MSG=""
if [ "$AUTO" = 1 ]; then USERNAME=$ANYBOT_SETUP_USER; PW1=$ANYBOT_SETUP_PASSWORD; BRAIN="雲端大腦"; fi
while [ "$AUTO" = 0 ]; 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"
[ "$TS" = 1 ] && q ANYBOT_TAILSCALE 1
[ "$LAN" = 1 ] && q ANYBOT_LAN_IP "$LANIP"
case "$TS$LAN" in
10) q COMPOSE_FILE "docker-compose.yml:docker-compose.tailscale.yml" ;;
11) q COMPOSE_FILE "docker-compose.yml:docker-compose.tailscale.yml:docker-compose.lan.yml" ;;
01) q COMPOSE_FILE "docker-compose.yml:docker-compose.lan.yml" ;;
esac
} >.env
chmod 600 .env
umask 022
if [ "$LAN" = 1 ]; then
sed "s/LAN_IP/$LANIP/g" ../../relay/Caddyfile.lan >Caddyfile
else
sed "s/^anybot.example.com {/$DOMAIN {/" ../../relay/Caddyfile >Caddyfile
fi
fi
# Rootless Docker: pin its socket in .env, whether .env was just written or kept from an earlier
# setup (one made before docker-socket-proxy came along has no DOCKER_SOCKET).
SOCK=$(rootless_socket)
if [ -n "$SOCK" ]; then
if ! grep -qxF "DOCKER_SOCKET='$SOCK'" .env; then
(umask 077; { grep -v '^DOCKER_SOCKET=' .env; printf "DOCKER_SOCKET='%s'\n" "$SOCK"; } >.env.new) \
&& mv .env.new .env
fi
export DOCKER_SOCKET=$SOCK
fi
# ---------- 4. start ----------
AT=3; header; card_open "啟動"
ok "寫好設定(.env、Caddyfile)"
[ -n "$SOCK" ] && ok "Docker 是 rootless:大腦經由 $SOCK 管 Bot 的電腦"
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
# Small servers run out of memory compiling many crates at once: one compile per 2 GB.
if [ -z "${ANYBOT_BUILD_JOBS:-}" ] && [ -r /proc/meminfo ]; then
mem=$(awk '/MemTotal/ {m=$2} /SwapTotal/ {s=$2} END {print int((m + s / 2) / 1024)}' /proc/meminfo)
j=$((mem / 2000)); [ "$j" -lt 1 ] && j=1
export ANYBOT_BUILD_JOBS=$j
[ "$mem" -lt 3000 ] && warn "記憶體只有 ${mem} MB:編譯會比較久。被中斷的話先加 2 GB swap(見 docs/install.md)"
fi
spin "建置並啟動(第一次約 5–15 分鐘)" docker compose up -d --build || die
[ "${ANYBOT_TAILSCALE:-}" = 1 ] && TS=1
if [ "${TS:-0}" = 1 ]; then
# Tailscale gives the address HTTPS and passes it to the account server (remembered across reboots).
if tailscale serve --bg --https=443 http://127.0.0.1:8780 >/dev/null 2>&1 \
|| sudo tailscale serve --bg --https=443 http://127.0.0.1:8780 >/dev/null 2>&1; then
ok "Tailscale:https://$DOMAIN → 帳號伺服器"
else
bad "沒辦法設定 tailscale serve。請執行:sudo tailscale serve --bg --https=443 http://127.0.0.1:8780"
fi
fi
# 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).
if [ "$DOMAIN" != "${LANIP:-}" ]; then
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 可以連"
elif [ "${TS:-0}" = 1 ]; then warn "https://$DOMAIN 還連不到。Tailscale 的 HTTPS 要在管理頁打開(DNS › HTTPS Certificates),看設定:tailscale serve status"
else warn "https://$DOMAIN 還連不到(DNS 還沒生效,或 80/443 埠被防火牆擋住)。看紀錄:docker compose logs caddy"; fi
fi
# The local network's address: checked with the certificate Caddy signed it with.
if [ -n "${LANIP:-}" ]; then
lanok=""; ca=$(mktemp)
for _ in $(seq 1 60); do
if docker compose exec -T caddy cat /data/caddy/pki/authorities/local/root.crt >"$ca" 2>/dev/null \
&& curl -fsS --max-time 5 --cacert "$ca" "https://$LANIP/api/hello" 2>/dev/null | grep -q anybot-relay; then lanok=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'; rm -f "$ca"
if [ -n "$lanok" ]; then ok "https://$LANIP 可以連(憑證:http://$LANIP/anybot-ca.crt)"
else warn "https://$LANIP 還連不到(80/443 埠被防火牆擋住?)。看紀錄:docker compose logs caddy"; fi
fi
card_close
printf '\n'
card_open "$([ -n "$joined" ] && echo "完成了" || echo "差一點")"
line "${B}手機${N} 打開 ${A}https://$DOMAIN${N},用 ${B}$USERNAME${N} 登入,可以「加入主畫面」"
if [ -n "${LANIP:-}" ]; then
[ "$DOMAIN" != "$LANIP" ] && line "${B}區網${N} 也可以打開 ${A}https://$LANIP${N}(同一個區網)"
line "${B}信任憑證${N} 每台手機和電腦先打開 ${A}http://$LANIP/anybot-ca.crt${N} 安裝它(一次就好)"
note " iPhone:裝好描述檔後,到 設定 › 一般 › 關於本機 › 憑證信任設定 打開它"
note " Mac:在「鑰匙圈存取」把它設成「永遠信任」;Linux:放進 /usr/local/share/ca-certificates/ 再執行 update-ca-certificates"
fi
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'