Первоначальная версия
This commit is contained in:
commit
46cc5bbb5e
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Скопируйте в .env и заполните реальными значениями. .env в git не попадает.
|
||||||
|
|
||||||
|
# Режим окружения: dev | prod
|
||||||
|
APP_ENV=dev
|
||||||
|
|
||||||
|
# Токен бота (получается у @BotFather)
|
||||||
|
BOT_TOKEN=
|
||||||
|
|
||||||
|
# Postgres
|
||||||
|
DB_HOST=localhost
|
||||||
|
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) и тестовый чат
|
||||||
|
PROD_CHAT_ID=
|
||||||
|
TEST_CHAT_ID=
|
||||||
|
# Личный чат для алертов об ошибках
|
||||||
|
ALERT_CHAT_ID=
|
||||||
|
|
||||||
|
# Telegram ID администраторов (через запятую)
|
||||||
|
ADMIN_IDS=
|
||||||
|
|
||||||
|
# Часовой пояс для расписаний
|
||||||
|
TIMEZONE=Europe/Moscow
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Normalize line endings: stored as LF in the repo, checked out per OS.
|
||||||
|
* text=auto eol=lf
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
tags: ["v*"]
|
||||||
|
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
|
||||||
|
# Линт/формат пока не блокируют пайплайн (см. TODO). После первого прогона
|
||||||
|
# выполните локально `ruff check --fix .` и `black .`, затем уберите
|
||||||
|
# continue-on-error, чтобы стиль стал обязательным.
|
||||||
|
- name: Ruff
|
||||||
|
continue-on-error: true
|
||||||
|
run: ruff check .
|
||||||
|
- name: Black
|
||||||
|
continue-on-error: true
|
||||||
|
run: black --check .
|
||||||
|
- 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"
|
||||||
|
SHORT_SHA="${GITHUB_SHA:0:12}"
|
||||||
|
TAGS="$IMAGE:$SHORT_SHA $IMAGE:latest"
|
||||||
|
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||||
|
TAGS="$TAGS $IMAGE:${GITHUB_REF_NAME}"
|
||||||
|
fi
|
||||||
|
ARGS=""
|
||||||
|
for t in $TAGS; do ARGS="$ARGS -t $t"; done
|
||||||
|
docker build $ARGS .
|
||||||
|
for t in $TAGS; do docker push "$t"; done
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# Virtualenv
|
||||||
|
.venv/
|
||||||
|
.venv11/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Secrets / environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
env/*.env
|
||||||
|
!env/*.env.example
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Logs / runtime
|
||||||
|
*.log
|
||||||
|
*.pid
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Локальные материалы (в репозиторий не попадают)
|
||||||
|
AGENTS.md
|
||||||
|
checklist.md
|
||||||
|
sql/README.md
|
||||||
|
deploy/
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
# DispelEvilBot
|
||||||
|
|
||||||
|
Telegram-бот клана Dispel Evil (aiogram 3 + Postgres). Монорепозиторий:
|
||||||
|
общий пакет `common` используется сервисом бота `bot` (и, в будущем, админкой `admin`).
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
common/ # общий пакет: config, logging, db, repositories, models, texts
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Python 3.12
|
||||||
|
- PostgreSQL 14+
|
||||||
|
|
||||||
|
## Настройка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# заполнить BOT_TOKEN, DB_*, чаты и ADMIN_IDS
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_ENV=dev python -m bot # dev
|
||||||
|
APP_ENV=prod python -m bot # prod
|
||||||
|
```
|
||||||
|
|
||||||
|
или через `./start.sh` (режим берётся из `APP_ENV`).
|
||||||
|
|
||||||
|
## База данных
|
||||||
|
|
||||||
|
Схема управляется миграциями **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).
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
ruff check .
|
||||||
|
black --check .
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Версионирование
|
||||||
|
|
||||||
|
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.1`.
|
||||||
|
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
||||||
|
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||||
|
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
||||||
|
|
||||||
|
## Процесс работы с git
|
||||||
|
|
||||||
|
Проект ведётся в режиме **trunk-based** (один разработчик): основная работа — прямо в `master`.
|
||||||
|
|
||||||
|
- `master` — интеграционная ветка, всегда в рабочем состоянии.
|
||||||
|
- Релизы — аннотированные теги `vX.Y` (см. «Версионирование»).
|
||||||
|
- Для крупных/рискованных изменений — короткая ветка `feature/...` и merge обратно в `master`.
|
||||||
|
- Push в `master` → CI (Gitea Actions) собирает образ; **авто-выката в прод нет**.
|
||||||
|
- Выкатка: сначала контур `test`, затем `prod` по подтверждению (см. Блок 2.5 плана).
|
||||||
|
- Переход на «ветки + PR» и защиту ветки — когда появится второй разработчик.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# обычная работа
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat: ..."
|
||||||
|
git push origin master
|
||||||
|
|
||||||
|
# выпуск релиза
|
||||||
|
# 1) поднять __version__ в common/version.py
|
||||||
|
git commit -am "chore: bump version to X.Y"
|
||||||
|
git tag -a vX.Y -m "Release vX.Y"
|
||||||
|
git push origin master --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
## Команды бота
|
||||||
|
|
||||||
|
`/ник`, `/список`, `/кто`, `/инфо`, `/помощь`, `/очищение`, `/яички`,
|
||||||
|
`/магазин`, `/рейтинг`, `/отписка`, `/подписка`.
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
См. `.env.example`.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Сервис Telegram-бота."""
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""Точка входа бота: python -m bot"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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
|
||||||
|
from common.db.pool import close_pool, init_pool
|
||||||
|
from common.logging_config import setup_logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
setup_logging(logging.INFO)
|
||||||
|
settings = get_settings()
|
||||||
|
logger.info("Запуск DispelEvilBot v%s (APP_ENV=%s)", __version__, settings.app_env)
|
||||||
|
|
||||||
|
await init_pool()
|
||||||
|
|
||||||
|
bot = create_bot()
|
||||||
|
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()
|
||||||
|
heartbeat_task.cancel()
|
||||||
|
await asyncio.gather(notifications_task, heartbeat_task, return_exceptions=True)
|
||||||
|
await close_pool()
|
||||||
|
await bot.session.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
logger.info("Бот остановлен")
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""Создание объектов Bot и Dispatcher, регистрация роутеров."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
|
|
||||||
|
from common.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def create_bot() -> Bot:
|
||||||
|
"""Создать объект бота с HTML-разметкой по умолчанию."""
|
||||||
|
settings = get_settings()
|
||||||
|
return Bot(
|
||||||
|
token=settings.bot_token,
|
||||||
|
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_dispatcher() -> Dispatcher:
|
||||||
|
"""Собрать Dispatcher и подключить все роутеры.
|
||||||
|
|
||||||
|
Порядок важен: сначала события участников и команды, затем магазин,
|
||||||
|
и в конце — экономика (в ней есть catch-all обработчик сообщений).
|
||||||
|
"""
|
||||||
|
from bot.handlers import commands, economy, member_events, shop
|
||||||
|
from bot.handlers.errors import register_errors
|
||||||
|
|
||||||
|
dp = Dispatcher(storage=MemoryStorage())
|
||||||
|
dp.include_router(member_events.router)
|
||||||
|
dp.include_router(commands.router)
|
||||||
|
dp.include_router(shop.router)
|
||||||
|
dp.include_router(economy.router)
|
||||||
|
register_errors(dp)
|
||||||
|
return dp
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Роутеры (хендлеры) бота."""
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
"""Основные команды бота."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiogram import Router
|
||||||
|
from aiogram.enums import ChatMemberStatus
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
from bot.services import access, stats, subscriptions
|
||||||
|
from bot.services import users as users_service
|
||||||
|
from common.config import get_settings
|
||||||
|
from common.texts import economy, info, system
|
||||||
|
from common.texts.render import format_tops, format_user_list, render
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = Router(name="commands")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("ник", ignore_case=True))
|
||||||
|
async def set_nick_handler(message: Message) -> None:
|
||||||
|
if not await access.check_user(message):
|
||||||
|
return
|
||||||
|
|
||||||
|
parts = (message.text or "").split()
|
||||||
|
if len(parts) < 2:
|
||||||
|
await message.reply(system.MSG_NICK_ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
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.delete()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Не удалось установить ник")
|
||||||
|
await message.reply(system.MSG_NICK_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("список", ignore_case=True))
|
||||||
|
async def list_nicks_handler(message: Message) -> None:
|
||||||
|
if not await access.check_user(message):
|
||||||
|
return
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
if message.chat.type == "private":
|
||||||
|
allowed = True
|
||||||
|
else:
|
||||||
|
member = await message.bot.get_chat_member(settings.chat_id, message.from_user.id)
|
||||||
|
allowed = member.status in {ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.CREATOR}
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
|
await message.answer(system.MSG_LIST_PRIVATE_ONLY)
|
||||||
|
return
|
||||||
|
|
||||||
|
await users_service.register_from_message(message)
|
||||||
|
nicks = await users_service.list_nicks()
|
||||||
|
context = {
|
||||||
|
"users_list": format_user_list([{"username": n.username, "nick": n.nick} for n in nicks]),
|
||||||
|
"users_list_count": len(nicks),
|
||||||
|
}
|
||||||
|
await message.answer(render(economy.MSG_NICK_LIST, context))
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("кто", ignore_case=True))
|
||||||
|
async def get_nick_handler(message: Message) -> None:
|
||||||
|
if not await access.check_user(message):
|
||||||
|
return
|
||||||
|
if message.reply_to_message is None or message.reply_to_message.from_user is None:
|
||||||
|
await message.reply(system.MSG_REPLY_REQUIRED)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_info = await users_service.get_user_info(message.reply_to_message.from_user.id)
|
||||||
|
if user_info is None or not user_info.nick:
|
||||||
|
await message.reply(system.MSG_NICK_EMPTY)
|
||||||
|
return
|
||||||
|
await message.reply(user_info.nick)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("помощь", ignore_case=True))
|
||||||
|
async def help_handler(message: Message) -> None:
|
||||||
|
if not await access.check_user(message):
|
||||||
|
return
|
||||||
|
await message.answer(info.MSG_HELP)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("инфо", ignore_case=True))
|
||||||
|
async def info_handler(message: Message) -> None:
|
||||||
|
if not await access.check_user(message):
|
||||||
|
return
|
||||||
|
|
||||||
|
user_info = await users_service.get_user_info(message.from_user.id)
|
||||||
|
if user_info is None:
|
||||||
|
await message.reply(system.MSG_NICK_ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"username": user_info.username,
|
||||||
|
"nick": user_info.nick_text,
|
||||||
|
"title": user_info.title_text,
|
||||||
|
"lvl": user_info.lvl,
|
||||||
|
"exp": user_info.exp,
|
||||||
|
"exp_max": user_info.exp_max,
|
||||||
|
"money": user_info.money,
|
||||||
|
"dt": user_info.dt,
|
||||||
|
"msg_count": user_info.msg_count,
|
||||||
|
"eggs": user_info.eggs,
|
||||||
|
}
|
||||||
|
await message.answer(render(info.MSG_INFO, context))
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("рейтинг", ignore_case=True))
|
||||||
|
async def top_handler(message: Message) -> None:
|
||||||
|
if not await access.check_user(message):
|
||||||
|
return
|
||||||
|
tops = await stats.top_users()
|
||||||
|
context = {"tops": format_tops([{"nick": t.nick, "count": t.count} for t in tops])}
|
||||||
|
await message.answer(render(economy.MSG_TOP, context))
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("отписка", ignore_case=True))
|
||||||
|
async def unsubscribe_handler(message: Message) -> None:
|
||||||
|
if not await access.check_user(message):
|
||||||
|
return
|
||||||
|
await subscriptions.unsubscribe(message.from_user.id)
|
||||||
|
await message.reply(system.MSG_UNSUBSCRIBED)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("подписка", ignore_case=True))
|
||||||
|
async def subscribe_handler(message: Message) -> None:
|
||||||
|
if not await access.check_user(message):
|
||||||
|
return
|
||||||
|
await subscriptions.subscribe(message.from_user.id)
|
||||||
|
await message.reply(system.MSG_SUBSCRIBED)
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""Команды экономики: очищение, яички и реакция на сообщения."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from aiogram import Router
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
from bot.services import access, economy as economy_service
|
||||||
|
from common.config import get_settings
|
||||||
|
from common.texts import economy, system
|
||||||
|
from common.texts.render import render
|
||||||
|
|
||||||
|
router = Router(name="economy")
|
||||||
|
|
||||||
|
#: Окно сезонного события «яички».
|
||||||
|
EGG_EVENT_START = date(2025, 4, 1)
|
||||||
|
EGG_EVENT_END = date(2025, 4, 20)
|
||||||
|
|
||||||
|
#: Шанс выпадения монетки на сообщение.
|
||||||
|
COIN_CHANCE = 27 # из 100
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("очищение", ignore_case=True))
|
||||||
|
async def purification_handler(message: Message) -> None:
|
||||||
|
if not await access.check_user(message):
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await economy_service.purify(message.from_user.id)
|
||||||
|
if not result.success:
|
||||||
|
context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
|
||||||
|
await message.answer(render(economy.MSG_NO_PURIFICATION, context))
|
||||||
|
else:
|
||||||
|
context = {"money_add": result.money_add, "exp_add": result.exp_add}
|
||||||
|
await message.answer(render(economy.MSG_PURIFICATION, context))
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("яички", ignore_case=True))
|
||||||
|
async def eggs_handler(message: Message) -> None:
|
||||||
|
today = datetime.now(get_settings().tzinfo).date()
|
||||||
|
if not (EGG_EVENT_START <= today <= EGG_EVENT_END):
|
||||||
|
await message.answer(system.MSG_EGGS_TOO_EARLY)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not await access.check_user(message):
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await economy_service.collect_eggs(message.from_user.id)
|
||||||
|
if not result.success:
|
||||||
|
context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
|
||||||
|
await message.answer(render(economy.MSG_NO_EGGS, context))
|
||||||
|
else:
|
||||||
|
await message.answer(render(economy.MSG_EGGS, {"eggs": result.eggs}))
|
||||||
|
|
||||||
|
|
||||||
|
@router.message()
|
||||||
|
async def message_handler(message: Message) -> None:
|
||||||
|
"""Случайная монетка за активность в основном чате."""
|
||||||
|
if not await access.check_user(message):
|
||||||
|
return
|
||||||
|
|
||||||
|
if message.chat.id != get_settings().chat_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
if random.randrange(1, 100) == COIN_CHANCE:
|
||||||
|
await message.reply(system.MSG_COIN)
|
||||||
|
await economy_service.add_coins(message.from_user.id, 1)
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""Глобальный обработчик ошибок."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiogram import Dispatcher
|
||||||
|
from aiogram.types import ErrorEvent
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def register_errors(dp: Dispatcher) -> None:
|
||||||
|
"""Зарегистрировать обработчик, чтобы одна ошибка не роняла поллинг."""
|
||||||
|
|
||||||
|
@dp.errors()
|
||||||
|
async def on_error(event: ErrorEvent) -> bool:
|
||||||
|
exc = event.exception
|
||||||
|
logger.error(
|
||||||
|
"Необработанная ошибка при обработке апдейта",
|
||||||
|
exc_info=(type(exc), exc, exc.__traceback__),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""События участников: добавление бота, вход и выход пользователей."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiogram import Router
|
||||||
|
from aiogram.filters import JOIN_TRANSITION, LEAVE_TRANSITION, ChatMemberUpdatedFilter
|
||||||
|
from aiogram.types import ChatMemberUpdated, FSInputFile
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from bot.services import users as users_service
|
||||||
|
from common.config import get_settings
|
||||||
|
from common.texts import system, welcome
|
||||||
|
from common.texts.render import render
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = Router(name="member_events")
|
||||||
|
|
||||||
|
|
||||||
|
@router.my_chat_member(ChatMemberUpdatedFilter(member_status_changed=JOIN_TRANSITION))
|
||||||
|
async def bot_added_handler(event: ChatMemberUpdated) -> None:
|
||||||
|
"""Бот добавлен в чат."""
|
||||||
|
settings = get_settings()
|
||||||
|
if event.chat.id not in {settings.prod_chat_id, settings.test_chat_id}:
|
||||||
|
await event.bot.send_message(chat_id=event.chat.id, text=system.MSG_BOT_FOREIGN_CHAT)
|
||||||
|
await event.bot.leave_chat(event.chat.id)
|
||||||
|
else:
|
||||||
|
await event.bot.send_message(chat_id=event.chat.id, text=system.MSG_BOT_ADDED)
|
||||||
|
|
||||||
|
|
||||||
|
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=JOIN_TRANSITION))
|
||||||
|
async def welcome_handler(event: ChatMemberUpdated) -> None:
|
||||||
|
"""Пользователь вошёл в чат — приветствие."""
|
||||||
|
user = event.new_chat_member.user
|
||||||
|
if user.is_bot:
|
||||||
|
return
|
||||||
|
|
||||||
|
await users_service.register_from_event(event)
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(text=welcome.MSG_WELCOME_BUTTON_CAPTION, url=welcome.MSG_WELCOME_BUTTON_URL)
|
||||||
|
|
||||||
|
await event.bot.send_photo(
|
||||||
|
chat_id=event.chat.id,
|
||||||
|
photo=FSInputFile("images/welcome_image.jpg"),
|
||||||
|
caption=render(welcome.MSG_WELCOME_TEXT, {"username": user.username}),
|
||||||
|
)
|
||||||
|
await event.bot.send_message(
|
||||||
|
chat_id=event.chat.id,
|
||||||
|
text=welcome.MSG_WELCOME_TEXT2,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
await event.bot.send_message(chat_id=event.chat.id, text=welcome.MSG_WELCOME_TEXT3)
|
||||||
|
|
||||||
|
|
||||||
|
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION))
|
||||||
|
async def goodbye_handler(event: ChatMemberUpdated) -> None:
|
||||||
|
"""Пользователь вышел из чата — удалить запись."""
|
||||||
|
settings = get_settings()
|
||||||
|
user = event.from_user
|
||||||
|
if user is None or user.is_bot:
|
||||||
|
return
|
||||||
|
if event.chat.id == settings.test_chat_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
await users_service.delete_user(user.id)
|
||||||
|
logger.info("Удалён пользователь %s после выхода из чата", user.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await event.bot.send_message(
|
||||||
|
chat_id=settings.test_chat_id,
|
||||||
|
text=f"[goodbye_handler]\nУдалил {user.username or user.id}!",
|
||||||
|
)
|
||||||
|
if settings.alert_chat_id:
|
||||||
|
await event.bot.send_message(
|
||||||
|
chat_id=settings.alert_chat_id,
|
||||||
|
text=f"Пользователь {user.username or user.id} покинул чат {event.chat.title}",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Не удалось отправить уведомление о выходе пользователя")
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""Магазин: команда и обработка callback-кнопок."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiogram import Router
|
||||||
|
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 common.texts.render import render
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = Router(name="shop")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("магазин", ignore_case=True))
|
||||||
|
async def shop_handler(message: Message) -> None:
|
||||||
|
if not await access.check_user(message):
|
||||||
|
return
|
||||||
|
if message.chat.type != "private":
|
||||||
|
await message.answer(system.MSG_SHOP_PRIVATE_ONLY)
|
||||||
|
return
|
||||||
|
await message.answer(shop_texts.MSG_SHOP_MAIN, reply_markup=get_keyboard_main())
|
||||||
|
|
||||||
|
|
||||||
|
async def _render_chapter(chapter: str) -> tuple[str, object] | None:
|
||||||
|
if chapter == "main":
|
||||||
|
return shop_texts.MSG_SHOP_MAIN, get_keyboard_main()
|
||||||
|
if chapter == "title":
|
||||||
|
return shop_texts.MSG_SHOP_TITLE, await get_keyboard_title()
|
||||||
|
if chapter == "donat":
|
||||||
|
return shop_texts.MSG_SHOP_DONAT, await get_keyboard_donat()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query()
|
||||||
|
async def callbacks_handler(callback: CallbackQuery) -> None:
|
||||||
|
answer_text = ""
|
||||||
|
args = (callback.data or "").split("_")
|
||||||
|
action = args[0] if args else ""
|
||||||
|
|
||||||
|
if action == "go" and len(args) > 1:
|
||||||
|
chapter = await _render_chapter(args[1])
|
||||||
|
if chapter is not None and callback.message is not None:
|
||||||
|
text, markup = chapter
|
||||||
|
await callback.bot.edit_message_text(
|
||||||
|
chat_id=callback.message.chat.id,
|
||||||
|
message_id=callback.message.message_id,
|
||||||
|
text=text,
|
||||||
|
reply_markup=markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif action == "buy" and len(args) > 2:
|
||||||
|
product, id_product = args[1], int(args[2])
|
||||||
|
if product == "title":
|
||||||
|
ok = await shop_service.buy_title(callback.from_user.id, id_product)
|
||||||
|
if not ok:
|
||||||
|
answer_text = system.MSG_PURCHASE_FAIL
|
||||||
|
elif callback.message is not None:
|
||||||
|
await callback.bot.edit_message_text(
|
||||||
|
chat_id=callback.message.chat.id,
|
||||||
|
message_id=callback.message.message_id,
|
||||||
|
text=system.MSG_PURCHASE_OK,
|
||||||
|
)
|
||||||
|
elif product == "donat":
|
||||||
|
promo = await shop_service.buy_donat(callback.from_user.id, id_product)
|
||||||
|
if promo is None:
|
||||||
|
answer_text = system.MSG_PURCHASE_FAIL
|
||||||
|
elif callback.message is not None:
|
||||||
|
await callback.bot.edit_message_text(
|
||||||
|
chat_id=callback.message.chat.id,
|
||||||
|
message_id=callback.message.message_id,
|
||||||
|
text=render(system.MSG_DONAT_PROMO, {"promo": promo}),
|
||||||
|
parse_mode="MARKDOWN",
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.answer(answer_text)
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Клавиатуры бота."""
|
||||||
|
|
||||||
|
from bot.keyboards.shop import get_keyboard_donat, get_keyboard_main, get_keyboard_title
|
||||||
|
|
||||||
|
__all__ = ["get_keyboard_donat", "get_keyboard_main", "get_keyboard_title"]
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""Клавиатуры магазина."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
|
||||||
|
from bot.services import shop as shop_service
|
||||||
|
|
||||||
|
BACK_BUTTON = InlineKeyboardButton(text="<<<Назад<<<", callback_data="go_main")
|
||||||
|
|
||||||
|
|
||||||
|
def get_keyboard_main() -> InlineKeyboardMarkup:
|
||||||
|
"""Главное меню магазина."""
|
||||||
|
return InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(text="Звание", callback_data="go_title")],
|
||||||
|
[InlineKeyboardButton(text="Донат", callback_data="go_donat")],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_keyboard_title() -> InlineKeyboardMarkup:
|
||||||
|
"""Список доступных званий."""
|
||||||
|
titles = await shop_service.list_titles()
|
||||||
|
buttons = [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{title.title} - {title.price} DE",
|
||||||
|
callback_data=f"buy_title_{title.id}",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for title in titles
|
||||||
|
]
|
||||||
|
buttons.append([BACK_BUTTON])
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_keyboard_donat() -> InlineKeyboardMarkup:
|
||||||
|
"""Список доступных донатов."""
|
||||||
|
donats = await shop_service.list_donats()
|
||||||
|
buttons = [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{donat.title} - {donat.price} DE",
|
||||||
|
callback_data=f"buy_donat_{donat.id}",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for donat in donats
|
||||||
|
]
|
||||||
|
buttons.append([BACK_BUTTON])
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Бизнес-логика бота."""
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
"""Проверка доступа пользователя к функциональности бота."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from aiogram import Bot
|
||||||
|
from aiogram.enums import ChatMemberStatus
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
from bot.services import users as users_service
|
||||||
|
from common.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_ALLOWED_STATUSES = {
|
||||||
|
ChatMemberStatus.MEMBER,
|
||||||
|
ChatMemberStatus.CREATOR,
|
||||||
|
ChatMemberStatus.ADMINISTRATOR,
|
||||||
|
ChatMemberStatus.RESTRICTED,
|
||||||
|
}
|
||||||
|
|
||||||
|
#: TTL-кеш членства, чтобы не дёргать get_chat_member на каждое сообщение.
|
||||||
|
_MEMBERSHIP_TTL = 300.0
|
||||||
|
_membership_cache: dict[int, tuple[float, bool]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _is_member(bot: Bot, user_id: int) -> bool:
|
||||||
|
now = time.monotonic()
|
||||||
|
cached = _membership_cache.get(user_id)
|
||||||
|
if cached is not None and now - cached[0] < _MEMBERSHIP_TTL:
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
|
member = await bot.get_chat_member(get_settings().chat_id, user_id)
|
||||||
|
allowed = member.status in _ALLOWED_STATUSES
|
||||||
|
_membership_cache[user_id] = (now, allowed)
|
||||||
|
return allowed
|
||||||
|
|
||||||
|
|
||||||
|
async def check_user(message: Message) -> bool:
|
||||||
|
"""Пропустить пользователя: проверить членство и обновить его запись.
|
||||||
|
|
||||||
|
В тестовом чате доступ разрешён всегда.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
if message.chat.id == settings.test_chat_id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
user = message.from_user
|
||||||
|
if user is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if user.id in settings.admin_ids or await _is_member(message.bot, user.id):
|
||||||
|
await users_service.register_from_message(message)
|
||||||
|
return True
|
||||||
|
|
||||||
|
await users_service.delete_user(user.id)
|
||||||
|
logger.warning("Пользователь %s стучится в чат, доступ запрещён", user.id)
|
||||||
|
await _notify_denied(message)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify_denied(message: Message) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
username = message.from_user.username if message.from_user else None
|
||||||
|
try:
|
||||||
|
await message.bot.send_message(
|
||||||
|
chat_id=settings.test_chat_id,
|
||||||
|
text=f"[check_user]\nСтучится в чат {username}!",
|
||||||
|
)
|
||||||
|
if settings.alert_chat_id:
|
||||||
|
await message.bot.forward_message(
|
||||||
|
chat_id=settings.alert_chat_id,
|
||||||
|
from_chat_id=message.chat.id,
|
||||||
|
message_id=message.message_id,
|
||||||
|
)
|
||||||
|
except Exception: # уведомление не должно ломать обработку
|
||||||
|
logger.exception("Не удалось отправить уведомление о запрете доступа")
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""Сервис экономики: очищение, яички, монетки."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from common.models import EggsResult, PurificationResult
|
||||||
|
from common.repositories import economy_repo
|
||||||
|
|
||||||
|
#: id действия для начисления монеток.
|
||||||
|
ACTION_COIN = 1
|
||||||
|
|
||||||
|
|
||||||
|
async def purify(id_user: int) -> PurificationResult:
|
||||||
|
"""Выполнить ежедневное очищение."""
|
||||||
|
row = await economy_repo.purification(id_user)
|
||||||
|
if not row:
|
||||||
|
return PurificationResult(success=False)
|
||||||
|
return PurificationResult(
|
||||||
|
success=int(row.get("res") or 0) != 0,
|
||||||
|
exp_add=int(row.get("expr_add") or 0),
|
||||||
|
money_add=int(row.get("mny_add") or 0),
|
||||||
|
hours=int(row.get("h") or 0),
|
||||||
|
minutes=int(row.get("m") or 0),
|
||||||
|
seconds=int(row.get("s") or 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_eggs(id_user: int) -> EggsResult:
|
||||||
|
"""Получить ежедневные яички."""
|
||||||
|
row = await economy_repo.add_eggs(id_user)
|
||||||
|
if not row:
|
||||||
|
return EggsResult(success=False)
|
||||||
|
return EggsResult(
|
||||||
|
success=int(row.get("res") or 0) != 0,
|
||||||
|
eggs=int(row.get("eggs_add") or 0),
|
||||||
|
hours=int(row.get("h") or 0),
|
||||||
|
minutes=int(row.get("m") or 0),
|
||||||
|
seconds=int(row.get("s") or 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def add_coins(id_user: int, amount: int) -> None:
|
||||||
|
"""Начислить монеты."""
|
||||||
|
await economy_repo.money_action(id_user, amount, ACTION_COIN)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
"""Планировщик рассылок.
|
||||||
|
|
||||||
|
Исполняется внутри процесса бота. Расписание пока задано в коде (данные, а не
|
||||||
|
магические числа); в Блоке 4 переедет в таблицу ``schedules`` и будет
|
||||||
|
редактироваться через админку.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from aiogram import Bot
|
||||||
|
|
||||||
|
from bot.services import users as users_service
|
||||||
|
from common.config import get_settings
|
||||||
|
from common.texts import format_usernames, render
|
||||||
|
from common.texts import notifications as notify_texts
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Размер порции упоминаний в одном сообщении.
|
||||||
|
BATCH_SIZE = 10
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ScheduledMessage:
|
||||||
|
"""Одна запланированная рассылка.
|
||||||
|
|
||||||
|
:param weekdays: дни недели (0=Пн … 6=Вс). None — ежедневно.
|
||||||
|
"""
|
||||||
|
|
||||||
|
hour: int
|
||||||
|
minute: int
|
||||||
|
template: str
|
||||||
|
weekdays: tuple[int, ...] | None = field(default=None)
|
||||||
|
|
||||||
|
def matches(self, now: datetime) -> bool:
|
||||||
|
if self.hour != now.hour or self.minute != now.minute:
|
||||||
|
return False
|
||||||
|
return self.weekdays is None or now.weekday() in self.weekdays
|
||||||
|
|
||||||
|
|
||||||
|
SCHEDULES: tuple[ScheduledMessage, ...] = (
|
||||||
|
ScheduledMessage(16, 30, notify_texts.MSG_BLAGO_30),
|
||||||
|
ScheduledMessage(16, 45, notify_texts.MSG_BLAGO_45),
|
||||||
|
ScheduledMessage(16, 55, notify_texts.MSG_BLAGO_55),
|
||||||
|
ScheduledMessage(17, 0, notify_texts.MSG_BLAGO),
|
||||||
|
ScheduledMessage(15, 0, notify_texts.MSG_VKLAD, weekdays=(4,)), # пятница
|
||||||
|
ScheduledMessage(14, 0, notify_texts.MSG_VKLAD, weekdays=(6,)), # воскресенье
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 [[]]
|
||||||
|
for chunk in chunks:
|
||||||
|
text = render(template, {"usernames": format_usernames(chunk)})
|
||||||
|
await bot.send_message(chat_id=get_settings().chat_id, text=text)
|
||||||
|
|
||||||
|
|
||||||
|
async def _tick(bot: Bot, now: datetime) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if now.minute == 0:
|
||||||
|
await bot.send_message(chat_id=settings.test_chat_id, text="Я ЖЫФ!")
|
||||||
|
|
||||||
|
for schedule in SCHEDULES:
|
||||||
|
if schedule.matches(now):
|
||||||
|
subscribers = await users_service.list_subscribers()
|
||||||
|
await _send_broadcast(bot, schedule.template, subscribers)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_notifications(bot: Bot) -> None:
|
||||||
|
"""Бесконечно проверять расписание раз в минуту."""
|
||||||
|
tz = get_settings().tzinfo
|
||||||
|
while True:
|
||||||
|
now = datetime.now(tz).replace(second=0, microsecond=0)
|
||||||
|
try:
|
||||||
|
await _tick(bot, now)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Ошибка планировщика рассылок")
|
||||||
|
# Спать до начала следующей минуты, чтобы обрабатывать каждую минуту один раз.
|
||||||
|
await asyncio.sleep(60 - datetime.now(tz).second)
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Сервис магазина."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from common.models import Donat, Title
|
||||||
|
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
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def buy_title(id_user: int, id_title: int) -> bool:
|
||||||
|
"""Купить звание. Возвращает True при успехе."""
|
||||||
|
return await shop_repo.buy_title(id_user, id_title) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def buy_donat(id_user: int, id_donat: int) -> str | None:
|
||||||
|
"""Купить донат. Возвращает промокод или None при неуспехе."""
|
||||||
|
result = await shop_repo.buy_donat(id_user, id_donat)
|
||||||
|
if result == "-1":
|
||||||
|
return None
|
||||||
|
return result
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
"""Сервис статистики."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from common.models import TopEntry
|
||||||
|
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
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
"""Сервис подписок на рассылки."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from common.repositories import subscriptions_repo
|
||||||
|
|
||||||
|
|
||||||
|
async def subscribe(id_user: int) -> None:
|
||||||
|
await subscriptions_repo.subscribe(id_user)
|
||||||
|
|
||||||
|
|
||||||
|
async def unsubscribe(id_user: int) -> None:
|
||||||
|
await subscriptions_repo.unsubscribe(id_user)
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""Сервис пользователей."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from aiogram.types import ChatMemberUpdated, Message, User
|
||||||
|
|
||||||
|
from common.models import NickEntry, UserInfo
|
||||||
|
from common.repositories import users_repo
|
||||||
|
|
||||||
|
|
||||||
|
def _user_fields(user: User) -> tuple[int, str, str, str]:
|
||||||
|
return (
|
||||||
|
int(user.id),
|
||||||
|
user.username or "",
|
||||||
|
user.first_name or "",
|
||||||
|
user.last_name or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def register_from_message(message: Message) -> None:
|
||||||
|
"""Зарегистрировать/обновить автора сообщения."""
|
||||||
|
user = message.from_user
|
||||||
|
if user is None or user.is_bot:
|
||||||
|
return
|
||||||
|
await users_repo.add_user(*_user_fields(user))
|
||||||
|
|
||||||
|
|
||||||
|
async def register_from_event(event: ChatMemberUpdated) -> None:
|
||||||
|
"""Зарегистрировать/обновить участника, вошедшего в чат."""
|
||||||
|
user = event.new_chat_member.user
|
||||||
|
if user.is_bot:
|
||||||
|
return
|
||||||
|
await users_repo.add_user(*_user_fields(user))
|
||||||
|
|
||||||
|
|
||||||
|
async def set_nick(id_user: int, nick: str) -> None:
|
||||||
|
await users_repo.add_nick(id_user, nick)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_user(id_user: int) -> None:
|
||||||
|
await users_repo.del_user_by_id(id_user)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_nicks() -> list[NickEntry]:
|
||||||
|
rows = await users_repo.get_nick_list()
|
||||||
|
return [NickEntry(username=row.get("username"), nick=row.get("nick")) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_subscribers() -> list[dict]:
|
||||||
|
return await users_repo.get_nick_list_subscribe()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_info(id_user: int) -> UserInfo | None:
|
||||||
|
row = await users_repo.get_info_by_id(id_user)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return UserInfo(
|
||||||
|
username=row.get("username"),
|
||||||
|
nick=row.get("nick"),
|
||||||
|
title=row.get("title"),
|
||||||
|
lvl=int(row.get("lvl") or 0),
|
||||||
|
exp=int(row.get("expr") or 0),
|
||||||
|
money=int(row.get("mny") or 0),
|
||||||
|
dt=row.get("dt") or "",
|
||||||
|
msg_count=int(row.get("msg_count") or 0),
|
||||||
|
eggs=int(row.get("eggs_count") or 0),
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Общий пакет, используемый сервисами bot и admin."""
|
||||||
|
|
||||||
|
from common.version import __version__
|
||||||
|
|
||||||
|
__all__ = ["__version__"]
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""Конфигурация приложения.
|
||||||
|
|
||||||
|
Значения читаются из переменных окружения / файла .env (см. .env.example).
|
||||||
|
Реальных секретов в коде быть не должно.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from pydantic import Field, field_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Настройки, общие для бота и админки."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
case_sensitive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
app_env: str = "dev"
|
||||||
|
|
||||||
|
bot_token: str = Field(..., description="Токен бота от @BotFather")
|
||||||
|
|
||||||
|
db_host: str = "localhost"
|
||||||
|
db_port: int = 5432
|
||||||
|
db_name: str = "dispel_evil"
|
||||||
|
db_user: str = "de_admin"
|
||||||
|
db_password: str = ""
|
||||||
|
db_pool_min: int = 1
|
||||||
|
db_pool_max: int = 10
|
||||||
|
db_statement_timeout_ms: int = 15000
|
||||||
|
|
||||||
|
prod_chat_id: int = 0
|
||||||
|
test_chat_id: int = 0
|
||||||
|
alert_chat_id: int = 0
|
||||||
|
|
||||||
|
admin_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Поддержать формат 'ADMIN_IDS=1,2,3' в дополнение к JSON-списку."""
|
||||||
|
if value is None or value == "":
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
cleaned = value.strip().strip("[]")
|
||||||
|
if not cleaned:
|
||||||
|
return []
|
||||||
|
return [int(item.strip()) for item in cleaned.split(",") if item.strip()]
|
||||||
|
return value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_prod(self) -> bool:
|
||||||
|
return self.app_env.strip().lower() in {"prod", "production"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def chat_id(self) -> int:
|
||||||
|
"""Основной чат клана для текущего окружения."""
|
||||||
|
return self.prod_chat_id if self.is_prod else self.test_chat_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tzinfo(self) -> ZoneInfo:
|
||||||
|
return ZoneInfo(self.timezone)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dsn(self) -> str:
|
||||||
|
"""Строка подключения 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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""Вернуть singleton настроек."""
|
||||||
|
return Settings() # type: ignore[call-arg]
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Слой подключения к базе данных."""
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""Async-пул соединений Postgres (psycopg v3).
|
||||||
|
|
||||||
|
Все репозитории берут соединение отсюда. Соединения возвращаются в пул и
|
||||||
|
коммитятся при успешном выходе из контекстного менеджера.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg_pool import AsyncConnectionPool
|
||||||
|
|
||||||
|
from common.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_pool: AsyncConnectionPool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def init_pool() -> AsyncConnectionPool:
|
||||||
|
"""Создать и открыть пул. Идемпотентно."""
|
||||||
|
global _pool
|
||||||
|
if _pool is not None:
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
_pool = AsyncConnectionPool(
|
||||||
|
conninfo=settings.dsn,
|
||||||
|
min_size=settings.db_pool_min,
|
||||||
|
max_size=settings.db_pool_max,
|
||||||
|
open=False,
|
||||||
|
kwargs={
|
||||||
|
"row_factory": dict_row,
|
||||||
|
"options": f"-c statement_timeout={settings.db_statement_timeout_ms}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await _pool.open(wait=True)
|
||||||
|
logger.info(
|
||||||
|
"Postgres pool opened: %s@%s:%s/%s (min=%s, max=%s)",
|
||||||
|
settings.db_user,
|
||||||
|
settings.db_host,
|
||||||
|
settings.db_port,
|
||||||
|
settings.db_name,
|
||||||
|
settings.db_pool_min,
|
||||||
|
settings.db_pool_max,
|
||||||
|
)
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
|
async def close_pool() -> None:
|
||||||
|
"""Закрыть пул при остановке приложения."""
|
||||||
|
global _pool
|
||||||
|
if _pool is not None:
|
||||||
|
await _pool.close()
|
||||||
|
_pool = None
|
||||||
|
logger.info("Postgres pool closed")
|
||||||
|
|
||||||
|
|
||||||
|
def get_pool() -> AsyncConnectionPool:
|
||||||
|
"""Вернуть открытый пул или упасть с понятной ошибкой."""
|
||||||
|
if _pool is None:
|
||||||
|
raise RuntimeError("Postgres pool не инициализирован: вызовите init_pool() при старте")
|
||||||
|
return _pool
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Единая настройка логирования для сервисов."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(level: int = logging.INFO) -> None:
|
||||||
|
"""Настроить корневой логгер на вывод в stdout."""
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(logging.Formatter(_LOG_FORMAT))
|
||||||
|
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.handlers.clear()
|
||||||
|
root.addHandler(handler)
|
||||||
|
root.setLevel(level)
|
||||||
|
|
||||||
|
# aiogram довольно шумный на INFO для сетевых событий
|
||||||
|
logging.getLogger("aiogram.event").setLevel(logging.WARNING)
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
"""Доменные модели (dataclasses)."""
|
||||||
|
|
||||||
|
from common.models.economy import EggsResult, PurificationResult
|
||||||
|
from common.models.shop import Donat, Title
|
||||||
|
from common.models.stats import TopEntry
|
||||||
|
from common.models.users import NickEntry, UserInfo
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Donat",
|
||||||
|
"EggsResult",
|
||||||
|
"NickEntry",
|
||||||
|
"PurificationResult",
|
||||||
|
"Title",
|
||||||
|
"TopEntry",
|
||||||
|
"UserInfo",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""Модели экономики (очищение, яички)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PurificationResult:
|
||||||
|
"""Результат ежедневного очищения."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
exp_add: int = 0
|
||||||
|
money_add: int = 0
|
||||||
|
hours: int = 0
|
||||||
|
minutes: int = 0
|
||||||
|
seconds: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class EggsResult:
|
||||||
|
"""Результат получения яичек."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
eggs: int = 0
|
||||||
|
hours: int = 0
|
||||||
|
minutes: int = 0
|
||||||
|
seconds: int = 0
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""Модели магазина."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Title:
|
||||||
|
"""Покупаемое звание."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
price: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Donat:
|
||||||
|
"""Покупаемый донат."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
price: int
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
"""Модели статистики."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class TopEntry:
|
||||||
|
"""Строка рейтинга по количеству сообщений."""
|
||||||
|
|
||||||
|
username: str | None
|
||||||
|
nick: str | None
|
||||||
|
count: int
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""Модели пользователя."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class UserInfo:
|
||||||
|
"""Сводная информация о пользователе (для /инфо)."""
|
||||||
|
|
||||||
|
username: str | None
|
||||||
|
nick: str | None
|
||||||
|
title: str | None
|
||||||
|
lvl: int
|
||||||
|
exp: int
|
||||||
|
money: int
|
||||||
|
dt: str
|
||||||
|
msg_count: int
|
||||||
|
eggs: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exp_max(self) -> int:
|
||||||
|
"""Порог опыта для текущего уровня."""
|
||||||
|
return self.lvl * 10
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title_text(self) -> str:
|
||||||
|
return self.title or ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nick_text(self) -> str:
|
||||||
|
return self.nick or ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class NickEntry:
|
||||||
|
"""Пара username/nick из списка участников."""
|
||||||
|
|
||||||
|
username: str | None
|
||||||
|
nick: str | None
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Репозитории — доступ к данным. Возвращают сырые dict/скаляры."""
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""Доступ к данным экономики (деньги, опыт, очищение, яички)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def get_eggs(id_user: int) -> int:
|
||||||
|
"""Количество яичек пользователя."""
|
||||||
|
async with get_pool().connection() as conn:
|
||||||
|
async with 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
|
||||||
|
|
||||||
|
|
||||||
|
async def purification(id_user: int) -> dict[str, Any] | None:
|
||||||
|
"""Выполнить ежедневное очищение."""
|
||||||
|
async with get_pool().connection() as conn:
|
||||||
|
async with 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:
|
||||||
|
await cur.execute("SELECT * FROM bot.add_eggs(%s)", (id_user,))
|
||||||
|
return await cur.fetchone()
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""Доступ к данным магазина (звания, донаты)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
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"
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
"""Доступ к статистике (рейтинг)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
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:
|
||||||
|
await cur.execute("SELECT u AS username, n AS nick, c AS count FROM bot.get_top_list()")
|
||||||
|
return await cur.fetchall()
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
"""Доступ к подпискам на рассылки."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
await cur.execute("CALL bot.unsubscribe(%s)", (id_user,))
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""Доступ к данным пользователей."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
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:
|
||||||
|
await cur.execute(
|
||||||
|
"CALL bot.add_user(%s, %s, %s, %s)",
|
||||||
|
(id_user, username, first_name, last_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def add_nick(id_user: int, nick: str) -> None:
|
||||||
|
"""Установить ник пользователя."""
|
||||||
|
async with get_pool().connection() as conn:
|
||||||
|
async with 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:
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
await cur.execute("CALL bot.del_user_by_id(%s)", (id_user,))
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""Тексты сообщений и рендер тегов.
|
||||||
|
|
||||||
|
Тексты сгруппированы по темам. ``TEMPLATES`` — реестр «ключ -> текст по
|
||||||
|
умолчанию» для засева таблицы ``message_templates`` (см. Блок 4).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from common.texts import economy, info, notifications, shop, system, welcome
|
||||||
|
from common.texts.render import (
|
||||||
|
MAX_MESSAGE_LENGTH,
|
||||||
|
RAW_TAGS,
|
||||||
|
SUPPORTED_TAGS,
|
||||||
|
TAG_REGISTRY,
|
||||||
|
TemplateError,
|
||||||
|
format_tops,
|
||||||
|
format_user_list,
|
||||||
|
format_usernames,
|
||||||
|
render,
|
||||||
|
validate,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Реестр шаблонов по умолчанию.
|
||||||
|
TEMPLATES: dict[str, str] = {
|
||||||
|
"welcome.text": welcome.MSG_WELCOME_TEXT,
|
||||||
|
"welcome.text2": welcome.MSG_WELCOME_TEXT2,
|
||||||
|
"welcome.text3": welcome.MSG_WELCOME_TEXT3,
|
||||||
|
"info.profile": info.MSG_INFO,
|
||||||
|
"info.help": info.MSG_HELP,
|
||||||
|
"shop.main": shop.MSG_SHOP_MAIN,
|
||||||
|
"shop.title": shop.MSG_SHOP_TITLE,
|
||||||
|
"shop.donat": shop.MSG_SHOP_DONAT,
|
||||||
|
"economy.nick_list": economy.MSG_NICK_LIST,
|
||||||
|
"economy.top": economy.MSG_TOP,
|
||||||
|
"economy.purification": economy.MSG_PURIFICATION,
|
||||||
|
"economy.no_purification": economy.MSG_NO_PURIFICATION,
|
||||||
|
"economy.eggs": economy.MSG_EGGS,
|
||||||
|
"economy.no_eggs": economy.MSG_NO_EGGS,
|
||||||
|
"notify.blago_30": notifications.MSG_BLAGO_30,
|
||||||
|
"notify.blago_45": notifications.MSG_BLAGO_45,
|
||||||
|
"notify.blago_55": notifications.MSG_BLAGO_55,
|
||||||
|
"notify.blago": notifications.MSG_BLAGO,
|
||||||
|
"notify.vklad": notifications.MSG_VKLAD,
|
||||||
|
}
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MAX_MESSAGE_LENGTH",
|
||||||
|
"RAW_TAGS",
|
||||||
|
"SUPPORTED_TAGS",
|
||||||
|
"TAG_REGISTRY",
|
||||||
|
"TEMPLATES",
|
||||||
|
"TemplateError",
|
||||||
|
"economy",
|
||||||
|
"format_tops",
|
||||||
|
"format_user_list",
|
||||||
|
"format_usernames",
|
||||||
|
"info",
|
||||||
|
"notifications",
|
||||||
|
"render",
|
||||||
|
"shop",
|
||||||
|
"system",
|
||||||
|
"validate",
|
||||||
|
"welcome",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
"""Тексты экономики: списки, рейтинг, очищение, яички."""
|
||||||
|
|
||||||
|
MSG_NICK_LIST = """<b><u>📑 Список игроков[{users_list_count}] 📑</u></b>
|
||||||
|
|
||||||
|
{users_list}"""
|
||||||
|
|
||||||
|
MSG_TOP = """<u><b>⭐️ТОП10 общительных участников⭐️</b></u>:
|
||||||
|
{tops}"""
|
||||||
|
|
||||||
|
MSG_PURIFICATION = """<b>💠Успешное очищение!💠</b>
|
||||||
|
<b>Получено:</b> 💰{money_add} DE и 🥪{exp_add} exp!"""
|
||||||
|
|
||||||
|
MSG_NO_PURIFICATION = """Очищение возможно не чаще раза в сутки!
|
||||||
|
Возвращайся через {h}ч {m}м {s}с"""
|
||||||
|
|
||||||
|
MSG_EGGS = """<b>💠Держи свои яички!💠</b>
|
||||||
|
<b>Получено</b> {eggs}🥚!"""
|
||||||
|
|
||||||
|
MSG_NO_EGGS = """Получать яички возможно не чаще раза в сутки!
|
||||||
|
Возвращайся через {h}ч {m}м {s}с"""
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Информационные тексты (профиль, помощь)."""
|
||||||
|
|
||||||
|
MSG_INFO = """<b>👤Информация о пользователе {username} </b>
|
||||||
|
|
||||||
|
<b>✅ Ник в игре:</b> {nick}
|
||||||
|
<b>🏆 Звание:</b> {title}
|
||||||
|
<b>🧩 Уровень:</b> {lvl}
|
||||||
|
<b>🥪 Опыт:</b> {exp}/{exp_max}
|
||||||
|
<b>💰 Деньги:</b> {money}
|
||||||
|
<b>📈 Начало активности в боте:</b> {dt}
|
||||||
|
<b>📊 Количество сообщений в чате:</b> {msg_count}
|
||||||
|
<b>🥚 Количество яичек:</b> {eggs}
|
||||||
|
"""
|
||||||
|
|
||||||
|
MSG_HELP = """<b><u>Список команд бота</u></b>
|
||||||
|
|
||||||
|
<b>/ник <i>НИК</i></b> - указать свой ник в игре <u>[чат, бот]</u>
|
||||||
|
<b>/список</b> - вывести список ников <u>[бот]</u>
|
||||||
|
<b>/кто</b> - вывод ника человека в ответ на сообщение <u>[чат]</u>
|
||||||
|
<b>/инфо</b> - вывод информации о себе <u>[чат, бот]</u>
|
||||||
|
<b>/очищение</b> - получение бонусов раз в сутки <u>[чат, бот]</u>
|
||||||
|
<b>/магазин</b> - магазин бота <u>[бот]</u>
|
||||||
|
<b>/рейтинг</b> - топ-10 игроков по сообщениям <u>[чат, бот]</u>
|
||||||
|
<b>/отписка</b> - отписаться от рассылок <u>[чат, бот]</u>
|
||||||
|
<b>/подписка</b> - подписаться на рассылки <u>[чат, бот]</u>
|
||||||
|
"""
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
"""Тексты рассылок (благо, вклад)."""
|
||||||
|
|
||||||
|
MSG_BLAGO_30 = """ ⏰ <b>Просыпаемся, 30 минут до блага!</b> ⏰
|
||||||
|
{usernames}"""
|
||||||
|
|
||||||
|
MSG_BLAGO_45 = """ 🔔 <b>15 минут до блага, ждём всех на базе!</b> 🔔
|
||||||
|
{usernames}"""
|
||||||
|
|
||||||
|
MSG_BLAGO_55 = """ ☀️ <b>5 МИНУТ ДО БЛАГА! НАРЯЖАЕМ ПОПКИ!</b> ☀️
|
||||||
|
{usernames}"""
|
||||||
|
|
||||||
|
MSG_BLAGO = """💃 <b>АКТИВНО ТРЯСЁМ ПОПКАМИ НА БАЗЕ!</b>🕺
|
||||||
|
{usernames}"""
|
||||||
|
|
||||||
|
MSG_VKLAD = """<b>💰 НЕ ЗАБЫВАЕМ ПРОВЕРИТЬ ВКЛАД 💰</b>
|
||||||
|
{usernames}"""
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
"""Рендер шаблонов сообщений по тегам вида ``{tag}``.
|
||||||
|
|
||||||
|
Единый источник правды для бота и админки. Не используется ``str.format``:
|
||||||
|
любая посторонняя фигурная скобка в тексте сломала бы его. Подстановка идёт
|
||||||
|
только по известным тегам из реестра, неизвестный тег — ошибка валидации.
|
||||||
|
|
||||||
|
Пользовательские значения экранируются под Telegram HTML (ParseMode.HTML),
|
||||||
|
кроме тегов, помеченных как RAW (списки, которые формируются уже безопасно).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
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"}
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Полный реестр поддерживаемых тегов: имя -> короткое описание.
|
||||||
|
TAG_REGISTRY: dict[str, str] = {
|
||||||
|
"username": "Имя пользователя (@username или имя)",
|
||||||
|
"nick": "Игровой ник",
|
||||||
|
"title": "Звание",
|
||||||
|
"lvl": "Уровень",
|
||||||
|
"exp": "Текущий опыт",
|
||||||
|
"exp_max": "Опыт до следующего уровня",
|
||||||
|
"money": "Баланс DE",
|
||||||
|
"dt": "Дата начала активности",
|
||||||
|
"msg_count": "Количество сообщений",
|
||||||
|
"eggs": "Количество яичек",
|
||||||
|
"h": "Часы до следующего действия",
|
||||||
|
"m": "Минуты до следующего действия",
|
||||||
|
"s": "Секунды до следующего действия",
|
||||||
|
"exp_add": "Начислено опыта",
|
||||||
|
"money_add": "Начислено DE",
|
||||||
|
"usernames": "Список @упоминаний для рассылки",
|
||||||
|
"users_list": "Нумерованный список игроков",
|
||||||
|
"users_list_count": "Количество игроков в списке",
|
||||||
|
"tops": "Топ игроков по сообщениям",
|
||||||
|
"promo": "Промокод доната",
|
||||||
|
}
|
||||||
|
|
||||||
|
SUPPORTED_TAGS: frozenset[str] = frozenset(TAG_REGISTRY)
|
||||||
|
|
||||||
|
#: Лимит длины сообщения Telegram.
|
||||||
|
MAX_MESSAGE_LENGTH = 4096
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateError(ValueError):
|
||||||
|
"""Ошибка в шаблоне: неизвестный тег или превышена длина."""
|
||||||
|
|
||||||
|
|
||||||
|
def escape(value: Any) -> str:
|
||||||
|
"""Экранировать значение под HTML-разметку Telegram."""
|
||||||
|
return html.escape(str(value), quote=False)
|
||||||
|
|
||||||
|
|
||||||
|
def render(template: str, context: Mapping[str, Any] | None = None) -> str:
|
||||||
|
"""Подставить теги в шаблон.
|
||||||
|
|
||||||
|
:raises TemplateError: если в шаблоне есть неизвестный тег.
|
||||||
|
"""
|
||||||
|
context = context or {}
|
||||||
|
|
||||||
|
def _replace(match: re.Match[str]) -> str:
|
||||||
|
name = match.group(1)
|
||||||
|
if name not in SUPPORTED_TAGS:
|
||||||
|
raise TemplateError(f"Неизвестный тег {{{name}}}")
|
||||||
|
value = context.get(name)
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if name in RAW_TAGS:
|
||||||
|
return str(value)
|
||||||
|
return escape(value)
|
||||||
|
|
||||||
|
return _TAG_RE.sub(_replace, template)
|
||||||
|
|
||||||
|
|
||||||
|
def validate(template: str) -> list[str]:
|
||||||
|
"""Вернуть список неизвестных тегов в шаблоне (для валидации в админке)."""
|
||||||
|
unknown = {m.group(1) for m in _TAG_RE.finditer(template)}
|
||||||
|
return sorted(tag for tag in unknown if tag not in SUPPORTED_TAGS)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Хелперы формирования RAW-значений -------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def format_usernames(rows: Iterable[Mapping[str, Any]]) -> str:
|
||||||
|
"""Строка вида ``@user1 @user2`` для рассылок."""
|
||||||
|
parts = []
|
||||||
|
for row in rows:
|
||||||
|
username = row.get("username")
|
||||||
|
if username:
|
||||||
|
parts.append("@" + escape(username))
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def format_user_list(rows: Iterable[Mapping[str, Any]]) -> str:
|
||||||
|
"""Нумерованный список игроков: ``1: @user - nick``."""
|
||||||
|
lines = []
|
||||||
|
for index, row in enumerate(rows, start=1):
|
||||||
|
username = row.get("username")
|
||||||
|
nick = row.get("nick")
|
||||||
|
user_text = "@" + escape(username) if username else "—"
|
||||||
|
nick_text = escape(nick) if nick else ""
|
||||||
|
lines.append(f"{index}: {user_text} - {nick_text}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_tops(rows: Iterable[Mapping[str, Any]]) -> str:
|
||||||
|
"""Список рейтинга: ``1) nick - N``."""
|
||||||
|
lines = []
|
||||||
|
for index, row in enumerate(rows, start=1):
|
||||||
|
nick = row.get("nick") or "—"
|
||||||
|
count = row.get("count", 0)
|
||||||
|
lines.append(f"{index}) {escape(nick)} - {count}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Тексты магазина."""
|
||||||
|
|
||||||
|
MSG_SHOP_MAIN = "Разделы магазина"
|
||||||
|
|
||||||
|
MSG_SHOP_TITLE = "Доступные звания для покупки"
|
||||||
|
|
||||||
|
MSG_SHOP_DONAT = "Доступные донаты для покупки"
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Служебные тексты и ответы бота."""
|
||||||
|
|
||||||
|
MSG_COIN = "Держи монетку! 🪙"
|
||||||
|
|
||||||
|
MSG_NICK_SET = "У @{username} теперь ник {nick}"
|
||||||
|
|
||||||
|
MSG_NICK_ERROR = "Что-то пошло не так..."
|
||||||
|
|
||||||
|
MSG_LIST_PRIVATE_ONLY = "Команда доступна в личных сообщениях бота."
|
||||||
|
|
||||||
|
MSG_SHOP_PRIVATE_ONLY = "Команда доступна в личных сообщениях бота"
|
||||||
|
|
||||||
|
MSG_REPLY_REQUIRED = "Нужно ответить на сообщение"
|
||||||
|
|
||||||
|
MSG_NICK_EMPTY = "Ник не указан :с"
|
||||||
|
|
||||||
|
MSG_SUBSCRIBED = "Подписал тебя на рассылки ❤️"
|
||||||
|
|
||||||
|
MSG_UNSUBSCRIBED = "Отписал тебя от рассылок 💔"
|
||||||
|
|
||||||
|
MSG_PURCHASE_OK = "Успешная покупка!"
|
||||||
|
|
||||||
|
MSG_PURCHASE_FAIL = "Не получилось купить :с"
|
||||||
|
|
||||||
|
MSG_DONAT_PROMO = "Назови этот код админам!\n`{promo}`"
|
||||||
|
|
||||||
|
MSG_EGGS_TOO_EARLY = "Слишком рано..."
|
||||||
|
|
||||||
|
MSG_BOT_ADDED = "Я пришёл! Требую админские права!"
|
||||||
|
|
||||||
|
MSG_BOT_FOREIGN_CHAT = "Создатель запретил ходить в другие чатики :с"
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""Тексты приветствия."""
|
||||||
|
|
||||||
|
MSG_WELCOME_TEXT = """💠{username}, добро пожаловать в Dispel Evil!
|
||||||
|
❗️Правила клана
|
||||||
|
|
||||||
|
<i>Незнание не освобождает от ответственности.
|
||||||
|
|
||||||
|
1. <u><b>Конфликты/разборки</b></u>: сами решаете между собой в лс. Админ имеет право дать мут участникам конфликта до его решения, потом пишите в лс, что решили вопрос и мут снимут. <b>Если замечены одни и те же люди в конфликте три раза - вас исключат из клана.</b>
|
||||||
|
|
||||||
|
2. <u><b>Слушать администрацию</b></u>: если кто-то из админов просит закрыть тему - вы её закрываете, если вам делают замечание - не агрессировать и отвечать спокойно. Со стороны админов не будет претензий на пустом месте.
|
||||||
|
|
||||||
|
3. <u><b>Маты в меру</b></u>. Если используйте маты в игре - на вашей совести, по правилам игры они запрещены, за последствия отвечать будете сами.</i> """
|
||||||
|
|
||||||
|
MSG_WELCOME_TEXT2 = """<i>4. <u><b>Сомнительные темы</b></u>: не рекомендуем заводить разговоры про политику/национальность/религию и другие спорные темы создающие конфликты. В противном случае вам могут выдать мут.
|
||||||
|
|
||||||
|
5. <u><b>Места на благо</b></u> - становитесь на любое свободное место, не выделяйте «своё личное место». <u><b>В съёмочный день оставьте 3е место для админа</b></u>. </i>
|
||||||
|
|
||||||
|
<u>Уважительная просьба</u>: если вам не приятен человек, решайте вопросы в лс. Пожалуйста, не выливайте это в общие чаты, чтобы сохранить приятную атмосферу для остальных.
|
||||||
|
|
||||||
|
<b>💠Ниже кнопка на наш основной канал со всеми новостями, конкурсами и темы для блага.</b>"""
|
||||||
|
|
||||||
|
MSG_WELCOME_TEXT3 = """❗️<b>По игре</b>:
|
||||||
|
|
||||||
|
💠Очки командного боя 20000+;
|
||||||
|
💠Вклад 1000+;
|
||||||
|
💠АФК 3 дня без предупреждения = Кик;
|
||||||
|
💠Чистка от бездельников по воскресеньям;
|
||||||
|
|
||||||
|
🕹Если появляются вопросы, Вы всегда можете написать админам: @Sad_Ju, @yaki_ra, @irinka87364
|
||||||
|
|
||||||
|
💡Также мы всегда ждём людей с новыми идеями и предложениями по развитию клана!
|
||||||
|
|
||||||
|
💠https://t.me/c/1800439573/397
|
||||||
|
В комментариях под постом, мы ждём твоего сообщения! Там же ты можешь посмотреть анкеты других участников и найти новых друзей!"""
|
||||||
|
|
||||||
|
MSG_WELCOME_BUTTON_CAPTION = "Канал"
|
||||||
|
|
||||||
|
MSG_WELCOME_BUTTON_URL = "https://t.me/+DQk8MyN2y6QzNTM6"
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
"""Версия проекта.
|
||||||
|
|
||||||
|
Единый источник правды. Git-тег совпадает с версией с префиксом ``v``
|
||||||
|
(например, версия ``0.1`` -> тег ``v0.1``). Docker-образы в Блоке 2 будут
|
||||||
|
тегироваться этой версией, а события в ClickHouse получат поле ``bot_version``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__version__ = "0.1"
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 396 KiB |
|
|
@ -0,0 +1,15 @@
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B", "W"]
|
||||||
|
ignore = ["E501"]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 120
|
||||||
|
target-version = ["py312"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-r requirements.txt
|
||||||
|
|
||||||
|
ruff>=0.6,<1.0
|
||||||
|
black>=24.4,<25.0
|
||||||
|
pytest>=8.2,<9.0
|
||||||
|
pytest-asyncio>=0.23,<1.0
|
||||||
|
pytest-cov>=5.0,<6.0
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Runtime dependencies (source of truth: этот файл).
|
||||||
|
# Python 3.12
|
||||||
|
|
||||||
|
aiogram>=3.7,<4.0
|
||||||
|
pydantic>=2.7,<3.0
|
||||||
|
pydantic-settings>=2.3,<3.0
|
||||||
|
python-dotenv>=1.0,<2.0
|
||||||
|
psycopg[binary,pool]>=3.2,<4.0
|
||||||
|
|
||||||
|
# Блок 3 (ClickHouse) — подключим позже:
|
||||||
|
# clickhouse-connect>=0.7,<1.0
|
||||||
|
# Блок 4 (расписания из БД) — при необходимости:
|
||||||
|
# APScheduler>=3.10,<4.0
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
-- Экономика: баланс, опыт, очищение, яички.
|
||||||
|
-- Repeatable-миграция: перезапускается при изменении (CREATE OR REPLACE).
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION bot.get_money(p_id_user bigint) RETURNS bigint
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_money bigint;
|
||||||
|
BEGIN
|
||||||
|
SELECT sum(amount) INTO v_money FROM bot.money WHERE id_user = p_id_user;
|
||||||
|
RETURN v_money;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION bot.get_eggs(p_id_user bigint) RETURNS bigint
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_result bigint;
|
||||||
|
BEGIN
|
||||||
|
SELECT sum(amount) INTO v_result FROM bot.eggs WHERE id_user = p_id_user;
|
||||||
|
RETURN v_result;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE PROCEDURE bot.money_action(
|
||||||
|
IN p_id_user bigint,
|
||||||
|
IN p_money bigint,
|
||||||
|
IN p_id_action bigint DEFAULT 1
|
||||||
|
) LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO bot.money (id_user, amount, id_action)
|
||||||
|
VALUES (p_id_user, p_money, p_id_action);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION bot.purification(p_id_user bigint)
|
||||||
|
RETURNS TABLE(res integer, expr_add bigint, mny_add bigint, h integer, m integer, s integer)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_lvl bigint;
|
||||||
|
v_expr bigint;
|
||||||
|
v_expr_add bigint;
|
||||||
|
v_streak bigint;
|
||||||
|
v_mny_add bigint;
|
||||||
|
v_dt_pur timestamp;
|
||||||
|
v_days bigint;
|
||||||
|
v_result integer;
|
||||||
|
v_h integer;
|
||||||
|
v_m integer;
|
||||||
|
v_s integer;
|
||||||
|
BEGIN
|
||||||
|
SELECT lvl, expr, expr_streak
|
||||||
|
INTO v_lvl, v_expr, v_streak
|
||||||
|
FROM bot.de_users
|
||||||
|
WHERE id_user = p_id_user;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
SELECT dt INTO v_dt_pur
|
||||||
|
FROM (
|
||||||
|
SELECT dt, row_number() OVER (ORDER BY dt DESC) rn
|
||||||
|
FROM bot.purifications
|
||||||
|
WHERE id_user = p_id_user
|
||||||
|
) t
|
||||||
|
WHERE rn = 1;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN others THEN
|
||||||
|
v_dt_pur := null;
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF v_dt_pur IS NULL THEN
|
||||||
|
v_days := -1;
|
||||||
|
ELSE
|
||||||
|
v_days = extract(day FROM now() - v_dt_pur);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_days = 0 THEN
|
||||||
|
v_result := 0;
|
||||||
|
v_h := 23 - extract(hour FROM now() - v_dt_pur);
|
||||||
|
v_m := 59 - extract(minute FROM now() - v_dt_pur);
|
||||||
|
v_s := 59 - extract(sec FROM now() - v_dt_pur);
|
||||||
|
ELSE
|
||||||
|
v_mny_add := floor(random() * 21) + 30;
|
||||||
|
v_expr_add := 1;
|
||||||
|
|
||||||
|
IF v_days = 1 THEN
|
||||||
|
IF v_streak < 30 THEN
|
||||||
|
v_streak := v_streak + 1;
|
||||||
|
END IF;
|
||||||
|
v_expr_add := v_expr_add + v_streak;
|
||||||
|
ELSE
|
||||||
|
v_streak := 0;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_expr := v_expr + v_expr_add;
|
||||||
|
|
||||||
|
WHILE v_expr >= v_lvl * 10 LOOP
|
||||||
|
v_expr := v_expr - v_lvl * 10;
|
||||||
|
v_lvl := v_lvl + 1;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
UPDATE bot.de_users
|
||||||
|
SET lvl = v_lvl, expr = v_expr, expr_streak = v_streak
|
||||||
|
WHERE id_user = p_id_user;
|
||||||
|
|
||||||
|
INSERT INTO bot.money (id_user, amount, id_action)
|
||||||
|
VALUES (p_id_user, v_mny_add, 4);
|
||||||
|
|
||||||
|
INSERT INTO bot.purifications (id_user) VALUES (p_id_user);
|
||||||
|
|
||||||
|
v_result := 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT v_result, v_expr_add, v_mny_add, v_h, v_m, v_s;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION bot.add_eggs(p_id_user bigint)
|
||||||
|
RETURNS TABLE(res integer, eggs_add bigint, h integer, m integer, s integer)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_dt timestamp;
|
||||||
|
v_egg_add bigint;
|
||||||
|
v_days bigint;
|
||||||
|
v_h integer;
|
||||||
|
v_m integer;
|
||||||
|
v_s integer;
|
||||||
|
v_result integer;
|
||||||
|
BEGIN
|
||||||
|
BEGIN
|
||||||
|
SELECT dt_action INTO v_dt
|
||||||
|
FROM (
|
||||||
|
SELECT dt_action, row_number() OVER (ORDER BY dt_action DESC) rn
|
||||||
|
FROM bot.eggs
|
||||||
|
WHERE id_user = p_id_user AND id_action = 1
|
||||||
|
) t
|
||||||
|
WHERE rn = 1;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN others THEN
|
||||||
|
v_dt := null;
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF v_dt IS NULL THEN
|
||||||
|
v_days := -1;
|
||||||
|
ELSE
|
||||||
|
v_days = extract(day FROM now() - v_dt);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_days = 0 THEN
|
||||||
|
v_result := 0;
|
||||||
|
v_h := 23 - extract(hour FROM now() - v_dt);
|
||||||
|
v_m := 59 - extract(minute FROM now() - v_dt);
|
||||||
|
v_s := 59 - extract(sec FROM now() - v_dt);
|
||||||
|
ELSE
|
||||||
|
v_egg_add := floor(random() * 31) + 10;
|
||||||
|
|
||||||
|
INSERT INTO bot.eggs (id_user, id_action, amount)
|
||||||
|
VALUES (p_id_user, 1, v_egg_add);
|
||||||
|
|
||||||
|
v_result := 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT v_result, v_egg_add, v_h, v_m, v_s;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
-- Магазин: списки и покупки.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION bot.get_titles_list()
|
||||||
|
RETURNS TABLE(i integer, n character varying, p bigint)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT id_title, title, price
|
||||||
|
FROM bot.titles
|
||||||
|
WHERE visible = true
|
||||||
|
ORDER BY price DESC;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION bot.get_donats_list()
|
||||||
|
RETURNS TABLE(i integer, n character varying, p bigint)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT id_donat, title, price
|
||||||
|
FROM bot.donats
|
||||||
|
WHERE visible = true
|
||||||
|
ORDER BY price DESC;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION bot.buy_title(p_id_user bigint, p_id_title integer)
|
||||||
|
RETURNS integer
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_money bigint;
|
||||||
|
v_price bigint;
|
||||||
|
BEGIN
|
||||||
|
SELECT sum(amount) INTO v_money FROM bot.money WHERE id_user = p_id_user;
|
||||||
|
SELECT price INTO v_price FROM bot.titles WHERE id_title = p_id_title;
|
||||||
|
|
||||||
|
IF v_money IS NULL OR v_money < v_price THEN
|
||||||
|
RETURN -1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
CALL bot.money_action(p_id_user, -1 * v_price, 2);
|
||||||
|
UPDATE bot.de_users SET id_title = p_id_title WHERE id_user = p_id_user;
|
||||||
|
RETURN 1;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION bot.buy_donat(p_id_user bigint, p_id_donat integer)
|
||||||
|
RETURNS character varying
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_money bigint;
|
||||||
|
v_price bigint;
|
||||||
|
v_promo varchar;
|
||||||
|
BEGIN
|
||||||
|
SELECT sum(amount) INTO v_money FROM bot.money WHERE id_user = p_id_user;
|
||||||
|
SELECT price INTO v_price FROM bot.donats WHERE id_donat = p_id_donat;
|
||||||
|
|
||||||
|
IF v_money IS NULL OR v_money < v_price THEN
|
||||||
|
RETURN '-1';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
CALL bot.money_action(p_id_user, -1 * v_price, 2);
|
||||||
|
v_promo := md5(random()::text);
|
||||||
|
|
||||||
|
INSERT INTO bot.user_donats (id_user, id_donat, promo, stat)
|
||||||
|
VALUES (p_id_user, p_id_donat, v_promo, false);
|
||||||
|
|
||||||
|
RETURN v_promo;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
-- Статистика: топ по количеству сообщений.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION bot.get_top_list()
|
||||||
|
RETURNS TABLE(u character varying, n character varying, c bigint)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT tu.username, du.nick, count(*) AS cnt
|
||||||
|
FROM bot.messages m
|
||||||
|
INNER JOIN bot.tg_users tu ON m.id_user = tu.id_user
|
||||||
|
INNER JOIN bot.de_users du ON m.id_user = du.id_user
|
||||||
|
GROUP BY tu.username, du.nick
|
||||||
|
ORDER BY 3 DESC
|
||||||
|
LIMIT 10;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
-- Подписки на рассылки.
|
||||||
|
|
||||||
|
-- Подписать: убрать из списка отписавшихся.
|
||||||
|
CREATE OR REPLACE PROCEDURE bot.subscribe(IN p_id_user bigint)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM bot.unsubscribe WHERE id_user = p_id_user;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Отписать: добавить в список отписавшихся (без дублей).
|
||||||
|
CREATE OR REPLACE PROCEDURE bot.unsubscribe(IN p_id_user bigint)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO bot.unsubscribe (id_user)
|
||||||
|
VALUES (p_id_user)
|
||||||
|
ON CONFLICT (id_user) DO NOTHING;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
-- Пользователи: регистрация, ник, выборки, удаление.
|
||||||
|
|
||||||
|
-- Список всех игроков.
|
||||||
|
CREATE OR REPLACE FUNCTION bot.get_nick_list()
|
||||||
|
RETURNS TABLE(username character varying, nick character varying)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT tg.username, de.nick
|
||||||
|
FROM bot.tg_users tg
|
||||||
|
LEFT JOIN bot.de_users de ON tg.id_user = de.id_user
|
||||||
|
ORDER BY de.nick;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Список подписанных на рассылки.
|
||||||
|
CREATE OR REPLACE FUNCTION bot.get_nick_list_subscribe()
|
||||||
|
RETURNS TABLE(username character varying, nick character varying)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT tg.username, de.nick
|
||||||
|
FROM bot.tg_users tg
|
||||||
|
LEFT JOIN bot.de_users de ON tg.id_user = de.id_user
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM bot.unsubscribe u WHERE u.id_user = tg.id_user)
|
||||||
|
ORDER BY de.nick;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Ник по id.
|
||||||
|
CREATE OR REPLACE FUNCTION bot.get_nick_by_id(p_id_user bigint)
|
||||||
|
RETURNS TABLE(nick character varying)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY SELECT de.nick FROM bot.de_users de WHERE de.id_user = p_id_user;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Сводная информация о пользователе.
|
||||||
|
CREATE OR REPLACE FUNCTION bot.get_info_by_id(p_id_user bigint)
|
||||||
|
RETURNS TABLE(
|
||||||
|
username character varying,
|
||||||
|
nick character varying,
|
||||||
|
title character varying,
|
||||||
|
lvl bigint,
|
||||||
|
expr bigint,
|
||||||
|
mny bigint,
|
||||||
|
dt text,
|
||||||
|
msg_count bigint,
|
||||||
|
eggs_count bigint
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_money bigint;
|
||||||
|
v_eggs bigint;
|
||||||
|
BEGIN
|
||||||
|
v_money := coalesce(bot.get_money(p_id_user), 0);
|
||||||
|
v_eggs := coalesce(bot.get_eggs(p_id_user), 0);
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT tg.username,
|
||||||
|
coalesce(de.nick, ''),
|
||||||
|
coalesce(t.title, ''),
|
||||||
|
de.lvl,
|
||||||
|
de.expr,
|
||||||
|
v_money AS mny,
|
||||||
|
to_char(de.dt_create, 'dd.mm.yyyy'),
|
||||||
|
coalesce(m.msg_count, 0),
|
||||||
|
v_eggs AS eggs_count
|
||||||
|
FROM bot.tg_users tg
|
||||||
|
LEFT JOIN bot.de_users de ON tg.id_user = de.id_user
|
||||||
|
LEFT JOIN bot.titles t ON de.id_title = t.id_title
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT id_user, count(*) AS msg_count
|
||||||
|
FROM bot.messages
|
||||||
|
GROUP BY id_user
|
||||||
|
) m ON tg.id_user = m.id_user
|
||||||
|
WHERE tg.id_user = p_id_user;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Создать/обновить пользователя и записать сообщение.
|
||||||
|
CREATE OR REPLACE PROCEDURE bot.add_user(
|
||||||
|
IN p_id_user bigint,
|
||||||
|
IN p_username character varying,
|
||||||
|
IN p_first_name character varying,
|
||||||
|
IN p_last_name character varying
|
||||||
|
) LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO bot.tg_users (id_user, username, first_name, last_name)
|
||||||
|
VALUES (p_id_user, p_username, p_first_name, p_last_name)
|
||||||
|
ON CONFLICT (id_user) DO UPDATE
|
||||||
|
SET username = EXCLUDED.username,
|
||||||
|
first_name = EXCLUDED.first_name,
|
||||||
|
last_name = EXCLUDED.last_name;
|
||||||
|
|
||||||
|
INSERT INTO bot.de_users (id_user, nick)
|
||||||
|
VALUES (p_id_user, NULL)
|
||||||
|
ON CONFLICT (id_user) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO bot.messages (id_user) VALUES (p_id_user);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Установить ник.
|
||||||
|
CREATE OR REPLACE PROCEDURE bot.add_nick(IN p_id_user bigint, IN p_nick character varying)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO bot.de_users (id_user, nick)
|
||||||
|
VALUES (p_id_user, p_nick)
|
||||||
|
ON CONFLICT (id_user) DO UPDATE SET nick = EXCLUDED.nick;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Удалить пользователя и обнулить его баланс.
|
||||||
|
CREATE OR REPLACE PROCEDURE bot.del_user_by_id(IN p_id_user bigint)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_money bigint;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM bot.tg_users WHERE id_user = p_id_user;
|
||||||
|
DELETE FROM bot.de_users WHERE id_user = p_id_user;
|
||||||
|
DELETE FROM bot.messages WHERE id_user = p_id_user;
|
||||||
|
DELETE FROM bot.purifications WHERE id_user = p_id_user;
|
||||||
|
|
||||||
|
v_money := bot.get_money(p_id_user);
|
||||||
|
IF v_money IS NOT NULL AND v_money <> 0 THEN
|
||||||
|
INSERT INTO bot.money (id_user, amount, id_action)
|
||||||
|
VALUES (p_id_user, -1 * v_money, 5);
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- Журнал ежедневных очищений.
|
||||||
|
CREATE TABLE IF NOT EXISTS bot.purifications
|
||||||
|
(
|
||||||
|
id_user bigint,
|
||||||
|
dt timestamp DEFAULT now()
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- Отписавшиеся от рассылок.
|
||||||
|
CREATE TABLE IF NOT EXISTS bot.unsubscribe
|
||||||
|
(
|
||||||
|
id_user bigint
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
-- Ключи и индексы.
|
||||||
|
-- Схема исторически велась без ключей, поэтому перед добавлением PK убираем
|
||||||
|
-- возможные дубли (важно при загрузке данных из старой БД).
|
||||||
|
|
||||||
|
-- tg_users
|
||||||
|
DELETE FROM bot.tg_users a USING bot.tg_users b
|
||||||
|
WHERE a.ctid < b.ctid AND a.id_user = b.id_user;
|
||||||
|
ALTER TABLE bot.tg_users ALTER COLUMN id_user SET NOT NULL;
|
||||||
|
ALTER TABLE bot.tg_users ADD CONSTRAINT tg_users_pkey PRIMARY KEY (id_user);
|
||||||
|
|
||||||
|
-- de_users
|
||||||
|
DELETE FROM bot.de_users a USING bot.de_users b
|
||||||
|
WHERE a.ctid < b.ctid AND a.id_user = b.id_user;
|
||||||
|
ALTER TABLE bot.de_users ALTER COLUMN id_user SET NOT NULL;
|
||||||
|
ALTER TABLE bot.de_users ADD CONSTRAINT de_users_pkey PRIMARY KEY (id_user);
|
||||||
|
|
||||||
|
-- unsubscribe
|
||||||
|
DELETE FROM bot.unsubscribe a USING bot.unsubscribe b
|
||||||
|
WHERE a.ctid < b.ctid AND a.id_user = b.id_user;
|
||||||
|
ALTER TABLE bot.unsubscribe ALTER COLUMN id_user SET NOT NULL;
|
||||||
|
ALTER TABLE bot.unsubscribe ADD CONSTRAINT unsubscribe_pkey PRIMARY KEY (id_user);
|
||||||
|
|
||||||
|
-- Суррогатные ключи
|
||||||
|
ALTER TABLE bot.actions ADD CONSTRAINT actions_pkey PRIMARY KEY (id_action);
|
||||||
|
ALTER TABLE bot.titles ADD CONSTRAINT titles_pkey PRIMARY KEY (id_title);
|
||||||
|
ALTER TABLE bot.donats ADD CONSTRAINT donats_pkey PRIMARY KEY (id_donat);
|
||||||
|
ALTER TABLE bot.money ADD CONSTRAINT money_pkey PRIMARY KEY (id_money);
|
||||||
|
ALTER TABLE bot.eggs ADD CONSTRAINT eggs_pkey PRIMARY KEY (id_egg);
|
||||||
|
|
||||||
|
-- Индексы
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_money_user ON bot.money (id_user);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_eggs_user_action ON bot.eggs (id_user, id_action);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_user ON bot.messages (id_user);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_dt ON bot.messages (dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purifications_user ON bot.purifications (id_user);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_de_users_nick ON bot.de_users (nick);
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- Справочник действий (id используется в money.id_action и eggs.id_action).
|
||||||
|
INSERT INTO bot.actions (id_action, title)
|
||||||
|
VALUES (1, 'Бонус'),
|
||||||
|
(2, 'Покупка'),
|
||||||
|
(3, 'Резерв'),
|
||||||
|
(4, 'Очищение'),
|
||||||
|
(5, 'Удаление пользователя')
|
||||||
|
ON CONFLICT (id_action) DO NOTHING;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- Схема bot: все объекты приложения.
|
||||||
|
CREATE SCHEMA IF NOT EXISTS bot;
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- Telegram-профили пользователей.
|
||||||
|
CREATE TABLE IF NOT EXISTS bot.tg_users
|
||||||
|
(
|
||||||
|
id_user bigint,
|
||||||
|
username varchar,
|
||||||
|
first_name varchar,
|
||||||
|
last_name varchar
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- Игровые профили (ник, опыт, уровень, звание).
|
||||||
|
CREATE TABLE IF NOT EXISTS bot.de_users
|
||||||
|
(
|
||||||
|
id_user bigint,
|
||||||
|
nick varchar,
|
||||||
|
expr bigint DEFAULT 0,
|
||||||
|
expr_streak bigint DEFAULT 0,
|
||||||
|
mny bigint DEFAULT 0,
|
||||||
|
id_title bigint DEFAULT 12,
|
||||||
|
dt_create timestamp DEFAULT now(),
|
||||||
|
lvl bigint DEFAULT 1
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- Справочник званий магазина.
|
||||||
|
CREATE TABLE IF NOT EXISTS bot.titles
|
||||||
|
(
|
||||||
|
id_title serial,
|
||||||
|
title varchar,
|
||||||
|
price bigint,
|
||||||
|
visible boolean
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- Справочник донатов магазина.
|
||||||
|
CREATE TABLE IF NOT EXISTS bot.donats
|
||||||
|
(
|
||||||
|
id_donat serial,
|
||||||
|
title varchar,
|
||||||
|
price bigint,
|
||||||
|
visible boolean
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- Купленные донаты (с промокодом выдачи).
|
||||||
|
CREATE TABLE IF NOT EXISTS bot.user_donats
|
||||||
|
(
|
||||||
|
id_user bigint,
|
||||||
|
id_donat bigint,
|
||||||
|
promo varchar,
|
||||||
|
stat boolean
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
-- Справочник действий и журнал движения денег (баланс = SUM(amount)).
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS bot.actions_id_action MAXVALUE 2147483647;
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS bot.money_id_money MAXVALUE 2147483647;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bot.actions
|
||||||
|
(
|
||||||
|
id_action integer DEFAULT nextval('bot.actions_id_action') NOT NULL,
|
||||||
|
title varchar
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bot.money
|
||||||
|
(
|
||||||
|
id_money integer DEFAULT nextval('bot.money_id_money') NOT NULL,
|
||||||
|
id_user bigint,
|
||||||
|
amount integer,
|
||||||
|
id_action integer,
|
||||||
|
dt_action timestamp DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- Журнал выдачи яичек (сезонное событие).
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS bot.eggs_id_egg MAXVALUE 2147483647;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bot.eggs
|
||||||
|
(
|
||||||
|
id_egg integer DEFAULT nextval('bot.eggs_id_egg') NOT NULL,
|
||||||
|
id_user bigint,
|
||||||
|
amount integer,
|
||||||
|
id_action integer,
|
||||||
|
dt_action timestamp DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- Журнал сообщений: количество строк = msg_count пользователя.
|
||||||
|
CREATE TABLE IF NOT EXISTS bot.messages
|
||||||
|
(
|
||||||
|
id_user bigint,
|
||||||
|
dt timestamp DEFAULT now()
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Запуск бота. Все параметры — из переменных окружения / .env (APP_ENV=dev|prod).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
exec python -m bot
|
||||||
|
|
@ -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": "<b>bad</b>"})
|
||||||
|
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"
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
"""Проверка версии проекта."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from common import __version__
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_is_set():
|
||||||
|
assert __version__
|
||||||
|
assert __version__ == "0.1"
|
||||||
Loading…
Reference in New Issue