diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..546c45d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +.git +.gitignore +.gitattributes + +# venv / кеши +.venv +.venv11 +venv +env +__pycache__ +*.py[cod] +*.egg-info +.pytest_cache +.ruff_cache +.mypy_cache + +# IDE / локальное +.idea +.vscode +AGENTS.md +checklist.md +*.log + +# инфраструктура и документация (в образ не нужны) +deploy +.gitea +tests +docs +*.md +Dockerfile +docker-compose*.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..ddea378 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + # Без под-фильтров: срабатывает на любой push (ветки и теги). Комбинация + # branches+tags в Forgejo не поддерживается и помечает workflow как проблемный. + push: + workflow_dispatch: + +env: + REGISTRY: git.mpakosrv.ru + IMAGE_NAME: mpakobecx/dispelevilbot-bot + +jobs: + lint-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Установка зависимостей + run: pip install -r requirements-dev.txt + # В Forgejo 1.21 `continue-on-error` игнорируется, поэтому неблокирующий + # режим для black сделан через shell. ruff обязателен. + - name: Ruff + run: ruff check . + - name: Black + run: black --check . || echo "::warning::black нашёл отличия в форматировании (пока не блокирует)" + - name: Pytest + run: pytest -q + + build-push: + runs-on: ubuntu-latest + needs: lint-test + # Собираем и пушим образ. Автодеплой НЕ делаем — выкатка вручную (test -> prod). + steps: + - uses: actions/checkout@v4 + - name: Логин в Gitea Registry + run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin + - name: Сборка и публикация образа + run: | + set -e + IMAGE="$REGISTRY/$IMAGE_NAME" + SHA="${GITHUB_SHA:-unknown}" + SHORT_SHA="$(echo "$SHA" | cut -c1-12)" + TAGS="$IMAGE:$SHORT_SHA $IMAGE:latest" + case "${GITHUB_REF:-}" in + refs/tags/*) TAGS="$TAGS $IMAGE:${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}" ;; + esac + ARGS="" + for t in $TAGS; do ARGS="$ARGS -t $t"; done + docker build $ARGS . + for t in $TAGS; do docker push "$t"; done diff --git a/.gitignore b/.gitignore index f4a36f1..85cb869 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ venv/ .env .env.* !.env.example +env/*.env +!env/*.env.example # IDE .idea/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dd19c88 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +# Непривилегированный пользователь +RUN useradd --create-home --uid 1000 app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY common ./common +COPY bot ./bot +COPY images ./images + +USER app + +# Живость процесса: бот обновляет файл-heartbeat (см. bot/services/heartbeat.py). +# Путь должен совпадать с HEARTBEAT_PATH (по умолчанию /tmp/dispelevilbot.heartbeat). +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD python -c "import os,sys,time; p='/tmp/dispelevilbot.heartbeat'; sys.exit(0 if os.path.exists(p) and time.time()-os.path.getmtime(p) < 120 else 1)" + +CMD ["python", "-m", "bot"] diff --git a/README.md b/README.md index 6692159..7d89dcf 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,11 @@ common/ # общий пакет: config, logging, db, repositories, models, bot/ # сервис бота: handlers, services, keyboards, notifications sql/ # миграции Flyway (V__*.sql, R__*.sql) images/ # статичные картинки (приветствие) +deploy/ # материалы деплоя (Caddyfile, инструкции) +tests/ # тесты +Dockerfile # образ бота +docker-compose.yml / docker-compose.prod.yml +.gitea/ # CI (Gitea Actions) ``` ## Требования @@ -37,6 +42,25 @@ APP_ENV=prod python -m bot # prod ## База данных Схема управляется миграциями **Flyway** в `sql/` (`V__*.sql` — versioned, `R__*.sql` — repeatable). +При Docker-развёртывании накат выполняется автоматически сервисом `migrate` перед ботом. + +## Docker + +Локальный запуск (postgres + migrate + bot): + +```bash +cp .env.example .env # заполнить +docker compose up --build +``` + +Прод/test-контуры — один `docker-compose.prod.yml`, разделяются Compose-проектом и env-файлом: + +```bash +docker compose -p prod --env-file env/prod.env -f docker-compose.prod.yml up -d +docker compose -p test --env-file env/test.env -f docker-compose.prod.yml up -d +``` + +Registry, перенос данных, TLS и firewall — см. [`deploy/README.md`](deploy/README.md). ## Разработка @@ -49,7 +73,7 @@ pytest ## Версионирование -- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.1`. +- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.2`. - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. diff --git a/bot/__main__.py b/bot/__main__.py index 72f5bf6..136de45 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -6,6 +6,7 @@ import asyncio import logging from bot.bot_factory import create_bot, create_dispatcher +from bot.services.heartbeat import run_heartbeat from bot.services.notifications import run_notifications from common import __version__ from common.config import get_settings @@ -26,12 +27,14 @@ async def main() -> None: dp = create_dispatcher() notifications_task = asyncio.create_task(run_notifications(bot)) + heartbeat_task = asyncio.create_task(run_heartbeat()) try: await bot.delete_webhook(drop_pending_updates=True) await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()) finally: notifications_task.cancel() - await asyncio.gather(notifications_task, return_exceptions=True) + heartbeat_task.cancel() + await asyncio.gather(notifications_task, heartbeat_task, return_exceptions=True) await close_pool() await bot.session.close() diff --git a/bot/handlers/economy.py b/bot/handlers/economy.py index 1c1d394..41ee67e 100644 --- a/bot/handlers/economy.py +++ b/bot/handlers/economy.py @@ -9,7 +9,8 @@ from aiogram import Router from aiogram.filters import Command from aiogram.types import Message -from bot.services import access, economy as economy_service +from bot.services import access +from bot.services import economy as economy_service from common.config import get_settings from common.texts import economy, system from common.texts.render import render diff --git a/bot/handlers/shop.py b/bot/handlers/shop.py index 3de67de..ebdefcf 100644 --- a/bot/handlers/shop.py +++ b/bot/handlers/shop.py @@ -9,8 +9,10 @@ from aiogram.filters import Command from aiogram.types import CallbackQuery, Message from bot.keyboards import get_keyboard_donat, get_keyboard_main, get_keyboard_title -from bot.services import access, shop as shop_service -from common.texts import shop as shop_texts, system +from bot.services import access +from bot.services import shop as shop_service +from common.texts import shop as shop_texts +from common.texts import system from common.texts.render import render logger = logging.getLogger(__name__) diff --git a/bot/services/heartbeat.py b/bot/services/heartbeat.py new file mode 100644 index 0000000..b198488 --- /dev/null +++ b/bot/services/heartbeat.py @@ -0,0 +1,28 @@ +"""Heartbeat для Docker healthcheck. + +Бот периодически обновляет mtime файла; healthcheck считает контейнер живым, +если файл свежий. Если event loop завис — heartbeat перестаёт обновляться. +""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path + +from common.config import get_settings + +logger = logging.getLogger(__name__) + +HEARTBEAT_INTERVAL = 30 + + +async def run_heartbeat() -> None: + """Обновлять файл-heartbeat каждые ``HEARTBEAT_INTERVAL`` секунд.""" + path = Path(get_settings().heartbeat_path) + while True: + try: + path.touch() + except OSError: + logger.exception("Не удалось обновить heartbeat-файл %s", path) + await asyncio.sleep(HEARTBEAT_INTERVAL) diff --git a/common/config.py b/common/config.py index ccce0a0..6f3514b 100644 --- a/common/config.py +++ b/common/config.py @@ -45,6 +45,9 @@ class Settings(BaseSettings): timezone: str = "Europe/Moscow" + #: Файл-heartbeat для Docker healthcheck (обновляется ботом периодически). + heartbeat_path: str = "/tmp/dispelevilbot.heartbeat" + @field_validator("admin_ids", mode="before") @classmethod def _parse_admin_ids(cls, value: object) -> object: diff --git a/common/version.py b/common/version.py index ef2b931..ac6dc75 100644 --- a/common/version.py +++ b/common/version.py @@ -7,4 +7,4 @@ from __future__ import annotations -__version__ = "0.1" +__version__ = "0.2" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..b005880 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,78 @@ +# Прод/test-контур: образы тянутся из Gitea Registry. +# +# Контуры разделяются именем compose-проекта (-p) и файлом окружения (ENV_FILE): +# ENV_FILE=env/prod.env docker compose -p prod -f docker-compose.prod.yml up -d +# ENV_FILE=env/test.env docker compose -p test -f docker-compose.prod.yml up -d +# +# Имя проекта задаёт отдельные volume'ы (pgdata) — у prod и test свои БД. +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME:-dispel_evil} + POSTGRES_USER: ${DB_USER:-de_admin} + POSTGRES_PASSWORD: ${DB_PASSWORD:?задайте DB_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-de_admin} -d ${DB_NAME:-dispel_evil}"] + interval: 10s + timeout: 5s + retries: 10 + + migrate: + image: flyway/flyway:10 + command: ["migrate"] + environment: + FLYWAY_URL: jdbc:postgresql://postgres:5432/${DB_NAME:-dispel_evil} + FLYWAY_USER: ${DB_USER:-de_admin} + FLYWAY_PASSWORD: ${DB_PASSWORD:?задайте DB_PASSWORD} + FLYWAY_LOCATIONS: filesystem:/flyway/sql + FLYWAY_BASELINE_ON_MIGRATE: "true" + FLYWAY_CONNECT_RETRIES: "10" + volumes: + - ./sql:/flyway/sql:ro + depends_on: + postgres: + condition: service_healthy + restart: "no" + + bot: + image: ${REGISTRY:-git.mpakosrv.ru}/mpakobecx/dispelevilbot-bot:${IMAGE_TAG:-latest} + restart: unless-stopped + env_file: + - ${ENV_FILE:-env/prod.env} + environment: + DB_HOST: postgres + DB_PORT: "5432" + depends_on: + postgres: + condition: service_healthy + migrate: + condition: service_completed_successfully + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + # --- Reverse proxy и TLS (Блок 2.4) --- + # Включается, когда появится админка/Mini App (Блоки 4–5). + # Раскомментировать и положить deploy/Caddyfile: + # + # caddy: + # image: caddy:2 + # restart: unless-stopped + # ports: + # - "80:80" + # - "443:443" + # volumes: + # - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + # - caddy_data:/data + # - caddy_config:/config + +volumes: + pgdata: + # caddy_data: + # caddy_config: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ece4353 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +# Базовый compose для локального запуска и разработки. +# Секреты берутся из .env (см. .env.example). +# +# docker compose up --build +# +name: dispelevilbot + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME:-dispel_evil} + POSTGRES_USER: ${DB_USER:-de_admin} + POSTGRES_PASSWORD: ${DB_PASSWORD:?задайте DB_PASSWORD в .env} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-de_admin} -d ${DB_NAME:-dispel_evil}"] + interval: 10s + timeout: 5s + retries: 10 + # ports: + # - "5432:5432" # раскомментировать для доступа к БД с хоста + + # Накат миграций Flyway перед стартом бота (идемпотентно). + migrate: + image: flyway/flyway:10 + command: ["migrate"] + environment: + FLYWAY_URL: jdbc:postgresql://postgres:5432/${DB_NAME:-dispel_evil} + FLYWAY_USER: ${DB_USER:-de_admin} + FLYWAY_PASSWORD: ${DB_PASSWORD:?задайте DB_PASSWORD в .env} + FLYWAY_LOCATIONS: filesystem:/flyway/sql + FLYWAY_BASELINE_ON_MIGRATE: "true" + FLYWAY_CONNECT_RETRIES: "10" + volumes: + - ./sql:/flyway/sql:ro + depends_on: + postgres: + condition: service_healthy + restart: "no" + + bot: + build: . + image: dispelevilbot-bot:local + restart: unless-stopped + env_file: + - .env + environment: + # Внутри сети compose БД доступна по имени сервиса. + DB_HOST: postgres + DB_PORT: "5432" + depends_on: + postgres: + condition: service_healthy + migrate: + condition: service_completed_successfully + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +volumes: + pgdata: diff --git a/env/prod.env.example b/env/prod.env.example new file mode 100644 index 0000000..251aed1 --- /dev/null +++ b/env/prod.env.example @@ -0,0 +1,26 @@ +# Прод-контур. Скопируйте в env/prod.env и заполните реальными значениями. +# env/*.env в git не попадают. +# +# Используется и для интерполяции, и как env_file сервиса: +# docker compose -p prod --env-file env/prod.env -f docker-compose.prod.yml up -d +ENV_FILE=env/prod.env + +APP_ENV=prod + +BOT_TOKEN= + +# Postgres (для контейнера бота host = имя сервиса compose) +DB_HOST=postgres +DB_PORT=5432 +DB_NAME=dispel_evil +DB_USER=de_admin +DB_PASSWORD= +DB_POOL_MIN=1 +DB_POOL_MAX=10 +DB_STATEMENT_TIMEOUT_MS=15000 + +PROD_CHAT_ID= +TEST_CHAT_ID= +ALERT_CHAT_ID= +ADMIN_IDS= +TIMEZONE=Europe/Moscow diff --git a/env/test.env.example b/env/test.env.example new file mode 100644 index 0000000..9866008 --- /dev/null +++ b/env/test.env.example @@ -0,0 +1,24 @@ +# Тестовый контур. Скопируйте в env/test.env и заполните. +# Отдельные токен бота, чаты и БД от прода. +# +# docker compose -p test --env-file env/test.env -f docker-compose.prod.yml up -d +ENV_FILE=env/test.env + +APP_ENV=dev + +BOT_TOKEN= + +DB_HOST=postgres +DB_PORT=5432 +DB_NAME=dispel_evil_test +DB_USER=de_admin +DB_PASSWORD= +DB_POOL_MIN=1 +DB_POOL_MAX=10 +DB_STATEMENT_TIMEOUT_MS=15000 + +PROD_CHAT_ID= +TEST_CHAT_ID= +ALERT_CHAT_ID= +ADMIN_IDS= +TIMEZONE=Europe/Moscow diff --git a/pyproject.toml b/pyproject.toml index 7d8d683..f4a0acf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,3 +13,5 @@ target-version = ["py312"] [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] +# Чтобы pytest видел пакеты из корня репозитория (common, bot) при запуске из CI. +pythonpath = ["."] diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 0000000..d39721a --- /dev/null +++ b/tests/test_render.py @@ -0,0 +1,53 @@ +"""Тесты рендера шаблонов (без БД и сети).""" + +from __future__ import annotations + +import pytest + +from common.texts.render import ( + TemplateError, + format_tops, + format_user_list, + format_usernames, + render, + validate, +) + + +def test_render_substitutes_and_escapes(): + template = "Привет, {username}!" + result = render(template, {"username": "bad"}) + assert result == "Привет, <b>bad</b>!" + + +def test_render_empty_value(): + assert render("Ник: {nick}", {"nick": None}) == "Ник: " + + +def test_render_unknown_tag_raises(): + with pytest.raises(TemplateError): + render("Значение {unknown}", {}) + + +def test_render_keeps_literal_braces_untouched(): + # Одиночная фигурная скобка не считается тегом. + assert render("текст { не тег", {}) == "текст { не тег" + + +def test_validate_reports_unknown_tags(): + assert validate("Норм {username}, плохо {nope} {bad2}") == ["bad2", "nope"] + + +def test_format_usernames_escapes_and_joins(): + rows = [{"username": "a"}, {"username": None}, {"username": "b&c"}] + assert format_usernames(rows) == "@a @b&c" + + +def test_format_user_list_numbering(): + rows = [{"username": "u1", "nick": "Nick1"}, {"username": None, "nick": None}] + assert format_user_list(rows) == "1: @u1 - Nick1\n2: — - " + + +def test_format_tops(): + rows = [{"nick": "N1", "count": 3}, {"nick": None, "count": 1}] + assert format_tops(rows) == "1) N1 - 3\n2) — - 1" diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..74f39fa --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,10 @@ +"""Проверка версии проекта.""" + +from __future__ import annotations + +from common import __version__ + + +def test_version_is_set(): + assert __version__ + assert __version__ == "0.2"