commit 10b48c77a7881b4886f1531d8cb195d2ff5c174d Author: MpakobecEx Date: Thu Oct 15 16:29:21 2026 +0500 Первоначальная версия diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7240f8e --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..287c37b --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4a36f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ + +# Virtualenv +.venv/ +.venv11/ +venv/ + +# Secrets / environment +.env +.env.* +!.env.example + +# IDE +.idea/ +.vscode/ + +# Logs / runtime +*.log +*.pid + +# OS +.DS_Store +Thumbs.db + +# Локальные материалы (в репозиторий не попадают) +AGENTS.md +checklist.md +sql/README.md +deploy/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6692159 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# 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/ # статичные картинки (приветствие) +``` + +## Требования + +- 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). + +## Разработка + +```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`. diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..6091edd --- /dev/null +++ b/bot/__init__.py @@ -0,0 +1 @@ +"""Сервис Telegram-бота.""" diff --git a/bot/__main__.py b/bot/__main__.py new file mode 100644 index 0000000..72f5bf6 --- /dev/null +++ b/bot/__main__.py @@ -0,0 +1,43 @@ +"""Точка входа бота: python -m bot""" + +from __future__ import annotations + +import asyncio +import logging + +from bot.bot_factory import create_bot, create_dispatcher +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)) + try: + await bot.delete_webhook(drop_pending_updates=True) + await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()) + finally: + notifications_task.cancel() + await asyncio.gather(notifications_task, return_exceptions=True) + await close_pool() + await bot.session.close() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except (KeyboardInterrupt, SystemExit): + logger.info("Бот остановлен") diff --git a/bot/bot_factory.py b/bot/bot_factory.py new file mode 100644 index 0000000..01bdd0c --- /dev/null +++ b/bot/bot_factory.py @@ -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 diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py new file mode 100644 index 0000000..260898b --- /dev/null +++ b/bot/handlers/__init__.py @@ -0,0 +1 @@ +"""Роутеры (хендлеры) бота.""" diff --git a/bot/handlers/commands.py b/bot/handlers/commands.py new file mode 100644 index 0000000..70c929b --- /dev/null +++ b/bot/handlers/commands.py @@ -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) diff --git a/bot/handlers/economy.py b/bot/handlers/economy.py new file mode 100644 index 0000000..1c1d394 --- /dev/null +++ b/bot/handlers/economy.py @@ -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) diff --git a/bot/handlers/errors.py b/bot/handlers/errors.py new file mode 100644 index 0000000..a7c1995 --- /dev/null +++ b/bot/handlers/errors.py @@ -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 diff --git a/bot/handlers/member_events.py b/bot/handlers/member_events.py new file mode 100644 index 0000000..d198d84 --- /dev/null +++ b/bot/handlers/member_events.py @@ -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("Не удалось отправить уведомление о выходе пользователя") diff --git a/bot/handlers/shop.py b/bot/handlers/shop.py new file mode 100644 index 0000000..3de67de --- /dev/null +++ b/bot/handlers/shop.py @@ -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) diff --git a/bot/keyboards/__init__.py b/bot/keyboards/__init__.py new file mode 100644 index 0000000..98791e9 --- /dev/null +++ b/bot/keyboards/__init__.py @@ -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"] diff --git a/bot/keyboards/shop.py b/bot/keyboards/shop.py new file mode 100644 index 0000000..1fc2422 --- /dev/null +++ b/bot/keyboards/shop.py @@ -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) diff --git a/bot/services/__init__.py b/bot/services/__init__.py new file mode 100644 index 0000000..4fb60c1 --- /dev/null +++ b/bot/services/__init__.py @@ -0,0 +1 @@ +"""Бизнес-логика бота.""" diff --git a/bot/services/access.py b/bot/services/access.py new file mode 100644 index 0000000..847ac17 --- /dev/null +++ b/bot/services/access.py @@ -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("Не удалось отправить уведомление о запрете доступа") diff --git a/bot/services/economy.py b/bot/services/economy.py new file mode 100644 index 0000000..0140067 --- /dev/null +++ b/bot/services/economy.py @@ -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) diff --git a/bot/services/notifications.py b/bot/services/notifications.py new file mode 100644 index 0000000..d303f10 --- /dev/null +++ b/bot/services/notifications.py @@ -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) diff --git a/bot/services/shop.py b/bot/services/shop.py new file mode 100644 index 0000000..f61ba27 --- /dev/null +++ b/bot/services/shop.py @@ -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 diff --git a/bot/services/stats.py b/bot/services/stats.py new file mode 100644 index 0000000..d4731ca --- /dev/null +++ b/bot/services/stats.py @@ -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 + ] diff --git a/bot/services/subscriptions.py b/bot/services/subscriptions.py new file mode 100644 index 0000000..01da525 --- /dev/null +++ b/bot/services/subscriptions.py @@ -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) diff --git a/bot/services/users.py b/bot/services/users.py new file mode 100644 index 0000000..d62b630 --- /dev/null +++ b/bot/services/users.py @@ -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), + ) diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 0000000..72ea174 --- /dev/null +++ b/common/__init__.py @@ -0,0 +1,5 @@ +"""Общий пакет, используемый сервисами bot и admin.""" + +from common.version import __version__ + +__all__ = ["__version__"] diff --git a/common/config.py b/common/config.py new file mode 100644 index 0000000..ccce0a0 --- /dev/null +++ b/common/config.py @@ -0,0 +1,88 @@ +"""Конфигурация приложения. + +Значения читаются из переменных окружения / файла .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" + + @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] diff --git a/common/db/__init__.py b/common/db/__init__.py new file mode 100644 index 0000000..4f7653d --- /dev/null +++ b/common/db/__init__.py @@ -0,0 +1 @@ +"""Слой подключения к базе данных.""" diff --git a/common/db/pool.py b/common/db/pool.py new file mode 100644 index 0000000..b015463 --- /dev/null +++ b/common/db/pool.py @@ -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 diff --git a/common/logging_config.py b/common/logging_config.py new file mode 100644 index 0000000..063b4a5 --- /dev/null +++ b/common/logging_config.py @@ -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) diff --git a/common/models/__init__.py b/common/models/__init__.py new file mode 100644 index 0000000..ff82e90 --- /dev/null +++ b/common/models/__init__.py @@ -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", +] diff --git a/common/models/economy.py b/common/models/economy.py new file mode 100644 index 0000000..fa5a633 --- /dev/null +++ b/common/models/economy.py @@ -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 diff --git a/common/models/shop.py b/common/models/shop.py new file mode 100644 index 0000000..280dfd3 --- /dev/null +++ b/common/models/shop.py @@ -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 diff --git a/common/models/stats.py b/common/models/stats.py new file mode 100644 index 0000000..31e942f --- /dev/null +++ b/common/models/stats.py @@ -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 diff --git a/common/models/users.py b/common/models/users.py new file mode 100644 index 0000000..ba2702b --- /dev/null +++ b/common/models/users.py @@ -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 diff --git a/common/repositories/__init__.py b/common/repositories/__init__.py new file mode 100644 index 0000000..1c2f072 --- /dev/null +++ b/common/repositories/__init__.py @@ -0,0 +1 @@ +"""Репозитории — доступ к данным. Возвращают сырые dict/скаляры.""" diff --git a/common/repositories/economy_repo.py b/common/repositories/economy_repo.py new file mode 100644 index 0000000..d9a7457 --- /dev/null +++ b/common/repositories/economy_repo.py @@ -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() diff --git a/common/repositories/shop_repo.py b/common/repositories/shop_repo.py new file mode 100644 index 0000000..7348409 --- /dev/null +++ b/common/repositories/shop_repo.py @@ -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" diff --git a/common/repositories/stats_repo.py b/common/repositories/stats_repo.py new file mode 100644 index 0000000..e736c97 --- /dev/null +++ b/common/repositories/stats_repo.py @@ -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() diff --git a/common/repositories/subscriptions_repo.py b/common/repositories/subscriptions_repo.py new file mode 100644 index 0000000..2ba29b2 --- /dev/null +++ b/common/repositories/subscriptions_repo.py @@ -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,)) diff --git a/common/repositories/users_repo.py b/common/repositories/users_repo.py new file mode 100644 index 0000000..e33ec50 --- /dev/null +++ b/common/repositories/users_repo.py @@ -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,)) diff --git a/common/texts/__init__.py b/common/texts/__init__.py new file mode 100644 index 0000000..ce05405 --- /dev/null +++ b/common/texts/__init__.py @@ -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", +] diff --git a/common/texts/economy.py b/common/texts/economy.py new file mode 100644 index 0000000..60559c3 --- /dev/null +++ b/common/texts/economy.py @@ -0,0 +1,20 @@ +"""Тексты экономики: списки, рейтинг, очищение, яички.""" + +MSG_NICK_LIST = """📑 Список игроков[{users_list_count}] 📑 + +{users_list}""" + +MSG_TOP = """⭐️ТОП10 общительных участников⭐️: +{tops}""" + +MSG_PURIFICATION = """💠Успешное очищение!💠 +Получено: 💰{money_add} DE и 🥪{exp_add} exp!""" + +MSG_NO_PURIFICATION = """Очищение возможно не чаще раза в сутки! +Возвращайся через {h}ч {m}м {s}с""" + +MSG_EGGS = """💠Держи свои яички!💠 +Получено {eggs}🥚!""" + +MSG_NO_EGGS = """Получать яички возможно не чаще раза в сутки! +Возвращайся через {h}ч {m}м {s}с""" diff --git a/common/texts/info.py b/common/texts/info.py new file mode 100644 index 0000000..7c82653 --- /dev/null +++ b/common/texts/info.py @@ -0,0 +1,26 @@ +"""Информационные тексты (профиль, помощь).""" + +MSG_INFO = """👤Информация о пользователе {username} + +✅ Ник в игре: {nick} +🏆 Звание: {title} +🧩 Уровень: {lvl} +🥪 Опыт: {exp}/{exp_max} +💰 Деньги: {money} +📈 Начало активности в боте: {dt} +📊 Количество сообщений в чате: {msg_count} +🥚 Количество яичек: {eggs} +""" + +MSG_HELP = """Список команд бота + +/ник НИК - указать свой ник в игре [чат, бот] +/список - вывести список ников [бот] +/кто - вывод ника человека в ответ на сообщение [чат] +/инфо - вывод информации о себе [чат, бот] +/очищение - получение бонусов раз в сутки [чат, бот] +/магазин - магазин бота [бот] +/рейтинг - топ-10 игроков по сообщениям [чат, бот] +/отписка - отписаться от рассылок [чат, бот] +/подписка - подписаться на рассылки [чат, бот] +""" diff --git a/common/texts/notifications.py b/common/texts/notifications.py new file mode 100644 index 0000000..788e84b --- /dev/null +++ b/common/texts/notifications.py @@ -0,0 +1,16 @@ +"""Тексты рассылок (благо, вклад).""" + +MSG_BLAGO_30 = """ ⏰ Просыпаемся, 30 минут до блага! ⏰ +{usernames}""" + +MSG_BLAGO_45 = """ 🔔 15 минут до блага, ждём всех на базе! 🔔 +{usernames}""" + +MSG_BLAGO_55 = """ ☀️ 5 МИНУТ ДО БЛАГА! НАРЯЖАЕМ ПОПКИ! ☀️ +{usernames}""" + +MSG_BLAGO = """💃 АКТИВНО ТРЯСЁМ ПОПКАМИ НА БАЗЕ!🕺 +{usernames}""" + +MSG_VKLAD = """💰 НЕ ЗАБЫВАЕМ ПРОВЕРИТЬ ВКЛАД 💰 +{usernames}""" diff --git a/common/texts/render.py b/common/texts/render.py new file mode 100644 index 0000000..f580473 --- /dev/null +++ b/common/texts/render.py @@ -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) diff --git a/common/texts/shop.py b/common/texts/shop.py new file mode 100644 index 0000000..0328881 --- /dev/null +++ b/common/texts/shop.py @@ -0,0 +1,7 @@ +"""Тексты магазина.""" + +MSG_SHOP_MAIN = "Разделы магазина" + +MSG_SHOP_TITLE = "Доступные звания для покупки" + +MSG_SHOP_DONAT = "Доступные донаты для покупки" diff --git a/common/texts/system.py b/common/texts/system.py new file mode 100644 index 0000000..35c5633 --- /dev/null +++ b/common/texts/system.py @@ -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 = "Создатель запретил ходить в другие чатики :с" diff --git a/common/texts/welcome.py b/common/texts/welcome.py new file mode 100644 index 0000000..eb4ef1e --- /dev/null +++ b/common/texts/welcome.py @@ -0,0 +1,38 @@ +"""Тексты приветствия.""" + +MSG_WELCOME_TEXT = """💠{username}, добро пожаловать в Dispel Evil! +❗️Правила клана + +Незнание не освобождает от ответственности. + +1. Конфликты/разборки: сами решаете между собой в лс. Админ имеет право дать мут участникам конфликта до его решения, потом пишите в лс, что решили вопрос и мут снимут. Если замечены одни и те же люди в конфликте три раза - вас исключат из клана. + +2. Слушать администрацию: если кто-то из админов просит закрыть тему - вы её закрываете, если вам делают замечание - не агрессировать и отвечать спокойно. Со стороны админов не будет претензий на пустом месте. + +3. Маты в меру. Если используйте маты в игре - на вашей совести, по правилам игры они запрещены, за последствия отвечать будете сами. """ + +MSG_WELCOME_TEXT2 = """4. Сомнительные темы: не рекомендуем заводить разговоры про политику/национальность/религию и другие спорные темы создающие конфликты. В противном случае вам могут выдать мут. + +5. Места на благо - становитесь на любое свободное место, не выделяйте «своё личное место». В съёмочный день оставьте 3е место для админа. + +Уважительная просьба: если вам не приятен человек, решайте вопросы в лс. Пожалуйста, не выливайте это в общие чаты, чтобы сохранить приятную атмосферу для остальных. + +💠Ниже кнопка на наш основной канал со всеми новостями, конкурсами и темы для блага.""" + +MSG_WELCOME_TEXT3 = """❗️По игре: + +💠Очки командного боя 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" diff --git a/common/version.py b/common/version.py new file mode 100644 index 0000000..ef2b931 --- /dev/null +++ b/common/version.py @@ -0,0 +1,10 @@ +"""Версия проекта. + +Единый источник правды. Git-тег совпадает с версией с префиксом ``v`` +(например, версия ``0.1`` -> тег ``v0.1``). Docker-образы в Блоке 2 будут +тегироваться этой версией, а события в ClickHouse получат поле ``bot_version``. +""" + +from __future__ import annotations + +__version__ = "0.1" diff --git a/images/welcome_image.jpg b/images/welcome_image.jpg new file mode 100644 index 0000000..f005479 Binary files /dev/null and b/images/welcome_image.jpg differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7d8d683 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..90cb7fa --- /dev/null +++ b/requirements-dev.txt @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8948810 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/sql/R__functions_economy.sql b/sql/R__functions_economy.sql new file mode 100644 index 0000000..df4cc1b --- /dev/null +++ b/sql/R__functions_economy.sql @@ -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; +$$; diff --git a/sql/R__functions_shop.sql b/sql/R__functions_shop.sql new file mode 100644 index 0000000..bc2d6e1 --- /dev/null +++ b/sql/R__functions_shop.sql @@ -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; +$$; diff --git a/sql/R__functions_stats.sql b/sql/R__functions_stats.sql new file mode 100644 index 0000000..7246c5f --- /dev/null +++ b/sql/R__functions_stats.sql @@ -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; +$$; diff --git a/sql/R__functions_subscriptions.sql b/sql/R__functions_subscriptions.sql new file mode 100644 index 0000000..b3af747 --- /dev/null +++ b/sql/R__functions_subscriptions.sql @@ -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; +$$; diff --git a/sql/R__functions_users.sql b/sql/R__functions_users.sql new file mode 100644 index 0000000..13d309f --- /dev/null +++ b/sql/R__functions_users.sql @@ -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; +$$; diff --git a/sql/V10__table_purifications.sql b/sql/V10__table_purifications.sql new file mode 100644 index 0000000..b40c7d6 --- /dev/null +++ b/sql/V10__table_purifications.sql @@ -0,0 +1,6 @@ +-- Журнал ежедневных очищений. +CREATE TABLE IF NOT EXISTS bot.purifications +( + id_user bigint, + dt timestamp DEFAULT now() +); diff --git a/sql/V11__table_unsubscribe.sql b/sql/V11__table_unsubscribe.sql new file mode 100644 index 0000000..aa4732c --- /dev/null +++ b/sql/V11__table_unsubscribe.sql @@ -0,0 +1,5 @@ +-- Отписавшиеся от рассылок. +CREATE TABLE IF NOT EXISTS bot.unsubscribe +( + id_user bigint +); diff --git a/sql/V12__constraints_indexes.sql b/sql/V12__constraints_indexes.sql new file mode 100644 index 0000000..a646d08 --- /dev/null +++ b/sql/V12__constraints_indexes.sql @@ -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); diff --git a/sql/V13__seed_actions.sql b/sql/V13__seed_actions.sql new file mode 100644 index 0000000..cb08b17 --- /dev/null +++ b/sql/V13__seed_actions.sql @@ -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; diff --git a/sql/V1__create_schema.sql b/sql/V1__create_schema.sql new file mode 100644 index 0000000..7690809 --- /dev/null +++ b/sql/V1__create_schema.sql @@ -0,0 +1,2 @@ +-- Схема bot: все объекты приложения. +CREATE SCHEMA IF NOT EXISTS bot; diff --git a/sql/V2__table_tg_users.sql b/sql/V2__table_tg_users.sql new file mode 100644 index 0000000..58501f6 --- /dev/null +++ b/sql/V2__table_tg_users.sql @@ -0,0 +1,8 @@ +-- Telegram-профили пользователей. +CREATE TABLE IF NOT EXISTS bot.tg_users +( + id_user bigint, + username varchar, + first_name varchar, + last_name varchar +); diff --git a/sql/V3__table_de_users.sql b/sql/V3__table_de_users.sql new file mode 100644 index 0000000..a4fc7e3 --- /dev/null +++ b/sql/V3__table_de_users.sql @@ -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 +); diff --git a/sql/V4__table_titles.sql b/sql/V4__table_titles.sql new file mode 100644 index 0000000..3e211d0 --- /dev/null +++ b/sql/V4__table_titles.sql @@ -0,0 +1,8 @@ +-- Справочник званий магазина. +CREATE TABLE IF NOT EXISTS bot.titles +( + id_title serial, + title varchar, + price bigint, + visible boolean +); diff --git a/sql/V5__table_donats.sql b/sql/V5__table_donats.sql new file mode 100644 index 0000000..a39020c --- /dev/null +++ b/sql/V5__table_donats.sql @@ -0,0 +1,8 @@ +-- Справочник донатов магазина. +CREATE TABLE IF NOT EXISTS bot.donats +( + id_donat serial, + title varchar, + price bigint, + visible boolean +); diff --git a/sql/V6__table_user_donats.sql b/sql/V6__table_user_donats.sql new file mode 100644 index 0000000..b5ad361 --- /dev/null +++ b/sql/V6__table_user_donats.sql @@ -0,0 +1,8 @@ +-- Купленные донаты (с промокодом выдачи). +CREATE TABLE IF NOT EXISTS bot.user_donats +( + id_user bigint, + id_donat bigint, + promo varchar, + stat boolean +); diff --git a/sql/V7__table_actions_money.sql b/sql/V7__table_actions_money.sql new file mode 100644 index 0000000..bf6ffd9 --- /dev/null +++ b/sql/V7__table_actions_money.sql @@ -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 +); diff --git a/sql/V8__table_eggs.sql b/sql/V8__table_eggs.sql new file mode 100644 index 0000000..e266210 --- /dev/null +++ b/sql/V8__table_eggs.sql @@ -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 +); diff --git a/sql/V9__table_messages.sql b/sql/V9__table_messages.sql new file mode 100644 index 0000000..ce1ed8a --- /dev/null +++ b/sql/V9__table_messages.sql @@ -0,0 +1,6 @@ +-- Журнал сообщений: количество строк = msg_count пользователя. +CREATE TABLE IF NOT EXISTS bot.messages +( + id_user bigint, + dt timestamp DEFAULT now() +); diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..7983631 --- /dev/null +++ b/start.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Запуск бота. Все параметры — из переменных окружения / .env (APP_ENV=dev|prod). +set -euo pipefail + +cd "$(dirname "$0")" +exec python -m bot