commit 8953b2f4bc9d10b324e788d6b7a283ff9bd525cd Author: Lolzteam Date: Thu Jul 30 10:22:59 2026 +0000 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3eb7410 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +.git/ +.idea/ +.vscode/ +.venv/ +venv/ +env/ +ENV/ + +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ + +.DS_Store +Thumbs.db + +.env +.env.* +!.env.example +settings.ini + +tgbot/data/*.db +tgbot/data/*.db-* +tgbot/data/*.sqlite +tgbot/data/*.sqlite-* +tgbot/data/*.sqlite3 +tgbot/data/*.sqlite3-* +tgbot/data/*.log +tgbot/data/logs.log* +tgbot/data/sv_log_*.log* + +other/ +tmp/ +temp/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4f1e72c --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +BOT_TOKEN= +BOT_ADMIN_IDS= +BOT_DATABASE_EXPORT=False +BOT_STATUS_NOTIFICATION=True +BOT_TIMEZONE=Europe/Moscow +BOT_USER_CACHE_TTL=300 +BOT_THROTTLE_RATE=0.5 +PATH_DATABASE=tgbot/data/database.db +PATH_LOGS=tgbot/data/logs.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa42e6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Виртуальное окружение +.venv/ +venv/ +env/ +ENV/ +pyvenv.cfg + +# Зависимости и сборка +build/ +dist/ +*.egg-info/ +.eggs/ +*.egg + +# Локальные настройки и секреты +.env +.env.* +settings.ini +!.env.example + +# База данных и логи бота +tgbot/data/*.db +tgbot/data/*.db-* +tgbot/data/*.sqlite +tgbot/data/*.sqlite-* +tgbot/data/*.sqlite3 +tgbot/data/*.sqlite3-* +tgbot/data/*.log +tgbot/data/logs.log* +tgbot/data/sv_log_*.log* + +# Кеш инструментов +.ruff_cache/ +.mypy_cache/ + +# IDE и системный мусор +.idea/ +.vscode/ +.DS_Store +Thumbs.db + +# Локальные временные файлы +other/ +tmp/ +temp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1847c07 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN useradd --create-home --shell /usr/sbin/nologin bot + +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY --chown=bot:bot . . +RUN mkdir -p /app/tgbot/data && chown -R bot:bot /app + +USER bot + +CMD ["sh", "-c", "python migrate.py up && python main.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..66fd9f6 --- /dev/null +++ b/README.md @@ -0,0 +1,302 @@ +# Djimbo Template для Telegram-ботов на aiogram 3 + +Шаблон для быстрого старта Telegram-бота на `aiogram 3`, `SQLAlchemy`, `aiosqlite`, `Alembic` и `.env`-настройках через `pydantic-settings`. + +Внутри уже есть базовая структура проекта, подключение роутеров, middleware для пользователя, админские фильтры, логирование, миграции БД и пример пользовательского/админского меню. + +## Стек + +- Python 3.11 +- aiogram 3.28.2 +- SQLAlchemy 2.x +- aiosqlite +- Alembic +- APScheduler +- aiohttp +- pydantic-settings +- pytz + +## Быстрый старт + +1. Создание виртуального окружения: + +```bash +python3.11 -m venv .venv +source .venv/bin/activate +``` + +2. Установка зависимостей: + +```bash +pip install -r requirements.txt +``` + + +3. Создание локального конфига: + +```bash +cp .env.example .env +``` + +4. Заполнение `.env`: + +```env +BOT_TOKEN=123456:telegram_bot_token +BOT_ADMIN_IDS=123456789 +BOT_DATABASE_EXPORT=False +``` + +`BOT_ADMIN_IDS` можно указать через запятую: `123456789,987654321`. + +5. Применение миграций: + +```bash +python migrate.py up +``` + +Без аргументов `python migrate.py` только показывает подсказки. Бот сам миграции при старте не запускает, чтобы не менять схему базы неожиданно. + +6. Запуск бота: + +```bash +python main.py +``` + +## Docker Compose + +Запуск через compose: + +```bash +docker compose up --build +``` + +Остановка: + +```bash +docker compose down +``` + +`docker-compose.yml` использует `Dockerfile`: Dockerfile собирает образ, а compose управляет запуском, `.env`, volume и restart-политикой. + +При запуске контейнер сначала применяет миграции командой `python migrate.py up`, а потом запускает бота через `python main.py`. + +Volume `./tgbot/data:/app/tgbot/data` нужен, чтобы база и логи не пропадали после остановки контейнера. + +Если compose не нужен, можно запустить Docker вручную: + +```bash +docker build -t djimbo-template . +docker run --rm --env-file .env -v "$(pwd)/tgbot/data:/app/tgbot/data" djimbo-template +``` + +## pyproject.toml + +`pyproject.toml` описывает проект для современных Python-инструментов. + +В этом шаблоне он нужен для: + +- указания версии Python; +- описания зависимостей проекта; +- установки проекта как пакета через `pip install -e .`; +- настройки сборки через `setuptools`. + +`requirements.txt` оставлен для простого запуска и Docker. Если коротко: `requirements.txt` удобен для установки зависимостей, а `pyproject.toml` описывает сам проект. + +## Настройки + +| Параметр | Что делает | +| --- | --- | +| `BOT_TOKEN` | Токен Telegram-бота от BotFather | +| `BOT_ADMIN_IDS` | Telegram ID админов, один или несколько через запятую | +| `BOT_DATABASE_EXPORT` | Разрешает отправку файла БД админам через `/db` и ежедневный автобэкап | +| `BOT_STATUS_NOTIFICATION` | Включает уведомление админов о запуске | +| `BOT_TIMEZONE` | Временная зона бота | +| `BOT_USER_CACHE_TTL` | Время кеширования пользователя в middleware | +| `BOT_THROTTLE_RATE` | Базовая задержка антиспама | +| `PATH_DATABASE` | Путь к SQLite-базе | +| `PATH_LOGS` | Путь к файлу логов | + +По умолчанию `BOT_DATABASE_EXPORT=False`. Это специально: база может содержать персональные данные, поэтому экспорт надо включать руками и осознанно. + +## Структура проекта + +```text +. +├── main.py # Точка входа +├── migrate.py # Удобная CLI-обертка для Alembic +├── Dockerfile # Запуск шаблона в Docker +├── docker-compose.yml # Удобный запуск Docker-контейнера +├── .dockerignore # Что не попадет в Docker-образ +├── pyproject.toml # Метаданные проекта +├── alembic.ini # Настройки Alembic +├── migrations/ # Миграции базы данных +├── .env.example # Пример локального .env +├── tgbot/ +│ ├── data/config.py # Настройки и пути +│ ├── database/ # SQLAlchemy-модели и репозитории +│ ├── keyboards/ # Reply и inline-клавиатуры +│ ├── middlewares/ # Middleware +│ ├── routers/ # Обработчики aiogram +│ ├── services/ # Внешние сервисы и aiohttp-сессия +│ └── utils/ # Общие утилиты +└── requirements.txt +``` + +## База данных + +Проект использует SQLite через `aiosqlite`, но работа с таблицами идёт через async `SQLAlchemy`. + +Ключевые файлы БД: + +- `core.py` - `Base`, `engine`, `session_factory`, `session_scope` +- `repository.py` - базовый репозиторий и проверка готовности БД +- `migration_runner.py` - запуск Alembic из кода +- `db_users.py` - пользователи Telegram +- `db_settings.py` - настройки бота в БД + +### UNIQUE + +В таблице пользователей поле `user_id` уникальное. + +Это значит, что один Telegram-пользователь не может появиться в таблице два раза. Если пользователь уже есть, база не создаст дубль. + +### UPSERT + +UPSERT - это логика “создай запись, а если она уже есть, обнови”. + +В шаблоне пользователь добавляется по `user_id`. Если он уже есть, обновляются только изменившиеся поля: username, имя, фамилия и полное имя. Если данные не поменялись, лишнего UPDATE в БД не будет. + +## Миграции + +Миграции управляются через Alembic, но запускать их удобнее через готовый CLI. + +Показать справку: + +```bash +python migrate.py +``` + +Применить все миграции: + +```bash +python migrate.py up +``` + +То же самое длинной командой: + +```bash +python migrate.py upgrade +``` + +Посмотреть текущую версию БД: + +```bash +python migrate.py status +``` + +Посмотреть историю: + +```bash +python migrate.py history +``` + +Создать новую миграцию вручную: + +```bash +python migrate.py new "add payments table" +``` + +Создать миграцию по изменениям SQLAlchemy-моделей: + +```bash +python migrate.py auto "add payments table" +``` + +Откатить последнюю миграцию: + +```bash +python migrate.py down +``` + +Полные Alembic-команды тоже доступны: `upgrade`, `downgrade`, `revision`, `current`, `history`, `heads`. + +Короткие алиасы: `up`, `down`, `new`, `auto`, `autogen`, `cur`, `hist`, `st`. + +## Роутеры + +Роутеры подключаются в `tgbot/routers/__init__.py`. + +Текущие группы: + +- `main_start.py` - старт и главное меню +- `user/user_menu.py` - пользовательские обработчики +- `admin/admin_menu.py` - админские обработчики +- `main_missed.py` - fallback на неизвестные сообщения и callback +- `main_errors.py` - обработка безопасных Telegram-ошибок + +Админский роутер уже закрыт фильтром `IsAdmin()` и для сообщений, и для callback query. + +## Middleware + +`ExistsUserMiddleware` добавляет или обновляет пользователя в БД и прокидывает объект пользователя в обработчик как `User`. + +Чтобы не писать в БД на каждый одинаковый update, middleware кеширует пользователя по `user_id`. + +Пример: + +```python +async def handler(message: Message, User: UserModel): + await message.answer(User.user_fullname) +``` + +## Логирование + +Логи пишутся в `tgbot/data/logs.log`. + +Файл не растёт бесконечно: используется `RotatingFileHandler`, который вращает логи по размеру. + +Админ может получить логи командой: + +```text +/log +``` + +Очистить логи: + +```text +/clear_log +``` + +## Админские команды + +| Команда | Что делает | +| --- | --- | +| `/log` | Отправляет файл логов | +| `/clear_log` | Очищает файлы логов | +| `/db` | Отправляет файл БД, только если `BOT_DATABASE_EXPORT=True` | + +Команда `/db` скрывается из меню команд, если экспорт БД выключен. + +## Как добавить новую таблицу + +1. Создать модель в `tgbot/database/`. +2. Импортировать её в `tgbot/database/__init__.py`. +3. Создать миграцию: + +```bash +python migrate.py auto "add new table" +``` + +4. Проверить созданный файл в `migrations/versions/`. +5. Применить миграцию: + +```bash +python migrate.py up +``` + +## Важно + +- Не коммить `.env`, базу данных и логи. +- Если токен попал в Git, его надо перевыпустить у BotFather. +- Перед деплоем проверь `BOT_DATABASE_EXPORT`: на проде лучше держать `False`, если экспорт БД реально не нужен. +- Для нового проекта сначала менять тексты, команды и клавиатуры под свою логику, а потом уже добавлять бизнес-код. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..49fb9b5 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,42 @@ +[alembic] +script_location = migrations +prepend_sys_path = . +path_separator = os +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s +timezone = Europe/Moscow + +sqlalchemy.url = sqlite+aiosqlite:///tgbot/data/database.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/dj_bot.conf b/dj_bot.conf new file mode 100644 index 0000000..4af1a2c --- /dev/null +++ b/dj_bot.conf @@ -0,0 +1,13 @@ +# Пример конфига для supervisorctl +[program:dj_bot] +directory=/path/to/djimbo_template/ +command=python3.11 -u main.py +environment=PYTHONUNBUFFERED="1" + +autostart=True +autorestart=True + +stderr_logfile=/path/to/djimbo_template/tgbot/data/sv_log_err.log +stderr_logfile_maxbytes=50MB +stdout_logfile=/path/to/djimbo_template/tgbot/data/sv_log_out.log +stdout_logfile_maxbytes=50MB diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a5a285a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + bot: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + env_file: + - .env + volumes: + - ./tgbot/data:/app/tgbot/data + stop_grace_period: 30s diff --git a/main.py b/main.py new file mode 100644 index 0000000..d33799b --- /dev/null +++ b/main.py @@ -0,0 +1,103 @@ +# - *- coding: utf- 8 - *- +import asyncio +import sys + +import colorama +from aiogram import Bot, Dispatcher +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode + +from tgbot.data.config import BOT_DATABASE_EXPORT, BOT_TOKEN, BOT_SCHEDULER, get_admins, validate_bot_config +from tgbot.database.core import close_database +from tgbot.database.repository import prepare_database +from tgbot.middlewares import register_all_middlewares +from tgbot.routers import register_all_routers +from tgbot.services.api_session import AsyncRequestSession +from tgbot.utils.misc.bot_commands import set_commands +from tgbot.utils.misc.bot_logging import bot_logger +from tgbot.utils.misc_functions import autobackup_admin, startup_notify + +# Включаем мгновенный вывод print() без flush=True в каждом вызове +def configure_console_output() -> None: + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(line_buffering=True, write_through=True) + + +configure_console_output() +colorama.init() + + +# Запуск задач по расписанию +async def scheduler_start(bot): + if BOT_DATABASE_EXPORT: + BOT_SCHEDULER.add_job( + autobackup_admin, + trigger="cron", + hour=0, + args=(bot,), + id="autobackup_admin", + replace_existing=True, + coalesce=True, + misfire_grace_time=60, + ) + + if not BOT_SCHEDULER.running: + BOT_SCHEDULER.start() + + +# Запуск бота и базовой обвязки +async def main(): + validate_bot_config() + await prepare_database() # Проверка готовности БД + + dp = Dispatcher() # Диспетчер событий + arSession = AsyncRequestSession() # Общая сессия aiohttp + + bot = Bot( # Образ Бота + token=BOT_TOKEN, + default=DefaultBotProperties( + parse_mode=ParseMode.HTML + ), + ) + + register_all_middlewares(dp) # Подключение мидлварей + register_all_routers(dp) # Подключение роутера + + try: + await set_commands(bot) # Обновление команды в Telegram + await startup_notify(bot) # Сообщаем админам о старте + await scheduler_start(bot) # Подключение задач по расписанию + + bot_info = await bot.get_me() + bot_logger.info("Бот запущен: @%s", bot_info.username) + print(colorama.Fore.LIGHTYELLOW_EX + f"~~~~~ Бот запущен - @{bot_info.username} ~~~~~") + print(colorama.Fore.LIGHTBLUE_EX + "~~~~~ TG developer - @djimbox ~~~~~") + print(colorama.Fore.RESET) + + if len(get_admins()) == 0: + print("***** УКАЖИТЕ BOT_ADMIN_IDS В .env *****") + + await bot.delete_webhook() # Сбрасывание вебхука, если он был + await bot.get_updates(offset=-1) # Чистка старых апдейтов + + # Запуск бота (polling режим) + await dp.start_polling( + bot, + arSession=arSession, + allowed_updates=dp.resolve_used_update_types(), + ) + finally: + if BOT_SCHEDULER.running: + BOT_SCHEDULER.shutdown(wait=False) + + await arSession.close() # Закрытие сессии aiohttp + await bot.session.close() # Закрытие сессии API Telegram + await close_database() # Закрытие соединений с БД + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except (KeyboardInterrupt, SystemExit): + bot_logger.warning("Бот остановлен") diff --git a/migrate.py b/migrate.py new file mode 100644 index 0000000..dbe7e1e --- /dev/null +++ b/migrate.py @@ -0,0 +1,182 @@ +# - *- coding: utf- 8 - *- +import argparse +from typing import Optional + +from alembic import command +from alembic.config import Config + +HELP_TEXT = """ +Миграции базы данных + +Безопасное правило: + python migrate.py только показывает эту справку + действия с БД выполняются только при явной команде + +Основные команды: + python migrate.py help показать эту справку + python migrate.py up применить все миграции до head + python migrate.py down откатить последнюю миграцию + python migrate.py new "add users" создать пустую миграцию + python migrate.py auto "add users" создать миграцию по SQLAlchemy-моделям + python migrate.py status показать текущую версию и последние версии + +Длинные команды: + python migrate.py upgrade [rev] применить миграции до rev, по умолчанию head + python migrate.py downgrade [rev] откатить миграции до rev, по умолчанию -1 + python migrate.py revision -m "name" создать пустую миграцию + python migrate.py current показать текущую версию БД + python migrate.py history показать историю миграций + python migrate.py heads показать последние версии веток + +Короткие алиасы: + up -> upgrade + down -> downgrade + new -> revision + auto -> revision --autogenerate + autogen -> auto + cur -> current + hist -> history + st -> status + +Подсказки: + - сначала меняешь SQLAlchemy-модель; + - потом запускаешь: python migrate.py auto "что изменилось"; + - проверяешь файл в migrations/versions/; + - применяешь: python migrate.py up; + - если сомневаешься, запускаешь: python migrate.py status. +""".strip() + + +# Создаем parser без лишнего шума, чтобы help был похож на нормальную подсказку +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Удобный CLI для Alembic-миграций", + formatter_class=argparse.RawTextHelpFormatter, + add_help=True, + ) + subparsers = parser.add_subparsers(dest="command") + + help_parser = subparsers.add_parser("help", aliases=["h"], help="Показать понятную справку") + help_parser.set_defaults(action=show_help) + + upgrade_parser = subparsers.add_parser("upgrade", aliases=["up"], help="Применить миграции") + upgrade_parser.add_argument("revision", nargs="?", default="head", help="Версия миграции, по умолчанию head") + upgrade_parser.set_defaults(action=run_upgrade) + + downgrade_parser = subparsers.add_parser("downgrade", aliases=["down"], help="Откатить миграции") + downgrade_parser.add_argument("revision", nargs="?", default="-1", help="Версия отката, по умолчанию -1") + downgrade_parser.set_defaults(action=run_downgrade) + + revision_parser = subparsers.add_parser("revision", aliases=["new"], help="Создать пустую миграцию") + revision_parser.add_argument("message", nargs="?", help="Название миграции") + revision_parser.add_argument("-m", "--message-option", dest="message_option", help="Название миграции") + revision_parser.add_argument("--autogenerate", "-a", action="store_true", help="Собрать изменения из моделей") + revision_parser.set_defaults(action=run_revision) + + auto_parser = subparsers.add_parser("auto", aliases=["autogen"], help="Создать миграцию по моделям") + auto_parser.add_argument("message", nargs="?", help="Название миграции") + auto_parser.add_argument("-m", "--message-option", dest="message_option", help="Название миграции") + auto_parser.set_defaults(action=run_auto_revision) + + current_parser = subparsers.add_parser("current", aliases=["cur"], help="Показать текущую версию БД") + current_parser.set_defaults(action=run_current) + + history_parser = subparsers.add_parser("history", aliases=["hist"], help="Показать историю миграций") + history_parser.set_defaults(action=run_history) + + heads_parser = subparsers.add_parser("heads", help="Показать последние версии веток") + heads_parser.set_defaults(action=run_heads) + + status_parser = subparsers.add_parser("status", aliases=["st"], help="Показать текущую версию и heads") + status_parser.set_defaults(action=run_status) + + return parser + + +def show_help(_config: Optional[Config], _args: argparse.Namespace) -> None: + print(HELP_TEXT) + + +def run_upgrade(config: Config, args: argparse.Namespace) -> None: + print(f"Применяю миграции до версии: {args.revision}") + command.upgrade(config, args.revision) + print("Готово") + + +def run_downgrade(config: Config, args: argparse.Namespace) -> None: + print(f"Откатываю миграции до версии: {args.revision}") + command.downgrade(config, args.revision) + print("Готово") + + +def run_revision(config: Config, args: argparse.Namespace) -> None: + message = get_revision_message(args) + print(f"Создаю миграцию: {message}") + command.revision(config, message=message, autogenerate=args.autogenerate) + print("Готово") + + +def run_auto_revision(config: Config, args: argparse.Namespace) -> None: + message = get_revision_message(args) + print(f"Создаю миграцию по моделям: {message}") + command.revision(config, message=message, autogenerate=True) + print("Готово") + + +def run_current(config: Config, _args: argparse.Namespace) -> None: + print("Текущая версия БД:") + command.current(config) + + +def run_history(config: Config, _args: argparse.Namespace) -> None: + print("История миграций:") + command.history(config) + + +def run_heads(config: Config, _args: argparse.Namespace) -> None: + print("Последние версии миграций:") + command.heads(config) + + +def run_status(config: Config, args: argparse.Namespace) -> None: + run_current(config, args) + print() + run_heads(config, args) + + +def get_revision_message(args: argparse.Namespace) -> str: + message: Optional[str] = args.message_option or args.message + + if not message: + raise SystemExit("Укажи название миграции. Пример: python migrate.py auto \"add users\"") + + return message + + +def get_config() -> Config: + from tgbot.database.migration_runner import get_alembic_config + + return get_alembic_config() + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + + if args.command is None: + show_help(None, args) + return + + if args.action == show_help: + show_help(None, args) + return + + if args.action in (run_revision, run_auto_revision): + get_revision_message(args) + + config = get_config() + args.action(config, args) + + +if __name__ == "__main__": + main() diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..c53bac2 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,79 @@ +# - *- coding: utf- 8 - *- +import asyncio +from contextlib import AbstractAsyncContextManager +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, async_engine_from_config + +from tgbot.database.core import Base + +# Импортируем модели, чтобы Alembic видел все таблицы +import tgbot.database # noqa: F401 + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +# Настройка миграций без подключения к базе +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +# Настройка миграций поверх готового соединения +def do_run_migrations(connection: Connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +# Создаем async-engine и передаем Alembic синхронное соединение внутри run_sync +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable_context(connectable) as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +# Отдельная обертка помогает IDE правильно определить async context manager +def connectable_context(connectable: AsyncEngine) -> AbstractAsyncContextManager[AsyncConnection]: + return connectable.connect() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + external_connection = config.attributes.get("connection") + + if external_connection is not None: + do_run_migrations(external_connection) + else: + asyncio.run(run_async_migrations()) diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..590f5b3 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/20260528_0001_initial_schema.py b/migrations/versions/20260528_0001_initial_schema.py new file mode 100644 index 0000000..6070258 --- /dev/null +++ b/migrations/versions/20260528_0001_initial_schema.py @@ -0,0 +1,134 @@ +"""Начальная схема проекта + +Revision ID: 0001_initial_schema +Revises: +Create Date: 2026-05-28 00:00:00 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0001_initial_schema" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# Проверяем таблицу через sqlite_master, потому что первая миграция должна принять и старую БД +def _table_exists(table_name: str) -> bool: + bind = op.get_bind() + result = bind.execute( + sa.text( + """ + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name = :table_name + """ + ), + {"table_name": table_name}, + ) + + return result.scalar_one_or_none() is not None + + +# Проверяем колонку перед миграцией старой таблицы +def _column_exists(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + result = bind.execute(sa.text(f"PRAGMA table_info({table_name})")) + + return column_name in [row.name for row in result] + + +def upgrade() -> None: + bind = op.get_bind() + + if _table_exists("storage_users"): + bind.execute(sa.text("DELETE FROM storage_users WHERE user_id IS NULL")) + bind.execute( + sa.text( + """ + DELETE + FROM storage_users + WHERE user_id IS NOT NULL + AND rowid NOT IN (SELECT MAX(rowid) AS last_rowid + FROM storage_users + WHERE user_id IS NOT NULL + GROUP BY user_id) + """ + ) + ) + else: + op.create_table( + "storage_users", + sa.Column("increment", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("user_login", sa.String(length=255), nullable=False, server_default=""), + sa.Column("user_name", sa.String(length=255), nullable=False, server_default=""), + sa.Column("user_surname", sa.String(length=255), nullable=False, server_default=""), + sa.Column("user_fullname", sa.String(length=511), nullable=False, server_default=""), + sa.Column("user_unix", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("increment"), + ) + + op.create_index("ix_storage_users_user_id", "storage_users", ["user_id"], unique=True, if_not_exists=True) + + if _table_exists("storage_settings"): + status_source = "status_work" if _column_exists("storage_settings", "status_work") else "'false'" + bind.execute(sa.text("DROP TABLE IF EXISTS storage_settings_new")) + bind.execute( + sa.text( + """ + CREATE TABLE storage_settings_new + ( + id INTEGER NOT NULL PRIMARY KEY, + status_work BOOLEAN NOT NULL DEFAULT 0 + ) + """ + ) + ) + bind.execute( + sa.text( + """ + INSERT INTO storage_settings_new (id, status_work) + SELECT 1 AS id, + CASE LOWER(CAST(COALESCE({status_source}, 'false') AS TEXT)) + WHEN '1' THEN 1 + WHEN 'true' THEN 1 + WHEN 'yes' THEN 1 + WHEN 'on' THEN 1 + WHEN 'да' THEN 1 + ELSE 0 + END AS status_work + FROM storage_settings + LIMIT 1 + """ + .format(status_source=status_source) + ) + ) + bind.execute(sa.text("DROP TABLE storage_settings")) + bind.execute(sa.text("ALTER TABLE storage_settings_new RENAME TO storage_settings")) + else: + op.create_table( + "storage_settings", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("status_work", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.PrimaryKeyConstraint("id"), + ) + + bind.execute( + sa.text( + """ + INSERT INTO storage_settings (id, status_work) + SELECT 1 AS id, 0 AS status_work + WHERE NOT EXISTS (SELECT 1 AS exists_status FROM storage_settings WHERE id = 1) + """ + ) + ) + + +def downgrade() -> None: + op.drop_table("storage_settings") + op.drop_index("ix_storage_users_user_id", table_name="storage_users") + op.drop_table("storage_users") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b2ef995 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "djimbo-template" +version = "0.1.0" +description = "Шаблон Telegram-бота на aiogram 3" +requires-python = ">=3.11" +dependencies = [ + "APScheduler>=3.11,<4.0", + "aiogram>=3.28,<4.0", + "aiosqlite>=0.20,<1.0", + "alembic>=1.13,<2.0", + "colorlog>=6.10,<7.0", + "aiofiles>=25.1,<26.0", + "aiohttp>=3.13,<4.0", + "cachetools>=6.1,<7.0", + "colorama>=0.4,<1.0", + "SQLAlchemy>=2.0,<3.0", + "pydantic>=2.12,<3.0", + "pydantic-settings>=2.6,<3.0", + "pytz>=2025.2,<2026.0", +] + +[tool.setuptools.packages.find] +include = ["tgbot*"] +exclude = ["migrations*"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3fd11d2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +APScheduler==3.11.1 +aiogram==3.28.2 +aiofiles==25.1.0 +aiohttp==3.13.2 +aiosqlite==0.22.1 +alembic==1.18.4 +cachetools==6.1.0 +colorama==0.4.6 +colorlog==6.10.1 +pydantic==2.12.4 +pydantic-settings==2.14.1 +pytz==2025.2 +SQLAlchemy==2.0.50 diff --git a/tgbot/__init__.py b/tgbot/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tgbot/__init__.py @@ -0,0 +1 @@ + diff --git a/tgbot/data/__init__.py b/tgbot/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tgbot/data/config.py b/tgbot/data/config.py new file mode 100644 index 0000000..3326f28 --- /dev/null +++ b/tgbot/data/config.py @@ -0,0 +1,128 @@ +# - *- coding: utf- 8 - *- +from functools import lru_cache +from pathlib import Path +from typing import List + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from pydantic import AliasChoices, Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from pytz import UnknownTimeZoneError, timezone + +BASE_DIR = Path(__file__).resolve().parents[2] +ENV_PATH = BASE_DIR / ".env" + + +# Все настройки из окружения или локального .env +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=ENV_PATH, + env_file_encoding="utf-8", + extra="ignore", + populate_by_name=True, + ) + + bot_token: str = Field(default="", validation_alias=AliasChoices("BOT_TOKEN", "bot_token")) + admin_ids: List[int] = Field(default_factory=list, validation_alias=AliasChoices("BOT_ADMIN_IDS", "admin_id")) + database_export: bool = Field(default=False, + validation_alias=AliasChoices("BOT_DATABASE_EXPORT", "allow_database_export")) + status_notification: bool = Field(default=True, validation_alias="BOT_STATUS_NOTIFICATION") + timezone: str = Field(default="Europe/Moscow", validation_alias="BOT_TIMEZONE") + database_path: str = Field(default="tgbot/data/database.db", validation_alias="PATH_DATABASE") + logs_path: str = Field(default="tgbot/data/logs.log", validation_alias="PATH_LOGS") + user_cache_ttl: int = Field(default=300, ge=0, validation_alias="BOT_USER_CACHE_TTL") + throttle_rate: float = Field(default=0.5, ge=0, validation_alias="BOT_THROTTLE_RATE") + + @field_validator("bot_token", "timezone", "database_path", "logs_path", mode="before") + @classmethod + def _strip_string(cls, value: object) -> str: + return str(value or "").strip() + + @field_validator("admin_ids", mode="before") + @classmethod + def _parse_admin_ids(cls, value: object) -> List[int]: + if value is None or value == "": + return [] + + if isinstance(value, int): + values = [value] + elif isinstance(value, str): + values = [admin_id for admin_id in value.replace(" ", "").split(",") if admin_id] + elif isinstance(value, (list, tuple, set)): + values = list(value) + else: + raise ValueError("BOT_ADMIN_IDS должен быть числом или списком чисел через запятую") + + admin_ids = [] + + for admin_id in values: + try: + parsed_id = int(admin_id) + except (TypeError, ValueError) as error: + raise ValueError("BOT_ADMIN_IDS должен содержать только Telegram ID через запятую") from error + + if parsed_id <= 0: + raise ValueError("BOT_ADMIN_IDS должен содержать Telegram ID больше нуля") + + admin_ids.append(parsed_id) + + return admin_ids + + @field_validator("timezone") + @classmethod + def _validate_timezone(cls, value: str) -> str: + try: + timezone(value) + except UnknownTimeZoneError as error: + raise ValueError("BOT_TIMEZONE должен быть корректной временной зоной, например Europe/Moscow") from error + + return value + + @field_validator("database_path", "logs_path") + @classmethod + def _validate_path(cls, value: str) -> str: + if not value: + raise ValueError("Путь к файлу не должен быть пустым") + + return value + + @property + def admins(self) -> List[int]: + return list(self.admin_ids) + + def resolve_path(self, path_value: str) -> Path: + path = Path(path_value) + + if path.is_absolute(): + return path + + return BASE_DIR / path + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() + +# Константы чтобы не ломать старые импорты в шаблоне +BOT_TOKEN = settings.bot_token.replace(" ", "") +PATH_DATABASE = str(settings.resolve_path(settings.database_path)) +PATH_LOGS = str(settings.resolve_path(settings.logs_path)) +BOT_STATUS_NOTIFICATION = settings.status_notification +BOT_DATABASE_EXPORT = settings.database_export +BOT_TIMEZONE = settings.timezone +BOT_USER_CACHE_TTL = settings.user_cache_ttl +BOT_THROTTLE_RATE = settings.throttle_rate +BOT_SCHEDULER = AsyncIOScheduler(timezone=BOT_TIMEZONE) + + +# Получение администраторов бота +def get_admins() -> List[int]: + return settings.admins + + +# Проверка настроек, которые нужны именно для запуска бота +def validate_bot_config() -> None: + if not BOT_TOKEN: + raise RuntimeError("В .env не заполнен параметр BOT_TOKEN") diff --git a/tgbot/database/__init__.py b/tgbot/database/__init__.py new file mode 100644 index 0000000..7a5da8b --- /dev/null +++ b/tgbot/database/__init__.py @@ -0,0 +1,5 @@ +from .db_settings import SettingsModel, SettingsRepository +from .db_users import UserModel, UsersRepository + +ModelSettings = SettingsModel +ModelUsers = UserModel diff --git a/tgbot/database/core.py b/tgbot/database/core.py new file mode 100644 index 0000000..e12fd30 --- /dev/null +++ b/tgbot/database/core.py @@ -0,0 +1,51 @@ +# - *- coding: utf- 8 - *- +from collections.abc import AsyncIterator +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from pathlib import Path + +from sqlalchemy import event +from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from tgbot.data.config import PATH_DATABASE + +database_path = Path(PATH_DATABASE) +database_url = f"sqlite+aiosqlite:///{database_path.as_posix()}" + +engine = create_async_engine(database_url, echo=False) +session_factory = async_sessionmaker(engine, expire_on_commit=False) + + +# Общая база для всех SQLAlchemy-моделей +class Base(AsyncAttrs, DeclarativeBase): + pass + + +# SQLite по умолчанию не включает foreign keys, поэтому включаем явно +@event.listens_for(engine.sync_engine, "connect") +def _enable_sqlite_foreign_keys(dbapi_connection, connection_record) -> None: + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + +# Открываем сессию и сами отвечаем за сохранение или откат +@asynccontextmanager +async def _session_scope() -> AsyncIterator[AsyncSession]: + async with session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + +# Отдельная обертка, чтобы IDE нормально видела асинхронный контекст +def session_scope() -> AbstractAsyncContextManager[AsyncSession]: + return _session_scope() + + +# Закрываем пул соединений при остановке приложения +async def close_database() -> None: + await engine.dispose() diff --git a/tgbot/database/db_settings.py b/tgbot/database/db_settings.py new file mode 100644 index 0000000..9fab138 --- /dev/null +++ b/tgbot/database/db_settings.py @@ -0,0 +1,61 @@ +# - *- coding: utf- 8 - *- +from sqlalchemy import Boolean, Integer +from sqlalchemy import update as sqlalchemy_update +from sqlalchemy.dialects.sqlite import insert +from sqlalchemy.orm import Mapped, mapped_column + +from tgbot.database.core import Base, session_scope +from tgbot.database.repository import BaseRepository + + +# Модель настроек бота +class SettingsModel(Base): + __tablename__ = "storage_settings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) + status_work: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + +ModelBase = SettingsModel +BaseModel = SettingsModel + + +# Репозиторий настроек бота +class SettingsRepository(BaseRepository[SettingsModel]): + def __init__(self): + super().__init__() + self.table_model = SettingsModel + self.storage_name = SettingsModel.__tablename__ + + # Создание строки настроек если их еще нет + async def ensure_default(self) -> None: + statement = insert(SettingsModel).values(id=1, status_work=False) + statement = statement.on_conflict_do_nothing(index_elements=[SettingsModel.id]) + + async with session_scope() as session: + await session.execute(statement) + + # Настройки должны быть всегда, поэтому при пустой таблице создаем дефолт + async def get(self) -> SettingsModel: + settings = await super().get(id=1) + + if settings is None: + await self.ensure_default() + settings = await super().get(id=1) + + if settings is None: + raise RuntimeError("Настройки бота по умолчанию не сохранились") + + return settings + + # Обновление единственной строки настроек + async def update(self, **kwargs) -> None: + if not kwargs: + return + + async with session_scope() as session: + await session.execute( + sqlalchemy_update(SettingsModel) + .where(SettingsModel.id == 1) + .values(**kwargs) + ) diff --git a/tgbot/database/db_users.py b/tgbot/database/db_users.py new file mode 100644 index 0000000..4c0aaba --- /dev/null +++ b/tgbot/database/db_users.py @@ -0,0 +1,106 @@ +# - *- coding: utf- 8 - *- +from sqlalchemy import BigInteger, Integer, String, or_ +from sqlalchemy import update as sqlalchemy_update +from sqlalchemy.dialects.sqlite import insert +from sqlalchemy.orm import Mapped, mapped_column + +from tgbot.database.core import Base, session_scope +from tgbot.database.repository import BaseRepository +from tgbot.utils.const_functions import get_unix + + +# Модель пользователя Telegram +class UserModel(Base): + __tablename__ = "storage_users" + + increment: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True) + user_login: Mapped[str] = mapped_column(String(255), nullable=False, default="") + user_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + user_surname: Mapped[str] = mapped_column(String(255), nullable=False, default="") + user_fullname: Mapped[str] = mapped_column(String(511), nullable=False, default="") + user_unix: Mapped[int] = mapped_column(Integer, nullable=False, default=get_unix) + + +ModelBase = UserModel +BaseModel = UserModel + + +# Репозиторий пользователей +class UsersRepository(BaseRepository[UserModel]): + def __init__(self): + super().__init__() + self.table_model = UserModel + self.storage_name = UserModel.__tablename__ + + # Для совместимости add ведет себя как upsert + async def add( + self, + user_id: int, + user_login: str, + user_name: str, + user_surname: str, + user_fullname: str, + ) -> UserModel: + return await self.upsert( + user_id=user_id, + user_login=user_login, + user_name=user_name, + user_surname=user_surname, + user_fullname=user_fullname, + ) + + # Создание пользователя или обновление его данных по user_id + async def upsert( + self, + user_id: int, + user_login: str, + user_name: str, + user_surname: str, + user_fullname: str, + ) -> UserModel: + statement = insert(UserModel).values( + user_id=user_id, + user_login=user_login, + user_name=user_name, + user_surname=user_surname, + user_fullname=user_fullname, + user_unix=get_unix(), + ) + statement = statement.on_conflict_do_update( + index_elements=[UserModel.user_id], + set_={ + "user_login": statement.excluded.user_login, + "user_name": statement.excluded.user_name, + "user_surname": statement.excluded.user_surname, + "user_fullname": statement.excluded.user_fullname, + }, + where=or_( + UserModel.user_login != statement.excluded.user_login, + UserModel.user_name != statement.excluded.user_name, + UserModel.user_surname != statement.excluded.user_surname, + UserModel.user_fullname != statement.excluded.user_fullname, + ), + ) + + async with session_scope() as session: + await session.execute(statement) + + user = await self.get(user_id=user_id) + + if user is None: + raise RuntimeError("Пользователь не сохранился") + + return user + + # Обновление пользователя по Telegram ID + async def update(self, user_id: int, **kwargs) -> None: + if not kwargs: + return + + async with session_scope() as session: + await session.execute( + sqlalchemy_update(UserModel) + .where(UserModel.user_id == user_id) + .values(**kwargs) + ) diff --git a/tgbot/database/migration_runner.py b/tgbot/database/migration_runner.py new file mode 100644 index 0000000..91bd52d --- /dev/null +++ b/tgbot/database/migration_runner.py @@ -0,0 +1,59 @@ +# - *- coding: utf- 8 - *- +import asyncio +from contextlib import AbstractAsyncContextManager +from pathlib import Path +from typing import Optional + +from alembic import command +from alembic.config import Config +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine + +from tgbot.database.core import database_url + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +ALEMBIC_INI = PROJECT_ROOT / "alembic.ini" +MIGRATIONS_DIR = PROJECT_ROOT / "migrations" + + +# Собираем конфиг Alembic так, чтобы команды работали из любой папки +def get_alembic_config(url: str = database_url) -> Config: + config = Config(str(ALEMBIC_INI)) + config.set_main_option("script_location", str(MIGRATIONS_DIR)) + config.set_main_option("sqlalchemy.url", url) + + return config + + +# Применяем все миграции до последней версии +async def run_migrations(engine: Optional[AsyncEngine] = None) -> None: + if engine is None: + config = get_alembic_config() + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, command.upgrade, config, "head") + return + + config = get_alembic_config(str(engine.url)) + + async with engine_context(engine) as connection: + await connection.run_sync(_upgrade_with_connection, config) + + +# Отдельная обертка нужна, чтобы IDE корректно видела async context manager +def engine_context(engine: AsyncEngine) -> AbstractAsyncContextManager[AsyncConnection]: + return engine.begin() + + +# Alembic умеет работать с синхронным соединением внутри async-engine +def _upgrade_with_connection(connection, config: Config) -> None: + config.attributes["connection"] = connection + command.upgrade(config, "head") + + +# Ручной запуск миграций из консоли +async def _main() -> None: + await run_migrations() + print("Миграции базы данных применены") + + +if __name__ == "__main__": + asyncio.run(_main()) diff --git a/tgbot/database/repository.py b/tgbot/database/repository.py new file mode 100644 index 0000000..9edfdfe --- /dev/null +++ b/tgbot/database/repository.py @@ -0,0 +1,106 @@ +# - *- coding: utf- 8 - *- +from typing import Generic, List, Optional, Type, TypeVar + +from sqlalchemy import delete as sqlalchemy_delete +from sqlalchemy import select +from sqlalchemy import update as sqlalchemy_update +from sqlalchemy.exc import SQLAlchemyError + +from tgbot.database.core import Base, database_path, session_scope +from tgbot.utils.misc.bot_logging import bot_logger + +ModelTranslator = TypeVar("ModelTranslator", bound=Base) + + +# Базовый репозиторий с общими методами работы с БД +class BaseRepository(Generic[ModelTranslator]): + def __init__(self): + self.storage_name = "storage" + self.table_model: Optional[Type[ModelTranslator]] = None + + # Без модели репозиторий работать не должен + def _model(self) -> Type[ModelTranslator]: + if self.table_model is None: + raise RuntimeError("Модель базы данных не настроена") + + return self.table_model + + # Удаление только по явному фильтру, без случайной чистки всей таблицы + async def delete(self, **kwargs) -> None: + if not kwargs: + raise ValueError("Для удаления нужен хотя бы один фильтр") + + model = self._model() + + async with session_scope() as session: + await session.execute(sqlalchemy_delete(model).filter_by(**kwargs)) + + # Полное удаление всех строк таблицы. Название намеренно прямое. + async def delete_all_rows(self) -> None: + model = self._model() + + async with session_scope() as session: + await session.execute(sqlalchemy_delete(model)) + + # Возвращение первой записи по фильтру + async def get(self, **kwargs) -> Optional[ModelTranslator]: + model = self._model() + statement = select(model).filter_by(**kwargs) + + async with session_scope() as session: + response = await session.execute(statement) + + return response.scalars().first() + + # Возвращение всех записей по фильтру + async def gets(self, **kwargs) -> List[ModelTranslator]: + model = self._model() + statement = select(model).filter_by(**kwargs) + + async with session_scope() as session: + response = await session.execute(statement) + + return list(response.scalars().all()) + + # Возвращение всей таблицы + async def get_all(self) -> List[ModelTranslator]: + model = self._model() + + async with session_scope() as session: + response = await session.execute(select(model)) + + return list(response.scalars().all()) + + # Обновляем только записи, которые попали под фильтр + async def update(self, filters: dict, **kwargs) -> None: + if not filters: + raise ValueError("Для обновления нужен хотя бы один фильтр") + + if not kwargs: + return + + model = self._model() + + async with session_scope() as session: + await session.execute( + sqlalchemy_update(model) + .filter_by(**filters) + .values(**kwargs) + ) + + +# Готовим подключение к БД. Сами миграции запускаются отдельно через migrate.py. +async def prepare_database() -> None: + database_path.parent.mkdir(parents=True, exist_ok=True) + + # Импортируем модели, чтобы репозитории работали с уже загруженными таблицами + import tgbot.database # noqa: F401 + + from tgbot.database.db_settings import SettingsRepository + + try: + await SettingsRepository().ensure_default() + except SQLAlchemyError as error: + raise RuntimeError("База данных не готова. Запусти миграции командой: python migrate.py") from error + + bot_logger.info("База данных готова") diff --git a/tgbot/keyboards/__init__.py b/tgbot/keyboards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tgbot/keyboards/inline_main.py b/tgbot/keyboards/inline_main.py new file mode 100644 index 0000000..f64b40f --- /dev/null +++ b/tgbot/keyboards/inline_main.py @@ -0,0 +1,35 @@ +# - *- coding: utf- 8 - *- +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from tgbot.utils.const_functions import ikb + + +# Инлайн-клавиатура для пользователя +def user_finl() -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("Действие", data="user_inline_x"), + ikb("Раздел", data="user_inline:user_btn"), + ikb("Скоро", data="..."), + ).row( + ikb("Неизвестная кнопка", data="unknown"), + ) + + return keyboard.as_markup() + + +# Инлайн-клавиатура для админа +def admin_finl() -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("Действие", data="admin_inline_x"), + ikb("Раздел", data="admin_inline:admin_btn"), + ikb("Скоро", data="..."), + ).row( + ikb("Неизвестная кнопка", data="unknown"), + ) + + return keyboard.as_markup() diff --git a/tgbot/keyboards/reply_main.py b/tgbot/keyboards/reply_main.py new file mode 100644 index 0000000..68651a8 --- /dev/null +++ b/tgbot/keyboards/reply_main.py @@ -0,0 +1,22 @@ +# - *- coding: utf- 8 - *- +from aiogram.types import ReplyKeyboardMarkup +from aiogram.utils.keyboard import ReplyKeyboardBuilder + +from tgbot.data.config import get_admins +from tgbot.utils.const_functions import rkb + + +# Кнопки главного меню +def menu_frep(user_id: int) -> ReplyKeyboardMarkup: + keyboard = ReplyKeyboardBuilder() + + keyboard.row( + rkb("Пользовательское меню"), + ) + + if user_id in get_admins(): + keyboard.row( + rkb("Админ-меню"), + ) + + return keyboard.as_markup(resize_keyboard=True) diff --git a/tgbot/middlewares/__init__.py b/tgbot/middlewares/__init__.py new file mode 100644 index 0000000..39a7007 --- /dev/null +++ b/tgbot/middlewares/__init__.py @@ -0,0 +1,20 @@ +# - *- coding: utf- 8 - *- +from aiogram import Dispatcher + +from tgbot.middlewares.middleware_throttling import ThrottlingMiddleware +from tgbot.middlewares.middleware_user import ExistsUserMiddleware + + +# Подключение всех мидлварей +def register_all_middlewares(dp: Dispatcher): + dp.callback_query.outer_middleware(ExistsUserMiddleware()) + dp.message.outer_middleware(ExistsUserMiddleware()) + + throttling = ThrottlingMiddleware() + dp.message.middleware(throttling) + dp.callback_query.middleware(throttling) + + +# Старое имя оставлено, чтобы не ломать импорт в чужих проектах на базе шаблона +def register_all_middlwares(dp: Dispatcher): + register_all_middlewares(dp) diff --git a/tgbot/middlewares/middleware_throttling.py b/tgbot/middlewares/middleware_throttling.py new file mode 100644 index 0000000..196d278 --- /dev/null +++ b/tgbot/middlewares/middleware_throttling.py @@ -0,0 +1,78 @@ +# - *- coding: utf- 8 - *- +import time +from typing import Any, Awaitable, Callable, Dict, Optional, Union + +from aiogram import BaseMiddleware +from aiogram.dispatcher.flags import get_flag +from aiogram.types import CallbackQuery, Message, TelegramObject, User +from cachetools import TTLCache + +from tgbot.data.config import BOT_THROTTLE_RATE + + +# Простая защита от спама +class ThrottlingMiddleware(BaseMiddleware): + def __init__(self, default_rate: Union[int, float] = BOT_THROTTLE_RATE) -> None: + # Базовая задержка между сообщениями + self.default_rate = default_rate + + self.message_users = TTLCache(maxsize=10_000, ttl=600) + self.callback_users = TTLCache(maxsize=10_000, ttl=600) + + async def __call__(self, handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], event: TelegramObject, data): + # Если юзер спамит, постепенно увеличиваем паузу + this_user: Optional[User] = data.get("event_from_user") + + if this_user is None: + return await handler(event, data) + + flag_rate = get_flag(data, "rate") + rate = float(self.default_rate if flag_rate is None else flag_rate) + + if rate == 0: + return await handler(event, data) + + now_time = time.monotonic() + bucket = self._get_bucket(event) + user_key = this_user.id + + if user_key not in bucket: + bucket[user_key] = { + 'last_throttled': now_time, + 'count_throttled': 0, + 'now_rate': rate, + } + + return await handler(event, data) + else: + if now_time - bucket[user_key]['last_throttled'] >= bucket[user_key]['now_rate']: + bucket.pop(user_key) + + return await handler(event, data) + else: + bucket[user_key]['last_throttled'] = now_time + bucket[user_key]['count_throttled'] += 1 + + if bucket[user_key]['count_throttled'] == 1: + bucket[user_key]['now_rate'] = rate + 2 + await self._warn_user(event) + elif bucket[user_key]['count_throttled'] == 2: + bucket[user_key]['now_rate'] = rate + 3 + else: + bucket[user_key]['now_rate'] = rate + 5 + + return None + + # Для сообщений и колбэков держим разные лимиты, чтобы они не мешали друг другу + def _get_bucket(self, event: TelegramObject) -> TTLCache: + if isinstance(event, CallbackQuery): + return self.callback_users + + return self.message_users + + # Предупреждаем там, где это возможно для конкретного типа апдейта + async def _warn_user(self, event: TelegramObject) -> None: + if isinstance(event, Message): + await event.reply("❗ Пожалуйста, не спамьте") + elif isinstance(event, CallbackQuery) or hasattr(event, "answer"): + await event.answer("❗ Пожалуйста, не спамьте", cache_time=1) diff --git a/tgbot/middlewares/middleware_user.py b/tgbot/middlewares/middleware_user.py new file mode 100644 index 0000000..35c97b2 --- /dev/null +++ b/tgbot/middlewares/middleware_user.py @@ -0,0 +1,55 @@ +# - *- coding: utf- 8 - *- +from aiogram import BaseMiddleware +from cachetools import TTLCache + +from tgbot.data.config import BOT_USER_CACHE_TTL +from tgbot.database.db_users import UsersRepository +from tgbot.utils.const_functions import clear_html + + +# Проверка юзера в БД и его добавление/обновление +class ExistsUserMiddleware(BaseMiddleware): + def __init__(self, cache_ttl: int = BOT_USER_CACHE_TTL) -> None: + self.users = UsersRepository() + self.cache = TTLCache(maxsize=10_000, ttl=cache_ttl) + + async def __call__(self, handler, event, data): + this_user = data.get("event_from_user") + + if this_user is not None and not this_user.is_bot: + user_id = this_user.id + user_login = this_user.username or "" + user_name = clear_html(this_user.first_name) + user_surname = clear_html(this_user.last_name) + user_fullname = clear_html(this_user.first_name) + + if user_name is None: user_name = "" + if user_surname is None: user_surname = "" + if user_fullname is None: user_fullname = "" + + if len(user_surname) >= 1: user_fullname += f" {user_surname}" + + user_data = ( + user_login.lower(), + user_name, + user_surname, + user_fullname, + ) + + cached_user = self.cache.get(user_id) + + if cached_user is None or cached_user["data"] != user_data: + user = await self.users.upsert( + user_id=user_id, + user_login=user_data[0], + user_name=user_data[1], + user_surname=user_data[2], + user_fullname=user_data[3], + ) + self.cache[user_id] = {"data": user_data, "user": user} + else: + user = cached_user["user"] + + data['User'] = user + + return await handler(event, data) diff --git a/tgbot/routers/__init__.py b/tgbot/routers/__init__.py new file mode 100644 index 0000000..31c1a92 --- /dev/null +++ b/tgbot/routers/__init__.py @@ -0,0 +1,33 @@ +# - *- coding: utf- 8 - *- +from aiogram import Dispatcher + +from tgbot.routers import main_errors, main_missed, main_start +from tgbot.routers.admin import admin_menu +from tgbot.routers.user import user_menu +from tgbot.utils.misc.bot_filters import IsAdmin, IsPrivate + + +# Подключение всех роутеров +def register_all_routers(dp: Dispatcher): + # Общие фильтры для приватных чатов + main_errors.router.message.filter(IsPrivate()) + main_start.router.message.filter(IsPrivate()) + + user_menu.router.message.filter(IsPrivate()) + user_menu.router.callback_query.filter(IsPrivate()) + admin_menu.router.message.filter(IsPrivate(), IsAdmin()) + admin_menu.router.callback_query.filter(IsPrivate(), IsAdmin()) + + main_missed.router.message.filter(IsPrivate()) + main_missed.router.callback_query.filter(IsPrivate()) + + # Базовые роутеры, которые нужны всегда + dp.include_router(main_errors.router) # Ошибки + dp.include_router(main_start.router) # Старт и главное меню + + # Роутеры для пользователей и админов + dp.include_router(user_menu.router) # Пользовательские обработчики + dp.include_router(admin_menu.router) # Админские обработчики + + # Обработка всего, что не поймали выше + dp.include_router(main_missed.router) # Пропущенные сообщения и колбэки diff --git a/tgbot/routers/admin/__init__.py b/tgbot/routers/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tgbot/routers/admin/admin_menu.py b/tgbot/routers/admin/admin_menu.py new file mode 100644 index 0000000..e1e2836 --- /dev/null +++ b/tgbot/routers/admin/admin_menu.py @@ -0,0 +1,111 @@ +# - *- coding: utf- 8 - *- +from pathlib import Path + +import aiofiles +from aiogram import Router, Bot, F +from aiogram.filters import Command +from aiogram.types import FSInputFile, Message, CallbackQuery +from aiogram.utils.media_group import MediaGroupBuilder + +from tgbot.data.config import BOT_DATABASE_EXPORT, PATH_DATABASE, PATH_LOGS +from tgbot.database.db_users import UserModel +from tgbot.keyboards.inline_main import admin_finl +from tgbot.utils.const_functions import get_date +from tgbot.utils.misc.bot_models import FSM, ARS + +router = Router(name=__name__) +LOGS_DIR = Path(PATH_LOGS).parent +SERVICE_LOG_FILES = ( + LOGS_DIR / "sv_log_err.log", + LOGS_DIR / "sv_log_out.log", +) + + +# Кнопка админского меню +@router.message(F.text == 'Админ-меню') +async def admin_button_inline(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel): + await state.clear() + + await message.answer( + "Инлайн-клавиатура админа", + reply_markup=admin_finl() + ) + + +# Колбэк для демо-действия админа +@router.callback_query(F.data == 'admin_inline_x') +async def admin_callback_inline_x(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: UserModel): + await call.answer("Админское действие выполнено") + + +# Колбэк с параметром из админской кнопки +@router.callback_query(F.data.startswith('admin_inline:')) +async def admin_callback_inline(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: UserModel): + get_data = call.data.split(":")[1] + + await call.answer(f"Выбран админский раздел: {get_data}", True) + + +# Отправка файла базы админам +@router.message(Command(commands=['db', 'database'])) +async def admin_database(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel): + await state.clear() + + if not BOT_DATABASE_EXPORT: + await message.answer("📦 Экспорт базы данных отключён в настройках") + return + + if not Path(PATH_DATABASE).is_file(): + await message.answer("📦 Файл базы данных не найден") + return + + await message.answer_document( + FSInputFile(PATH_DATABASE), + caption=f"📦 #БЭКАП | {get_date()}", + ) + + +# Отправка логов админам +@router.message(Command(commands=['log', 'logs'])) +async def admin_log(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel): + await state.clear() + + log_files = [] + + if Path(PATH_LOGS).is_file(): + log_files.append(PATH_LOGS) + + for log_file in SERVICE_LOG_FILES: + if log_file.is_file(): + log_files.append(str(log_file)) + + caption = f"🖨 #ЛОГИ | {get_date(full=False)}" + + if len(log_files) == 0: + await message.answer("🖨 Логи не найдены") + elif len(log_files) == 1: + await message.answer_document(FSInputFile(log_files[0]), caption=caption) + else: + media_group = MediaGroupBuilder(caption=caption) + + for log_file in log_files: + media_group.add_document(media=FSInputFile(log_file)) + + await message.answer_media_group(media=media_group.build()) + + +# Очистка файлов логов +@router.message(Command(commands=['clear_log', 'clear_logs', 'log_clear', 'logs_clear'])) +async def admin_logs_clear(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel): + await state.clear() + + log_files = [Path(PATH_LOGS), *SERVICE_LOG_FILES] + + for log_file in log_files: + if not log_file.is_file(): + continue + + async with aiofiles.open(log_file, "w", encoding="utf-8") as file: + await file.write(f"{get_date()} | ЛОГИ ОЧИЩЕНЫ") + + await message.answer("🖨 Логи очищены") diff --git a/tgbot/routers/main_errors.py b/tgbot/routers/main_errors.py new file mode 100644 index 0000000..da2c95a --- /dev/null +++ b/tgbot/routers/main_errors.py @@ -0,0 +1,27 @@ +# - *- coding: utf- 8 - *- +from aiogram import Router +from aiogram.exceptions import TelegramBadRequest +from aiogram.filters import ExceptionTypeFilter +from aiogram.handlers import ErrorHandler + +from tgbot.utils.misc.bot_logging import bot_logger + +router = Router(name=__name__) + + +# Ошибка при отправке сообщения пользователю, который заблокировал бота +# @router.errors(ExceptionTypeFilter(TelegramForbiddenError)) +# class ForbiddenErrorHandler(ErrorHandler): +# async def handle(self): +# ... + + +# Безопасно игнорируем повторное редактирование сообщения без изменений +@router.errors(ExceptionTypeFilter(TelegramBadRequest)) +class MessageNotModifiedHandler(ErrorHandler): + async def handle(self): + if "message is not modified" in self.exception_message.lower(): + bot_logger.debug("Telegram отклонил повторное редактирование сообщения без изменений") + return True + + raise self.event diff --git a/tgbot/routers/main_missed.py b/tgbot/routers/main_missed.py new file mode 100644 index 0000000..2ce013b --- /dev/null +++ b/tgbot/routers/main_missed.py @@ -0,0 +1,36 @@ +# - *- coding: utf- 8 - *- +from aiogram import Router, Bot, F +from aiogram.types import CallbackQuery, Message + +from tgbot.database import ModelUsers +from tgbot.utils.const_functions import del_message +from tgbot.utils.misc.bot_models import FSM, ARS + +router = Router(name=__name__) + + +# Колбэк для удаления текущего сообщения +@router.callback_query(F.data == 'close_this') +async def main_callback_close(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: ModelUsers): + await del_message(call.message) + + +# Колбэк-заглушка для пустых кнопок +@router.callback_query(F.data == '...') +async def main_callback_answer(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: ModelUsers): + await call.answer(cache_time=30) + + +# Ответ на колбэк, который никто не обработал +@router.callback_query() +async def main_callback_missed(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: ModelUsers): + await call.answer(f"❗️ Неизвестный колбэк: {call.data}", True) + + +# Ответ на неизвестные сообщения +@router.message() +async def main_message_missed(message: Message, bot: Bot, state: FSM, arSession: ARS, User: ModelUsers): + await message.answer( + "♦️ Неизвестная команда\n" + "♦️ Введите /start", + ) diff --git a/tgbot/routers/main_start.py b/tgbot/routers/main_start.py new file mode 100644 index 0000000..03dbc03 --- /dev/null +++ b/tgbot/routers/main_start.py @@ -0,0 +1,26 @@ +# - *- coding: utf- 8 - *- +from aiogram import Router, Bot, F +from aiogram.filters import Command +from aiogram.types import Message + +from tgbot.database.db_users import UserModel +from tgbot.keyboards.reply_main import menu_frep +from tgbot.utils.const_functions import ded +from tgbot.utils.misc.bot_models import FSM, ARS + +router = Router(name=__name__) + + +# Главное меню пользователя +@router.message(F.text.in_(('menu', 'return', 'start'))) +@router.message(Command(commands=['start'])) +async def main_start(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel): + await state.clear() + + await message.answer( + ded(f""" + 🔸 Привет, {User.user_name} + 🔸 Введи /start или /menu + """), + reply_markup=menu_frep(message.from_user.id), + ) diff --git a/tgbot/routers/user/__init__.py b/tgbot/routers/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tgbot/routers/user/user_menu.py b/tgbot/routers/user/user_menu.py new file mode 100644 index 0000000..2385858 --- /dev/null +++ b/tgbot/routers/user/user_menu.py @@ -0,0 +1,47 @@ +# - *- coding: utf- 8 - *- +from aiogram import Router, Bot, F +from aiogram.filters import Command +from aiogram.types import Message, CallbackQuery + +from tgbot.database.db_users import UserModel +from tgbot.keyboards.inline_main import user_finl +from tgbot.keyboards.reply_main import menu_frep +from tgbot.utils.misc.bot_models import FSM, ARS + +router = Router(name=__name__) + + +# Кнопка пользовательского меню +@router.message(F.text == 'Пользовательское меню') +async def user_button_inline(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel): + await state.clear() + + await message.answer( + "Инлайн-клавиатура пользователя", + reply_markup=user_finl() + ) + + +# Команда возврата в меню +@router.message(Command(commands="menu")) +async def user_command_menu(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel): + await state.clear() + + await message.answer( + "Команда /menu открывает главное меню", + reply_markup=menu_frep(message.from_user.id), + ) + + +# Колбэк для демо-действия +@router.callback_query(F.data == 'user_inline_x') +async def user_callback_inline_x(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: UserModel): + await call.answer("Действие выполнено") + + +# Колбэк с параметром из пользовательской кнопки +@router.callback_query(F.data.startswith('user_inline:')) +async def user_callback_inline(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: UserModel): + get_data = call.data.split(":")[1] + + await call.answer(f"Выбран раздел: {get_data}", True) diff --git a/tgbot/services/__init__.py b/tgbot/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tgbot/services/api_session.py b/tgbot/services/api_session.py new file mode 100644 index 0000000..11c6cf5 --- /dev/null +++ b/tgbot/services/api_session.py @@ -0,0 +1,32 @@ +# - *- coding: utf- 8 - *- +from typing import Optional + +import aiohttp + + +# Пример использования в обработчике +# session = await arSession.get_session() +# response = await session.get(...) +# response = await session.post(...) + +# Асинхронная сессия для запросов +class AsyncRequestSession: + def __init__(self, timeout: int = 30) -> None: + self._session: Optional[aiohttp.ClientSession] = None + self._timeout = aiohttp.ClientTimeout(total=timeout) + + # Получение сессии + async def get_session(self) -> aiohttp.ClientSession: + if self._session is None: + new_session = aiohttp.ClientSession(timeout=self._timeout) + self._session = new_session + + return self._session + + # Закрытие сессии + async def close(self) -> None: + if self._session is None: + return + + await self._session.close() + self._session = None diff --git a/tgbot/utils/__init__.py b/tgbot/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tgbot/utils/const_functions.py b/tgbot/utils/const_functions.py new file mode 100644 index 0000000..f5b07ee --- /dev/null +++ b/tgbot/utils/const_functions.py @@ -0,0 +1,320 @@ +# - *- coding: utf- 8 - *- +import html +import secrets +import string +import textwrap +import time +from datetime import datetime +from typing import List, Optional, Union + +from aiogram import Bot +from aiogram.types import (InlineKeyboardButton, KeyboardButton, WebAppInfo, Message, InlineKeyboardMarkup, + ReplyKeyboardMarkup) +from pytz import timezone + +from tgbot.data.config import get_admins, BOT_TIMEZONE +from tgbot.utils.misc.bot_logging import bot_logger + + +#################################### AIOGRAM ################################### +# Быстрая сборка реплай-кнопки +def rkb(text: str) -> KeyboardButton: + return KeyboardButton(text=text) + + +# Быстрая сборка инлайн-кнопки +def ikb( + text: str, + data: Optional[str] = None, + url: Optional[str] = None, + switch: Optional[str] = None, + web: Optional[str] = None, +) -> InlineKeyboardButton: + if data is not None: + return InlineKeyboardButton(text=text, callback_data=data) + elif url is not None: + return InlineKeyboardButton(text=text, url=url) + elif switch is not None: + return InlineKeyboardButton(text=text, switch_inline_query=switch) + elif web is not None: + return InlineKeyboardButton(text=text, web_app=WebAppInfo(url=web)) + else: + raise ValueError("Не указано действие для инлайн-кнопки") + + +# Удаление сообщения без падения на ошибках Telegram +async def del_message(message: Message): + try: + await message.delete() + except Exception: + bot_logger.debug("Не удалось удалить сообщение", exc_info=True) + + +# Отправка текста с фото, если оно передано или обычным сообщением +async def smart_message( + bot: Bot, + user_id: int, + text: str, + keyboard: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = None, + photo: Optional[str] = None, +): + if photo is not None and photo.title() != "None": + await bot.send_photo( + chat_id=user_id, + photo=photo, + caption=text, + reply_markup=keyboard, + ) + else: + await bot.send_message( + chat_id=user_id, + text=text, + reply_markup=keyboard, + ) + + +# Отправка сообщения всем админам +async def send_admins(bot: Bot, text: str, markup=None, not_me=0): + for admin in get_admins(): + try: + if str(admin) != str(not_me): + await bot.send_message( + admin, + text, + reply_markup=markup, + disable_web_page_preview=True, + ) + except Exception: + bot_logger.warning("Не удалось отправить сообщение админу %s", admin, exc_info=True) + + +################################## РАЗНОЕ ###################################### +# Убирает лишние отступы в многострочном тексте +def ded(get_text: str) -> str: + return textwrap.dedent(get_text or "").strip() + + +# Чистит HTML-символы, чтобы Telegram не сломал разметку +def clear_html(get_text: str) -> str: + return html.escape(get_text or "", quote=False) + + +# Убирает пустые и мусорные элементы из списка +def clear_list(get_list: list) -> list: + trash = {"", " ", ".", ",", "\r", "\n"} + + return [value for value in get_list if value not in trash] + + +# Делит список на части нужного размера +def split_list(get_list: list, count: int) -> List[list]: + return [get_list[i:i + count] for i in range(0, len(get_list), count)] + + +# Возвращает текущую дату, при full=True еще и время +def get_date(full: bool = True) -> str: + bot_timezone = timezone(BOT_TIMEZONE) + + if full: + return datetime.now(bot_timezone).strftime("%d.%m.%Y %H:%M:%S") + else: + return datetime.now(bot_timezone).strftime("%d.%m.%Y") + + +# Возвращает Unix-время: секунды или наносекунды +def get_unix(full: bool = False) -> int: + if full: + return time.time_ns() + else: + return int(time.time()) + + +# Конвертирует дату в Unix и обратно +def convert_date(from_time, full=True, second=True) -> Union[str, int]: + bot_timezone = timezone(BOT_TIMEZONE) + from_time = str(from_time).strip().replace("-", ".") + + if from_time.isdigit(): + from_timestamp = int(from_time) + if full: + to_time = datetime.fromtimestamp(from_timestamp, bot_timezone).strftime("%d.%m.%Y %H:%M:%S") + elif second: + to_time = datetime.fromtimestamp(from_timestamp, bot_timezone).strftime("%d.%m.%Y %H:%M") + else: + to_time = datetime.fromtimestamp(from_timestamp, bot_timezone).strftime("%d.%m.%Y") + else: + parts = from_time.split() + + if len(parts) == 2 and ":" in parts[0]: + time_part, date_part = parts + elif len(parts) == 2: + date_part, time_part = parts + else: + date_part, time_part = from_time, "00:00:00" + + date_values = date_part.split(".") + time_values = time_part.split(":") + + if len(time_values) == 2: + time_values.append("0") + + if len(date_values[0]) == 4: + x_year, x_month, x_day = date_values[0], date_values[1], date_values[2] + else: + x_day, x_month, x_year = date_values[0], date_values[1], date_values[2] + + date_time = datetime( + int(x_year), + int(x_month), + int(x_day), + int(time_values[0]), + int(time_values[1]), + int(time_values[2]), + ) + date_time = bot_timezone.localize(date_time) + to_time = int(date_time.timestamp()) + + return to_time + + +# Генерация числового уникального ID +def gen_id(len_id: int = 16) -> int: + if len_id <= 0: + raise ValueError("Длина ID должна быть больше нуля") + + first_digit = secrets.choice("123456789") + other_digits = "".join(secrets.choice(string.digits) for _ in range(len_id - 1)) + + return int(f"{first_digit}{other_digits}") + + +# Генерация пароля под разные сценарии +def gen_password(len_password: int = 16, type_password: str = "default") -> str: + if len_password <= 0: + raise ValueError("Длина пароля должна быть больше нуля") + + if type_password == "default": + alphabet = string.ascii_letters + string.digits + elif type_password == "letter": + alphabet = string.ascii_letters + elif type_password == "number": + alphabet = string.digits + elif type_password == "onechar": + alphabet = string.digits + else: + raise ValueError("Неизвестный тип пароля") + + random_chars = "".join(secrets.choice(alphabet) for _ in range(len_password)) + + if type_password == "onechar": + random_chars = f"{secrets.choice(string.ascii_letters)}{random_chars[1:]}" + + return random_chars + + +# Склоняет единицы времени под число +def convert_times(get_time: int, get_type: str = "day") -> str: + get_time = int(get_time) + if get_time < 0: get_time = 0 + + if get_type == "second": + get_list = ['секунда', 'секунды', 'секунд'] + elif get_type == "minute": + get_list = ['минута', 'минуты', 'минут'] + elif get_type == "hour": + get_list = ['час', 'часа', 'часов'] + elif get_type == "day": + get_list = ['день', 'дня', 'дней'] + elif get_type == "month": + get_list = ['месяц', 'месяца', 'месяцев'] + else: + get_list = ['год', 'года', 'лет'] + + if get_time % 10 == 1 and get_time % 100 != 11: + count = 0 + elif 2 <= get_time % 10 <= 4 and (get_time % 100 < 10 or get_time % 100 >= 20): + count = 1 + else: + count = 2 + + return f"{get_time} {get_list[count]}" + + +# Приводит строку или число к bool +def is_bool(value: Union[bool, str, int]) -> bool: + value = str(value).strip().lower() + + if value in ('y', 'yes', 't', 'true', 'on', '1'): + return True + elif value in ('n', 'no', 'f', 'false', 'off', '0'): + return False + else: + raise ValueError(f"Некорректное bool-значение: {value}") + + +################################### ЧИСЛА ###################################### +# Приводит число к читаемой строке без лишних нулей +def snum(amount: Union[int, float], remains: int = 2) -> str: + format_str = "{:." + str(remains) + "f}" + str_amount = format_str.format(float(amount)) + + if remains != 0: + if "." in str_amount: + remains_find = str_amount.find(".") + remains_save = remains_find + 8 - (8 - remains) + 1 + + str_amount = str_amount[:remains_save] + + if "." in str(str_amount): + while str(str_amount).endswith('0'): str_amount = str(str_amount)[:-1] + + if str(str_amount).endswith('.'): str_amount = str(str_amount)[:-1] + + return str(str_amount) + + +# Приводит входное значение к int или float +def to_float(get_number, remains: int = 2) -> Union[int, float]: + value = str(get_number).strip().replace(" ", "").replace(",", ".") + number = round(float(value), remains) + + if number.is_integer(): + return int(number) + + return number + + +# Округляет число до int +def to_int(get_number: float) -> int: + if "," in str(get_number): + get_number = str(get_number).replace(",", ".") + + get_number = int(round(float(get_number))) + + return get_number + + +# Проверяет, является ли значение числом +def is_number(get_number: Union[str, int, float]) -> bool: + if str(get_number).isdigit(): + return True + else: + if "," in str(get_number): get_number = str(get_number).replace(",", ".") + + try: + float(get_number) + return True + except (TypeError, ValueError): + return False + + +# Форматирует число с разделением тысяч +def format_rate(amount: Union[float, int], around: int = 2) -> str: + value = str(amount).strip().replace(" ", "").replace(",", ".") + number = round(float(value), around) + response = f"{number:,.{around}f}".replace(",", " ") + + if "." in response: + response = response.rstrip("0").rstrip(".") + + return response diff --git a/tgbot/utils/misc/__init__.py b/tgbot/utils/misc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tgbot/utils/misc/bot_commands.py b/tgbot/utils/misc/bot_commands.py new file mode 100644 index 0000000..d41e556 --- /dev/null +++ b/tgbot/utils/misc/bot_commands.py @@ -0,0 +1,33 @@ +# - *- coding: utf- 8 - *- +from aiogram import Bot +from aiogram.types import BotCommand, BotCommandScopeChat, BotCommandScopeDefault + +from tgbot.data.config import BOT_DATABASE_EXPORT, get_admins +from tgbot.utils.misc.bot_logging import bot_logger + +# Команды для обычных пользователей +user_commands = [ + BotCommand(command="start", description="♻️ Перезапуск бота"), + BotCommand(command="menu", description="🌀 Получение клавиатуры"), +] + +# Команды для админов +admin_commands = [ + BotCommand(command="start", description="♻️ Перезапуск бота"), + BotCommand(command="menu", description="🌀 Получение клавиатуры"), + BotCommand(command="log", description="🖨 Получить логи"), +] + +if BOT_DATABASE_EXPORT: + admin_commands.append(BotCommand(command="db", description="📦 Получить БД")) + + +# Обновление списка команд в Telegram +async def set_commands(bot: Bot): + await bot.set_my_commands(user_commands, scope=BotCommandScopeDefault()) + + for admin in get_admins(): + try: + await bot.set_my_commands(admin_commands, scope=BotCommandScopeChat(chat_id=admin)) + except Exception: + bot_logger.warning("Не удалось обновить команды для админа %s", admin, exc_info=True) diff --git a/tgbot/utils/misc/bot_filters.py b/tgbot/utils/misc/bot_filters.py new file mode 100644 index 0000000..8cd88f4 --- /dev/null +++ b/tgbot/utils/misc/bot_filters.py @@ -0,0 +1,30 @@ +# - *- coding: utf- 8 - *- +from typing import Union + +from aiogram.filters import BaseFilter +from aiogram.types import CallbackQuery, Message + +from tgbot.data.config import get_admins + + +# Проверка, что действия совершает админ +class IsAdmin(BaseFilter): + async def __call__(self, event: Union[Message, CallbackQuery]) -> bool: + user = getattr(event, "from_user", None) + + return bool(user and user.id in get_admins()) + + +# Проверка приватного чата +class IsPrivate(BaseFilter): + async def __call__(self, event: Union[Message, CallbackQuery]) -> bool: + chat = getattr(event, "chat", None) + message = getattr(event, "message", None) + + if chat is None and message is not None: + chat = message.chat + + if chat is None: + return True + + return chat.type == "private" diff --git a/tgbot/utils/misc/bot_logging.py b/tgbot/utils/misc/bot_logging.py new file mode 100644 index 0000000..6fb5dc3 --- /dev/null +++ b/tgbot/utils/misc/bot_logging.py @@ -0,0 +1,58 @@ +# - *- coding: utf- 8 - *- +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path + +import colorlog + +from tgbot.data.config import PATH_LOGS + +LOG_FILE_MAX_BYTES = 5 * 1024 * 1024 +LOG_FILE_BACKUP_COUNT = 5 + +# Папка под логи создается сама, чтобы бот не падал на старте +log_path = Path(PATH_LOGS) +log_path.parent.mkdir(parents=True, exist_ok=True) + +# Один общий логгер для всего шаблона +bot_logger = logging.getLogger("tgbot") +bot_logger.setLevel(logging.INFO) +bot_logger.propagate = False + +if not bot_logger.handlers: + # Формат для файла: без цветов + file_formatter = logging.Formatter( + "%(levelname)s | %(asctime)s | %(name)s | %(filename)s:%(lineno)d | %(message)s", + datefmt="%d-%m-%Y %H:%M:%S", + ) + # Формат для консоли: коротко и с цветами + console_formatter = colorlog.ColoredFormatter( + "%(log_color)s%(levelname)s%(reset)s | %(blue)s%(asctime)s%(reset)s | " + "%(purple)s%(filename)s:%(lineno)d%(reset)s | %(message)s", + datefmt="%d-%m-%Y %H:%M:%S", + log_colors={ + "DEBUG": "cyan", + "INFO": "green", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "bold_red", + }, + ) + + # Не очищаем файл, а крутим по размеру + file_handler = RotatingFileHandler( + log_path, + maxBytes=LOG_FILE_MAX_BYTES, + backupCount=LOG_FILE_BACKUP_COUNT, + encoding="utf-8", + ) + file_handler.setFormatter(file_formatter) + file_handler.setLevel(logging.INFO) + + # В консоль выводим то, что важно видеть сразу + console_handler = logging.StreamHandler() + console_handler.setFormatter(console_formatter) + console_handler.setLevel(logging.INFO) + + bot_logger.addHandler(file_handler) + bot_logger.addHandler(console_handler) diff --git a/tgbot/utils/misc/bot_models.py b/tgbot/utils/misc/bot_models.py new file mode 100644 index 0000000..22e4afb --- /dev/null +++ b/tgbot/utils/misc/bot_models.py @@ -0,0 +1,8 @@ +# - *- coding: utf- 8 - *- +from aiogram.fsm.context import FSMContext + +from tgbot.services.api_session import AsyncRequestSession + +# Короткие алиасы для типизации обработчиков +FSM = FSMContext +ARS = AsyncRequestSession diff --git a/tgbot/utils/misc_functions.py b/tgbot/utils/misc_functions.py new file mode 100644 index 0000000..8a72f65 --- /dev/null +++ b/tgbot/utils/misc_functions.py @@ -0,0 +1,29 @@ +# - *- coding: utf- 8 - *- +from aiogram import Bot +from aiogram.types import FSInputFile + +from tgbot.data.config import BOT_DATABASE_EXPORT, BOT_STATUS_NOTIFICATION, PATH_DATABASE, get_admins +from tgbot.utils.const_functions import get_date, send_admins +from tgbot.utils.misc.bot_logging import bot_logger + + +# Уведомление админам после запуска +async def startup_notify(bot: Bot): + if len(get_admins()) >= 1 and BOT_STATUS_NOTIFICATION: + await send_admins(bot, "✅ Бот запущен") + + +# Автобэкап базы для админов +async def autobackup_admin(bot: Bot): + if not BOT_DATABASE_EXPORT: + return + + for admin in get_admins(): + try: + await bot.send_document( + admin, + FSInputFile(PATH_DATABASE), + caption=f"📦 #АВТОБЭКАП | {get_date()}", + ) + except Exception: + bot_logger.warning("Не удалось отправить автобэкап админу %s", admin, exc_info=True)