finance-telegram-bot/install.sh
2026-07-14 12:21:12 +07:00

251 lines
9.9 KiB
Bash
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
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=""
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 "Проект скачан и проверен"
}
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
download_project
deploy_project
}
main "$@"