Доработки контуров, тесты конфига и CI
CI / lint-test (push) Successful in 7s Details
CI / build-push (push) Successful in 12s Details

This commit is contained in:
MpakobecEx 2026-10-16 14:56:24 +05:00
parent 05169b9b39
commit aafb9c9e3b
22 changed files with 243 additions and 107 deletions

View File

@ -26,6 +26,8 @@ deploy
.gitea
tests
docs
scripts
Makefile
*.md
Dockerfile
docker-compose*.yml

View File

@ -6,6 +6,11 @@ on:
branches: [master]
workflow_dispatch:
# Отменяем предыдущий незавершённый прогон при новом пуше.
concurrency:
group: ci
cancel-in-progress: true
env:
REGISTRY: git.mpakosrv.ru
IMAGE_NAME: mpakobecex/dispelevilbot-bot
@ -20,12 +25,10 @@ jobs:
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 нашёл отличия в форматировании (пока не блокирует)"
run: black --check .
- name: Pytest
run: pytest -q
@ -43,7 +46,9 @@ jobs:
IMAGE="$REGISTRY/$IMAGE_NAME"
SHA="${GITHUB_SHA:-unknown}"
SHORT_SHA="$(echo "$SHA" | cut -c1-12)"
VERSION="$(sed -n 's/^__version__ = "\(.*\)"/\1/p' common/version.py)"
TAGS="$IMAGE:$SHORT_SHA $IMAGE:latest"
if [ -n "$VERSION" ]; then TAGS="$TAGS $IMAGE:$VERSION"; fi
ARGS=""
for t in $TAGS; do ARGS="$ARGS -t $t"; done
docker build $ARGS .

51
Makefile Normal file
View File

@ -0,0 +1,51 @@
# Управление контурами test/prod.
# Пример: make up E=test
#
# Используем .RECIPEPREFIX, чтобы не зависеть от табов (рецепты начинаются с ">").
E ?= test
FILE := env/$(E).env
COMPOSE := docker compose -p $(E) --env-file $(FILE) -f docker-compose.prod.yml
.DEFAULT_GOAL := help
.RECIPEPREFIX := >
.PHONY: help up pull down restart logs ps images check psql
help:
> @echo "Контур задаётся через E=test|prod (по умолчанию test):"
> @echo " make up E=test поднять контур"
> @echo " make pull E=test подтянуть образ из registry"
> @echo " make down E=test остановить (данные сохраняются)"
> @echo " make restart E=test пересоздать контейнеры"
> @echo " make logs E=test стримить логи"
> @echo " make ps E=test статус сервисов"
> @echo " make images E=test образы контура"
> @echo " make check E=test проверка контура (скрипт)"
> @echo " make psql E=test psql в БД контура"
up:
> $(COMPOSE) up -d
pull:
> $(COMPOSE) pull
down:
> $(COMPOSE) down
restart:
> $(COMPOSE) up -d --force-recreate
logs:
> $(COMPOSE) logs -f
ps:
> $(COMPOSE) ps
images:
> $(COMPOSE) images
check:
> ./scripts/check_contour.sh $(E)
psql:
> $(COMPOSE) exec postgres sh -lc 'psql -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"'

View File

@ -73,7 +73,7 @@ pytest
## Версионирование
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.2`.
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.2.5`.
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.

View File

@ -32,9 +32,7 @@ async def set_nick_handler(message: Message) -> None:
nick = parts[1]
try:
await users_service.set_nick(message.from_user.id, nick)
await message.answer(
render(system.MSG_NICK_SET, {"username": message.from_user.username, "nick": nick})
)
await message.answer(render(system.MSG_NICK_SET, {"username": message.from_user.username, "nick": nick}))
await message.delete()
except Exception:
logger.exception("Не удалось установить ник")

View File

@ -54,9 +54,7 @@ SCHEDULES: tuple[ScheduledMessage, ...] = (
async def _send_broadcast(bot: Bot, template: str, subscribers: list[dict]) -> None:
chunks = [
subscribers[i : i + BATCH_SIZE] for i in range(0, len(subscribers), BATCH_SIZE)
] or [[]]
chunks = [subscribers[i : i + BATCH_SIZE] for i in range(0, len(subscribers), BATCH_SIZE)] or [[]]
for chunk in chunks:
text = render(template, {"usernames": format_usernames(chunk)})
await bot.send_message(chat_id=get_settings().chat_id, text=text)

View File

@ -8,18 +8,12 @@ from common.repositories import shop_repo
async def list_titles() -> list[Title]:
rows = await shop_repo.get_titles_list()
return [
Title(id=int(row["i"]), title=row["n"] or "", price=int(row["p"] or 0))
for row in rows
]
return [Title(id=int(row["i"]), title=row["n"] or "", price=int(row["p"] or 0)) for row in rows]
async def list_donats() -> list[Donat]:
rows = await shop_repo.get_donats_list()
return [
Donat(id=int(row["i"]), title=row["n"] or "", price=int(row["p"] or 0))
for row in rows
]
return [Donat(id=int(row["i"]), title=row["n"] or "", price=int(row["p"] or 0)) for row in rows]
async def buy_title(id_user: int, id_title: int) -> bool:

View File

@ -9,6 +9,5 @@ from common.repositories import stats_repo
async def top_users() -> list[TopEntry]:
rows = await stats_repo.get_top_list()
return [
TopEntry(username=row.get("username"), nick=row.get("nick"), count=int(row.get("count") or 0))
for row in rows
TopEntry(username=row.get("username"), nick=row.get("nick"), count=int(row.get("count") or 0)) for row in rows
]

View File

@ -87,10 +87,7 @@ class Settings(BaseSettings):
"""Строка подключения psycopg для async-пула (креды URL-экранируются)."""
user = quote_plus(self.db_user)
password = quote_plus(self.db_password)
return (
f"postgresql://{user}:{password}"
f"@{self.db_host}:{self.db_port}/{self.db_name}"
)
return f"postgresql://{user}:{password}" f"@{self.db_host}:{self.db_port}/{self.db_name}"
@lru_cache

View File

@ -9,15 +9,13 @@ from common.db.pool import get_pool
async def money_action(id_user: int, amount: int, id_action: int = 1) -> None:
"""Начислить/списать деньги (запись в журнал money)."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("CALL bot.money_action(%s, %s, %s)", (id_user, amount, id_action))
async def get_money(id_user: int) -> int:
"""Текущий баланс пользователя (SUM по журналу)."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT bot.get_money(%s) AS money", (id_user,))
row = await cur.fetchone()
return int(row["money"] or 0) if row else 0
@ -25,8 +23,7 @@ async def get_money(id_user: int) -> int:
async def get_eggs(id_user: int) -> int:
"""Количество яичек пользователя."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT bot.get_eggs(%s) AS eggs", (id_user,))
row = await cur.fetchone()
return int(row["eggs"] or 0) if row else 0
@ -34,15 +31,13 @@ async def get_eggs(id_user: int) -> int:
async def purification(id_user: int) -> dict[str, Any] | None:
"""Выполнить ежедневное очищение."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT * FROM bot.purification(%s)", (id_user,))
return await cur.fetchone()
async def add_eggs(id_user: int) -> dict[str, Any] | None:
"""Получить яички (раз в сутки)."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT * FROM bot.add_eggs(%s)", (id_user,))
return await cur.fetchone()

View File

@ -9,24 +9,21 @@ from common.db.pool import get_pool
async def get_titles_list() -> list[dict[str, Any]]:
"""Доступные к покупке звания (id_title, title, price)."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT * FROM bot.get_titles_list()")
return await cur.fetchall()
async def get_donats_list() -> list[dict[str, Any]]:
"""Доступные к покупке донаты (id_donat, title, price)."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT * FROM bot.get_donats_list()")
return await cur.fetchall()
async def buy_title(id_user: int, id_title: int) -> int:
"""Купить звание. Возвращает 1 при успехе, -1 если не хватает денег."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT bot.buy_title(%s, %s) AS res", (id_user, id_title))
row = await cur.fetchone()
return int(row["res"]) if row else -1
@ -34,8 +31,7 @@ async def buy_title(id_user: int, id_title: int) -> int:
async def buy_donat(id_user: int, id_donat: int) -> str:
"""Купить донат. Возвращает промокод или '-1' при неуспехе."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT bot.buy_donat(%s, %s) AS res", (id_user, id_donat))
row = await cur.fetchone()
return str(row["res"]) if row else "-1"

View File

@ -9,7 +9,6 @@ from common.db.pool import get_pool
async def get_top_list() -> list[dict[str, Any]]:
"""Топ-10 пользователей по количеству сообщений (username, nick, count)."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT u AS username, n AS nick, c AS count FROM bot.get_top_list()")
return await cur.fetchall()

View File

@ -7,13 +7,11 @@ from common.db.pool import get_pool
async def subscribe(id_user: int) -> None:
"""Подписать пользователя на рассылки."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("CALL bot.subscribe(%s)", (id_user,))
async def unsubscribe(id_user: int) -> None:
"""Отписать пользователя от рассылок."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("CALL bot.unsubscribe(%s)", (id_user,))

View File

@ -9,8 +9,7 @@ from common.db.pool import get_pool
async def add_user(id_user: int, username: str, first_name: str, last_name: str) -> None:
"""Создать/обновить пользователя и добавить запись о сообщении."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute(
"CALL bot.add_user(%s, %s, %s, %s)",
(id_user, username, first_name, last_name),
@ -19,37 +18,32 @@ async def add_user(id_user: int, username: str, first_name: str, last_name: str)
async def add_nick(id_user: int, nick: str) -> None:
"""Установить ник пользователя."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("CALL bot.add_nick(%s, %s)", (id_user, nick))
async def get_nick_list() -> list[dict[str, Any]]:
"""Список всех игроков (username, nick), отсортированный по нику."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT username, nick FROM bot.get_nick_list()")
return await cur.fetchall()
async def get_nick_list_subscribe() -> list[dict[str, Any]]:
"""Список подписанных на рассылки (username, nick)."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT username, nick FROM bot.get_nick_list_subscribe()")
return await cur.fetchall()
async def get_info_by_id(id_user: int) -> dict[str, Any] | None:
"""Сводная информация о пользователе."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT * FROM bot.get_info_by_id(%s)", (id_user,))
return await cur.fetchone()
async def del_user_by_id(id_user: int) -> None:
"""Удалить пользователя (при выходе из чата)."""
async with get_pool().connection() as conn:
async with conn.cursor() as cur:
async with get_pool().connection() as conn, conn.cursor() as cur:
await cur.execute("CALL bot.del_user_by_id(%s)", (id_user,))

View File

@ -18,9 +18,7 @@ from typing import Any
_TAG_RE = re.compile(r"\{([a-z_][a-z0-9_]*)\}")
#: Теги, чьи значения подставляются «как есть» (формируются helpers-функциями ниже).
RAW_TAGS: frozenset[str] = frozenset(
{"usernames", "users_list", "users_list_count", "tops"}
)
RAW_TAGS: frozenset[str] = frozenset({"usernames", "users_list", "users_list_count", "tops"})
#: Полный реестр поддерживаемых тегов: имя -> короткое описание.
TAG_REGISTRY: dict[str, str] = {

View File

@ -7,4 +7,4 @@
from __future__ import annotations
__version__ = "0.2"
__version__ = "0.2.5"

View File

@ -7,9 +7,9 @@ ENV_FILE=env/prod.env
APP_ENV=prod
# Образ в реестре
# Образ в реестре (latest = последняя сборка из master; можно закрепить <sha> или версию, напр. 0.2.5)
REGISTRY=git.mpakosrv.ru
IMAGE_TAG=v0.2
IMAGE_TAG=latest
BOT_TOKEN=

View File

@ -6,9 +6,9 @@ ENV_FILE=env/test.env
APP_ENV=dev
# Образ в реестре
# Образ в реестре (latest = последняя сборка из master; можно закрепить <sha> или версию, напр. 0.2.5)
REGISTRY=git.mpakosrv.ru
IMAGE_TAG=v0.2
IMAGE_TAG=latest
BOT_TOKEN=

View File

@ -3,7 +3,7 @@ line-length = 120
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "W"]
select = ["E", "F", "I", "UP", "B", "W", "C4", "SIM", "PIE"]
ignore = ["E501"]
[tool.black]

52
scripts/check_contour.sh Normal file
View File

@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Проверка контура: сервисы запущены, образ бота — ожидаемый, миграции применены.
# Использование: ./scripts/check_contour.sh [test|prod]
set -euo pipefail
E="${1:-test}"
FILE="env/${E}.env"
if [ ! -f "$FILE" ]; then
echo "Нет файла окружения: $FILE" >&2
exit 1
fi
COMPOSE="docker compose -p ${E} --env-file ${FILE} -f docker-compose.prod.yml"
echo "== контур: ${E} =="
$COMPOSE ps
echo
echo "== образы =="
$COMPOSE images
echo
echo "== проверки =="
running="$($COMPOSE ps --services --status running || true)"
fail=0
for svc in postgres bot; do
if echo "$running" | grep -qx "$svc"; then
echo "OK ${svc}: running"
else
echo "FAIL ${svc}: не запущен" >&2
fail=1
fi
done
# миграции: сервис migrate должен завершиться успешно (Exited (0))
if $COMPOSE ps -a --format '{{.Service}} {{.Status}}' | grep -q '^migrate Exited (0)'; then
echo "OK migrate: завершён успешно"
else
echo "WARN migrate: нет подтверждённого успешного завершения (см. 'logs migrate')" >&2
fi
# БД отвечает
if $COMPOSE exec -T postgres sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; then
echo "OK postgres: принимает соединения"
else
echo "FAIL postgres: не отвечает" >&2
fail=1
fi
exit $fail

60
tests/test_config.py Normal file
View File

@ -0,0 +1,60 @@
"""Тесты разбора переменных окружения (без БД и сети).
Проверяем граничные случаи, которые уже ломали запуск:
- ``ADMIN_IDS`` пустой / через запятую / как JSON-массив;
- пустые целочисленные поля ``CHAT_ID``/``ALERT_CHAT_ID``;
- обязательный ``BOT_TOKEN``.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from common.config import Settings
#: Переменные, которые чистим перед каждым тестом, чтобы окружение не влияло.
_ENV_KEYS = ("BOT_TOKEN", "APP_ENV", "ADMIN_IDS", "CHAT_ID", "ALERT_CHAT_ID")
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch: pytest.MonkeyPatch) -> None:
for key in _ENV_KEYS:
monkeypatch.delenv(key, raising=False)
def _settings(monkeypatch: pytest.MonkeyPatch, **env: str) -> Settings:
for key, value in env.items():
monkeypatch.setenv(key, value)
return Settings(_env_file=None)
def test_admin_ids_empty(monkeypatch: pytest.MonkeyPatch) -> None:
assert _settings(monkeypatch, BOT_TOKEN="1:a", ADMIN_IDS="").admin_ids == []
def test_admin_ids_not_set(monkeypatch: pytest.MonkeyPatch) -> None:
assert _settings(monkeypatch, BOT_TOKEN="1:a").admin_ids == []
def test_admin_ids_comma(monkeypatch: pytest.MonkeyPatch) -> None:
assert _settings(monkeypatch, BOT_TOKEN="1:a", ADMIN_IDS="1,2, 3").admin_ids == [1, 2, 3]
def test_admin_ids_json_like(monkeypatch: pytest.MonkeyPatch) -> None:
assert _settings(monkeypatch, BOT_TOKEN="1:a", ADMIN_IDS="[7, 8]").admin_ids == [7, 8]
def test_chat_ids_empty_are_zero(monkeypatch: pytest.MonkeyPatch) -> None:
settings = _settings(monkeypatch, BOT_TOKEN="1:a", CHAT_ID="", ALERT_CHAT_ID="")
assert settings.chat_id == 0
assert settings.alert_chat_id == 0
def test_chat_id_value(monkeypatch: pytest.MonkeyPatch) -> None:
assert _settings(monkeypatch, BOT_TOKEN="1:a", CHAT_ID="-1001234567890").chat_id == -1001234567890
def test_bot_token_required(monkeypatch: pytest.MonkeyPatch) -> None:
with pytest.raises(ValidationError):
Settings(_env_file=None)

View File

@ -7,4 +7,4 @@ from common import __version__
def test_version_is_set():
assert __version__
assert __version__ == "0.2"
assert __version__ == "0.2.5"