Первоначальная версия

This commit is contained in:
MpakobecEx 2026-10-15 16:29:21 +05:00
commit 46cc5bbb5e
81 changed files with 2873 additions and 0 deletions

31
.dockerignore Normal file
View File

@ -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

31
.env.example Normal file
View File

@ -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

6
.gitattributes vendored Normal file
View File

@ -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

55
.gitea/workflows/ci.yml Normal file
View File

@ -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

37
.gitignore vendored Normal file
View File

@ -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/

28
Dockerfile Normal file
View File

@ -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"]

112
README.md Normal file
View File

@ -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`.

1
bot/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Сервис Telegram-бота."""

46
bot/__main__.py Normal file
View File

@ -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("Бот остановлен")

37
bot/bot_factory.py Normal file
View File

@ -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

1
bot/handlers/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Роутеры (хендлеры) бота."""

138
bot/handlers/commands.py Normal file
View File

@ -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)

70
bot/handlers/economy.py Normal file
View File

@ -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)

23
bot/handlers/errors.py Normal file
View File

@ -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

View File

@ -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("Не удалось отправить уведомление о выходе пользователя")

81
bot/handlers/shop.py Normal file
View File

@ -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)

View File

@ -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"]

51
bot/keyboards/shop.py Normal file
View File

@ -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)

1
bot/services/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Бизнес-логика бота."""

80
bot/services/access.py Normal file
View File

@ -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("Не удалось отправить уведомление о запрете доступа")

43
bot/services/economy.py Normal file
View File

@ -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)

28
bot/services/heartbeat.py Normal file
View File

@ -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)

View File

@ -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)

35
bot/services/shop.py Normal file
View File

@ -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

14
bot/services/stats.py Normal file
View File

@ -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
]

View File

@ -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)

67
bot/services/users.py Normal file
View File

@ -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),
)

5
common/__init__.py Normal file
View File

@ -0,0 +1,5 @@
"""Общий пакет, используемый сервисами bot и admin."""
from common.version import __version__
__all__ = ["__version__"]

91
common/config.py Normal file
View File

@ -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]

1
common/db/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Слой подключения к базе данных."""

64
common/db/pool.py Normal file
View File

@ -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

22
common/logging_config.py Normal file
View File

@ -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)

16
common/models/__init__.py Normal file
View File

@ -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",
]

28
common/models/economy.py Normal file
View File

@ -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

23
common/models/shop.py Normal file
View File

@ -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

14
common/models/stats.py Normal file
View File

@ -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

41
common/models/users.py Normal file
View File

@ -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

View File

@ -0,0 +1 @@
"""Репозитории — доступ к данным. Возвращают сырые dict/скаляры."""

View File

@ -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()

View File

@ -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"

View File

@ -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()

View File

@ -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,))

View File

@ -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,))

62
common/texts/__init__.py Normal file
View File

@ -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",
]

20
common/texts/economy.py Normal file
View File

@ -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}с"""

26
common/texts/info.py Normal file
View File

@ -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>
"""

View File

@ -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}"""

123
common/texts/render.py Normal file
View File

@ -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)

7
common/texts/shop.py Normal file
View File

@ -0,0 +1,7 @@
"""Тексты магазина."""
MSG_SHOP_MAIN = "Разделы магазина"
MSG_SHOP_TITLE = "Доступные звания для покупки"
MSG_SHOP_DONAT = "Доступные донаты для покупки"

31
common/texts/system.py Normal file
View File

@ -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 = "Создатель запретил ходить в другие чатики :с"

38
common/texts/welcome.py Normal file
View File

@ -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"

10
common/version.py Normal file
View File

@ -0,0 +1,10 @@
"""Версия проекта.
Единый источник правды. Git-тег совпадает с версией с префиксом ``v``
(например, версия ``0.1`` -> тег ``v0.1``). Docker-образы в Блоке 2 будут
тегироваться этой версией, а события в ClickHouse получат поле ``bot_version``.
"""
from __future__ import annotations
__version__ = "0.1"

78
docker-compose.prod.yml Normal file
View File

@ -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:

66
docker-compose.yml Normal file
View File

@ -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:

26
env/prod.env.example vendored Normal file
View File

@ -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

24
env/test.env.example vendored Normal file
View File

@ -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

BIN
images/welcome_image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

15
pyproject.toml Normal file
View File

@ -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"]

7
requirements-dev.txt Normal file
View File

@ -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

13
requirements.txt Normal file
View File

@ -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

View File

@ -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;
$$;

74
sql/R__functions_shop.sql Normal file
View File

@ -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;
$$;

View File

@ -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;
$$;

View File

@ -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;
$$;

139
sql/R__functions_users.sql Normal file
View File

@ -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;
$$;

View File

@ -0,0 +1,6 @@
-- Журнал ежедневных очищений.
CREATE TABLE IF NOT EXISTS bot.purifications
(
id_user bigint,
dt timestamp DEFAULT now()
);

View File

@ -0,0 +1,5 @@
-- Отписавшиеся от рассылок.
CREATE TABLE IF NOT EXISTS bot.unsubscribe
(
id_user bigint
);

View File

@ -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);

View File

@ -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;

View File

@ -0,0 +1,2 @@
-- Схема bot: все объекты приложения.
CREATE SCHEMA IF NOT EXISTS bot;

View File

@ -0,0 +1,8 @@
-- Telegram-профили пользователей.
CREATE TABLE IF NOT EXISTS bot.tg_users
(
id_user bigint,
username varchar,
first_name varchar,
last_name varchar
);

View File

@ -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
);

8
sql/V4__table_titles.sql Normal file
View File

@ -0,0 +1,8 @@
-- Справочник званий магазина.
CREATE TABLE IF NOT EXISTS bot.titles
(
id_title serial,
title varchar,
price bigint,
visible boolean
);

8
sql/V5__table_donats.sql Normal file
View File

@ -0,0 +1,8 @@
-- Справочник донатов магазина.
CREATE TABLE IF NOT EXISTS bot.donats
(
id_donat serial,
title varchar,
price bigint,
visible boolean
);

View File

@ -0,0 +1,8 @@
-- Купленные донаты (с промокодом выдачи).
CREATE TABLE IF NOT EXISTS bot.user_donats
(
id_user bigint,
id_donat bigint,
promo varchar,
stat boolean
);

View File

@ -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
);

11
sql/V8__table_eggs.sql Normal file
View File

@ -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
);

View File

@ -0,0 +1,6 @@
-- Журнал сообщений: количество строк = msg_count пользователя.
CREATE TABLE IF NOT EXISTS bot.messages
(
id_user bigint,
dt timestamp DEFAULT now()
);

6
start.sh Normal file
View File

@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Запуск бота. Все параметры — из переменных окружения / .env (APP_ENV=dev|prod).
set -euo pipefail
cd "$(dirname "$0")"
exec python -m bot

53
tests/test_render.py Normal file
View File

@ -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 == "Привет, &lt;b&gt;bad&lt;/b&gt;!"
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&amp;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"

10
tests/test_version.py Normal file
View File

@ -0,0 +1,10 @@
"""Проверка версии проекта."""
from __future__ import annotations
from common import __version__
def test_version_is_set():
assert __version__
assert __version__ == "0.1"