#!/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() { 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 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 card_close; pause # 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=4; 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 ask BRAIN "大腦名字" "${BRAIN:-雲端大腦}" [ -z "$BRAIN" ] && BRAIN="雲端大腦" break done choose OTHERS "別人可以在這台註冊帳號嗎?" \ "只有我|最安全" \ "給邀請碼的人可以|家人或同事拿邀請碼註冊,各自有自己的帳號和 Bot" INVITE="" [ "$OTHERS" = 1 ] && INVITE=$(LC_ALL=C tr -dc 'a-z0-9' .env chmod 600 .env umask 022 sed "s/^anybot.example.com {/$DOMAIN {/" ../../relay/Caddyfile >Caddyfile fi # ---------- 4. start ---------- AT=4; 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}Linux 手腳${N} anybot setup(選「手腳」,再選「$BRAIN」)" [ -n "${INVITE:-}" ] && line "${B}邀請碼${N} $INVITE(給要註冊的人)" line "" note "看紀錄 docker compose logs -f anybot" note "更新 ./setup.sh(選「用現有設定」)" card_close printf '\n'