266 lines
10 KiB
Bash
266 lines
10 KiB
Bash
#!/usr/bin/env bash
|
||
|
||
set -Eeuo pipefail
|
||
|
||
PUBLIC_URL="${FINANCE_BOT_PUBLIC_URL:-https://disk.yandex.ru/d/hhbDTO08CB030Q}"
|
||
DEFAULT_INSTALL_DIR="/opt/finance-bot"
|
||
YANDEX_API="https://cloud-api.yandex.net/v1/disk/public/resources/download"
|
||
TMP_DIR=""
|
||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||
|
||
if [[ -t 1 ]]; then
|
||
BOLD='\033[1m'
|
||
GREEN='\033[0;32m'
|
||
YELLOW='\033[0;33m'
|
||
RED='\033[0;31m'
|
||
RESET='\033[0m'
|
||
else
|
||
BOLD=''
|
||
GREEN=''
|
||
YELLOW=''
|
||
RED=''
|
||
RESET=''
|
||
fi
|
||
|
||
info() { printf '%b\n' "${GREEN}✓${RESET} $*"; }
|
||
warn() { printf '%b\n' "${YELLOW}!${RESET} $*"; }
|
||
fail() { printf '%b\n' "${RED}Ошибка:${RESET} $*" >&2; exit 1; }
|
||
|
||
cleanup() {
|
||
if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then
|
||
rm -rf -- "$TMP_DIR"
|
||
fi
|
||
}
|
||
trap cleanup EXIT
|
||
|
||
require_root() {
|
||
if [[ "${EUID}" -ne 0 ]]; then
|
||
fail "запустите установщик с правами администратора: sudo bash install.sh"
|
||
fi
|
||
}
|
||
|
||
install_base_tools() {
|
||
local missing=()
|
||
local command_name
|
||
for command_name in curl unzip python3; do
|
||
command -v "$command_name" >/dev/null 2>&1 || missing+=("$command_name")
|
||
done
|
||
[[ -e /usr/share/zoneinfo/UTC ]] || missing+=("tzdata")
|
||
if (( ${#missing[@]} == 0 )); then
|
||
return
|
||
fi
|
||
|
||
command -v apt-get >/dev/null 2>&1 \
|
||
|| fail "установите curl, unzip и python3, затем запустите скрипт повторно"
|
||
warn "Устанавливаю системные утилиты: ${missing[*]}"
|
||
apt-get update
|
||
DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl unzip python3 tzdata
|
||
}
|
||
|
||
install_docker() {
|
||
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||
info "Docker и Docker Compose уже установлены"
|
||
return
|
||
fi
|
||
|
||
local answer
|
||
read -r -p "Docker Compose не найден. Установить Docker из официального репозитория? [Y/n]: " answer
|
||
answer="${answer:-Y}"
|
||
[[ "$answer" =~ ^[YyДд]$ ]] || fail "для запуска бота необходимы Docker Engine и Docker Compose"
|
||
|
||
[[ -r /etc/os-release ]] || fail "не удалось определить дистрибутив Linux"
|
||
# shellcheck disable=SC1091
|
||
. /etc/os-release
|
||
case "${ID:-}" in
|
||
ubuntu|debian) ;;
|
||
*) fail "автоматическая установка Docker поддерживает Ubuntu и Debian; установите Docker вручную" ;;
|
||
esac
|
||
|
||
info "Подключаю официальный репозиторий Docker"
|
||
apt-get update
|
||
DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl
|
||
install -m 0755 -d /etc/apt/keyrings
|
||
curl -fsSL "https://download.docker.com/linux/${ID}/gpg" -o /etc/apt/keyrings/docker.asc
|
||
chmod a+r /etc/apt/keyrings/docker.asc
|
||
|
||
local codename architecture
|
||
codename="${UBUNTU_CODENAME:-${VERSION_CODENAME:-}}"
|
||
[[ -n "$codename" ]] || fail "не удалось определить кодовое имя дистрибутива"
|
||
architecture="$(dpkg --print-architecture)"
|
||
cat > /etc/apt/sources.list.d/docker.sources <<EOF
|
||
Types: deb
|
||
URIs: https://download.docker.com/linux/${ID}
|
||
Suites: ${codename}
|
||
Components: stable
|
||
Architectures: ${architecture}
|
||
Signed-By: /etc/apt/keyrings/docker.asc
|
||
EOF
|
||
|
||
apt-get update
|
||
DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||
systemctl enable --now docker
|
||
docker compose version >/dev/null 2>&1 || fail "Docker Compose не запустился после установки"
|
||
info "Docker установлен"
|
||
}
|
||
|
||
prompt_install_dir() {
|
||
local entered
|
||
read -r -p "Папка установки [${DEFAULT_INSTALL_DIR}]: " entered
|
||
INSTALL_DIR="${entered:-$DEFAULT_INSTALL_DIR}"
|
||
[[ "$INSTALL_DIR" == /* ]] || fail "путь установки должен быть абсолютным"
|
||
case "$INSTALL_DIR" in
|
||
/|/etc|/usr|/var|/opt) fail "выберите отдельную папку, например ${DEFAULT_INSTALL_DIR}" ;;
|
||
esac
|
||
}
|
||
|
||
read_existing_value() {
|
||
local key="$1"
|
||
local env_file="$2"
|
||
[[ -f "$env_file" ]] || return 0
|
||
sed -n "s/^${key}=//p" "$env_file" | tail -n 1
|
||
}
|
||
|
||
prompt_bot_settings() {
|
||
local env_file="${INSTALL_DIR}/.env"
|
||
local existing_token existing_timezone existing_report_time keep answer
|
||
existing_token="$(read_existing_value BOT_TOKEN "$env_file")"
|
||
existing_timezone="$(read_existing_value BOT_TIMEZONE "$env_file")"
|
||
existing_report_time="$(read_existing_value DAILY_REPORT_TIME "$env_file")"
|
||
|
||
BOT_TOKEN=""
|
||
if [[ -n "$existing_token" ]]; then
|
||
read -r -p "Найдены прежние настройки. Сохранить текущий токен бота? [Y/n]: " keep
|
||
keep="${keep:-Y}"
|
||
if [[ "$keep" =~ ^[YyДд]$ ]]; then
|
||
BOT_TOKEN="$existing_token"
|
||
fi
|
||
fi
|
||
while [[ ! "$BOT_TOKEN" =~ ^[0-9]{6,}:[A-Za-z0-9_-]{20,}$ ]]; do
|
||
read -r -s -p "Токен от @BotFather: " BOT_TOKEN
|
||
printf '\n'
|
||
[[ "$BOT_TOKEN" =~ ^[0-9]{6,}:[A-Za-z0-9_-]{20,}$ ]] \
|
||
|| warn "Токен выглядит некорректно. Попробуйте ещё раз."
|
||
done
|
||
|
||
local default_timezone="${existing_timezone:-Asia/Novosibirsk}"
|
||
while true; do
|
||
read -r -p "Часовой пояс [${default_timezone}]: " answer
|
||
BOT_TIMEZONE="${answer:-$default_timezone}"
|
||
if [[ "$BOT_TIMEZONE" =~ ^[A-Za-z0-9_+.-]+(/[A-Za-z0-9_+.-]+)+$ ]] \
|
||
&& [[ -e "/usr/share/zoneinfo/${BOT_TIMEZONE}" ]]; then
|
||
break
|
||
fi
|
||
warn "Неизвестный часовой пояс. Пример: Asia/Novosibirsk"
|
||
done
|
||
|
||
local default_report_time="${existing_report_time:-21:00}"
|
||
while true; do
|
||
read -r -p "Время ежедневного отчёта [${default_report_time}]: " answer
|
||
DAILY_REPORT_TIME="${answer:-$default_report_time}"
|
||
if [[ "$DAILY_REPORT_TIME" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]]; then
|
||
break
|
||
fi
|
||
warn "Введите время в формате ЧЧ:ММ, например 21:00"
|
||
done
|
||
}
|
||
|
||
download_project() {
|
||
TMP_DIR="$(mktemp -d)"
|
||
local api_response download_url archive project_root
|
||
archive="${TMP_DIR}/finance-bot.zip"
|
||
|
||
info "Получаю проект с Яндекс.Диска"
|
||
api_response="$(curl -fsS --get --data-urlencode "public_key=${PUBLIC_URL}" "$YANDEX_API")" \
|
||
|| fail "Яндекс.Диск не вернул ссылку на скачивание"
|
||
download_url="$(printf '%s' "$api_response" | python3 -c '
|
||
import json, sys
|
||
try:
|
||
payload = json.load(sys.stdin)
|
||
print(payload["href"])
|
||
except (KeyError, ValueError):
|
||
raise SystemExit(1)
|
||
')" || fail "не удалось разобрать ответ Яндекс.Диска; проверьте публичную ссылку"
|
||
|
||
curl -fL --retry 3 --connect-timeout 20 "$download_url" -o "$archive" \
|
||
|| fail "не удалось скачать архив проекта"
|
||
[[ -s "$archive" ]] || fail "Яндекс.Диск вернул пустой архив"
|
||
if (( $(stat -c '%s' "$archive") > 209715200 )); then
|
||
fail "архив больше 200 МБ; установка остановлена"
|
||
fi
|
||
unzip -tq "$archive" >/dev/null || fail "скачанный файл не является исправным ZIP-архивом"
|
||
if unzip -Z1 "$archive" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then
|
||
fail "в архиве обнаружены небезопасные пути"
|
||
fi
|
||
|
||
mkdir -p "${TMP_DIR}/extracted"
|
||
unzip -q "$archive" -d "${TMP_DIR}/extracted"
|
||
COMPOSE_FILE="$(find "${TMP_DIR}/extracted" -maxdepth 4 -type f -name compose.yaml -print -quit)"
|
||
[[ -n "$COMPOSE_FILE" ]] || fail "в архиве не найден compose.yaml"
|
||
project_root="$(dirname "$COMPOSE_FILE")"
|
||
[[ -f "${project_root}/Dockerfile" && -f "${project_root}/pyproject.toml" && -d "${project_root}/app" ]] \
|
||
|| fail "структура проекта в архиве неполная"
|
||
DOWNLOADED_PROJECT_DIR="$project_root"
|
||
info "Проект скачан и проверен"
|
||
}
|
||
|
||
locate_project() {
|
||
if [[ -f "${SCRIPT_DIR}/compose.yaml" \
|
||
&& -f "${SCRIPT_DIR}/Dockerfile" \
|
||
&& -f "${SCRIPT_DIR}/pyproject.toml" \
|
||
&& -d "${SCRIPT_DIR}/app" ]]; then
|
||
DOWNLOADED_PROJECT_DIR="$SCRIPT_DIR"
|
||
info "Использую проект из ${SCRIPT_DIR}"
|
||
return
|
||
fi
|
||
|
||
warn "Рядом с установщиком не найден проект — загружаю резервную копию с Яндекс.Диска"
|
||
download_project
|
||
}
|
||
|
||
deploy_project() {
|
||
mkdir -p "$INSTALL_DIR"
|
||
cp -a "${DOWNLOADED_PROJECT_DIR}/." "$INSTALL_DIR/"
|
||
|
||
umask 077
|
||
cat > "${INSTALL_DIR}/.env" <<EOF
|
||
BOT_TOKEN=${BOT_TOKEN}
|
||
BOT_TIMEZONE=${BOT_TIMEZONE}
|
||
DAILY_REPORT_TIME=${DAILY_REPORT_TIME}
|
||
DATABASE_PATH=/app/data/finance.db
|
||
LOG_LEVEL=INFO
|
||
EOF
|
||
chmod 600 "${INSTALL_DIR}/.env"
|
||
|
||
info "Собираю и запускаю контейнер"
|
||
docker compose --project-directory "$INSTALL_DIR" -f "${INSTALL_DIR}/compose.yaml" \
|
||
up -d --build --remove-orphans
|
||
|
||
sleep 5
|
||
local status
|
||
status="$(docker inspect --format '{{.State.Status}}' finance-telegram-bot 2>/dev/null || true)"
|
||
if [[ "$status" != "running" ]]; then
|
||
docker compose --project-directory "$INSTALL_DIR" -f "${INSTALL_DIR}/compose.yaml" \
|
||
logs --tail=40 finance-bot || true
|
||
fail "контейнер не запустился; выше показаны последние строки журнала"
|
||
fi
|
||
|
||
info "Бот установлен и запущен"
|
||
printf '\n%b\n' "${BOLD}Папка:${RESET} ${INSTALL_DIR}"
|
||
printf '%b\n' "${BOLD}Статус:${RESET} docker compose -f ${INSTALL_DIR}/compose.yaml ps"
|
||
printf '%b\n' "${BOLD}Журнал:${RESET} docker compose -f ${INSTALL_DIR}/compose.yaml logs -f"
|
||
printf '%b\n' "Повторный запуск этого установщика обновит код и сохранит базу данных."
|
||
}
|
||
|
||
main() {
|
||
printf '%b\n\n' "${BOLD}Установка финансового Telegram-бота${RESET}"
|
||
require_root
|
||
install_base_tools
|
||
prompt_install_dir
|
||
prompt_bot_settings
|
||
install_docker
|
||
locate_project
|
||
deploy_project
|
||
}
|
||
|
||
main "$@"
|