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 index 841ed68..fa42e6c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,189 +1,52 @@ -# Byte-compiled / optimized / DLL files +# Python __pycache__/ *.py[cod] *$py.class - -# C extensions *.so - -# Distribution / packaging .Python + +# Виртуальное окружение +.venv/ +venv/ env/ +ENV/ +pyvenv.cfg + +# Зависимости и сборка build/ -develop-eggs/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ *.egg-info/ -.installed.cfg +.eggs/ *.egg -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*,cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# IPython Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# dotenv +# Локальные настройки и секреты .env - -# virtualenv -venv/ -ENV/ - -# Spyder project settings -.spyderproject - -# Rope project settings -.ropeproject -### VirtualEnv template -# Virtualenv -# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ -[Bb]in -[Ii]nclude -[Ll]ib -[Ll]ib64 -[Ll]ocal -[Ss]cripts -pyvenv.cfg -.venv -pip-selfcheck.json - -### JetBrains template -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -auto-import. -.idea/artifacts -.idea/compiler.xml -.idea/jarRepositories.xml -.idea/modules.xml -.idea/*.iml -.idea/modules -*.iml -*.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# SonarLint plugin -.idea/sonarlint/ - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser - -# idea folder, uncomment if you don't need it -.idea - -# Users exceptions -/other/ -/tgbot/data/database.db -/tgbot/data/logs.log +.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 index 1420530..66fd9f6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,302 @@ -### [![Python](https://img.shields.io/badge/Python-3.10-blue)](https://www.python.org/downloads/release/python-399/) [![Aiogram](https://img.shields.io/badge/aiogram-3.4.1-blue)](https://pypi.org/project/aiogram/) [![Last UPD](https://img.shields.io/badge/LastUPD-10.10.2025-blue)](https://example.com/) +# Djimbo Template для Telegram-ботов на aiogram 3 -# Template telegram bot for Aiogram 3 by Djimbo +Шаблон для быстрого старта 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 index 7d320b2..4af1a2c 100644 --- a/dj_bot.conf +++ b/dj_bot.conf @@ -1,12 +1,13 @@ -# supervisorctl +# Пример конфига для supervisorctl [program:dj_bot] -directory=/root/djimbo_template/ -command=python3.10 main.py +directory=/path/to/djimbo_template/ +command=python3.11 -u main.py +environment=PYTHONUNBUFFERED="1" autostart=True autorestart=True -stderr_logfile=/root/djimbo_template/tgbot/data/sv_log_err.log -; stderr_logfile_maxbytes=10MB -stdout_logfile=/root/djimbo_template/tgbot/data/sv_log_out.log -; stdout_logfile_maxbytes=10MB \ No newline at end of file +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 index 4005909..d33799b 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,5 @@ # - *- coding: utf- 8 - *- import asyncio -import os import sys import colorama @@ -8,28 +7,52 @@ from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode -from tgbot.data.config import BOT_TOKEN, BOT_SCHEDULER, get_admins -from tgbot.database.adb_helper import database_initialization -from tgbot.middlewares import register_all_middlwares +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() -# Start schedulers +# Запуск задач по расписанию async def scheduler_start(bot): - BOT_SCHEDULER.add_job(autobackup_admin, trigger="cron", hour=00, args=(bot,)) # Ежедневный Автобэкап в 00:00 + 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() -# Start bot and basic functions +# Запуск бота и базовой обвязки async def main(): - BOT_SCHEDULER.start() # Start scheduler - dp = Dispatcher() # Dispatcher image - arSession = AsyncRequestSession() # Async session pool (aiohttp) + validate_bot_config() + await prepare_database() # Проверка готовности БД + + dp = Dispatcher() # Диспетчер событий + arSession = AsyncRequestSession() # Общая сессия aiohttp bot = Bot( # Образ Бота token=BOT_TOKEN, @@ -38,44 +61,43 @@ async def main(): ), ) - register_all_middlwares(dp) # Register all middlewares - register_all_routers(dp) # Register all routers + register_all_middlewares(dp) # Подключение мидлварей + register_all_routers(dp) # Подключение роутера try: - await set_commands(bot) # Set commands for users - await startup_notify(bot) # Notification that bot was started - await scheduler_start(bot) # Connect schedulers + await set_commands(bot) # Обновление команды в Telegram + await startup_notify(bot) # Сообщаем админам о старте + await scheduler_start(bot) # Подключение задач по расписанию - bot_logger.warning("BOT WAS STARTED") - print(colorama.Fore.LIGHTYELLOW_EX + f"~~~~~ Bot was started - @{(await bot.get_me()).username} ~~~~~") + 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("***** ENTER ADMIN ID IN settings.ini *****") + if len(get_admins()) == 0: + print("***** УКАЖИТЕ BOT_ADMIN_IDS В .env *****") - await bot.delete_webhook() # Deletes webhooks, if they was have - await bot.get_updates(offset=-1) # Reset update pengings + await bot.delete_webhook() # Сбрасывание вебхука, если он был + await bot.get_updates(offset=-1) # Чистка старых апдейтов - # Run bot (polling method) + # Запуск бота (polling режим) await dp.start_polling( bot, arSession=arSession, allowed_updates=dp.resolve_used_update_types(), ) finally: - await arSession.close() # Close async session (aiohttp) - await bot.session.close() # Close bot session + 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__": - database_initialization() # Initializate Database, tables and columns - try: asyncio.run(main()) except (KeyboardInterrupt, SystemExit): - bot_logger.warning("Bot was stopped") - finally: - if sys.platform.startswith("win"): - os.system("cls") - else: - os.system("clear") + 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 index ffc88b3..3fd11d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,13 @@ -APScheduler==3.11.0 -aiogram==3.22.0 -colorlog==6.9.0 -pytz==2025.2 -DateTime==5.5 -typing==3.7.4.3 +APScheduler==3.11.1 +aiogram==3.28.2 aiofiles==25.1.0 -colorama==0.4.6 \ No newline at end of file +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/settings.ini b/settings.ini deleted file mode 100644 index 7151e81..0000000 --- a/settings.ini +++ /dev/null @@ -1,3 +0,0 @@ -[settings] -bot_token= -admin_id= diff --git a/tgbot/data/config.py b/tgbot/data/config.py index 8743eef..3326f28 100644 --- a/tgbot/data/config.py +++ b/tgbot/data/config.py @@ -1,42 +1,128 @@ # - *- coding: utf- 8 - *- -import configparser +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 -# Токен бота -BOT_TOKEN = configparser.ConfigParser() -BOT_TOKEN.read("settings.ini") -BOT_TOKEN = BOT_TOKEN['settings']['bot_token'].strip().replace(' ', '') +BASE_DIR = Path(__file__).resolve().parents[2] +ENV_PATH = BASE_DIR / ".env" -# Пути к файлам -PATH_DATABASE = "tgbot/data/database.db" # Путь к БД -PATH_LOGS = "tgbot/data/logs.log" # Путь к Логам -# Образы и конфиги -BOT_STATUS_NOTIFICATION = True # Оповещение админам о запуске бота (True или False) -BOT_TIMEZONE = "Europe/Moscow" # Временная зона бота -BOT_SCHEDULER = AsyncIOScheduler(timezone=BOT_TIMEZONE) # Образ шедулера +# Все настройки из окружения или локального .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]: - read_admins = configparser.ConfigParser() - read_admins.read('settings.ini') +def get_admins() -> List[int]: + return settings.admins - admins = read_admins['settings']['admin_id'].strip().replace(" ", "") - if "," in admins: - admins = admins.split(",") - else: - if len(admins) >= 1: - admins = [admins] - else: - admins = [] - - while "" in admins: admins.remove("") - while " " in admins: admins.remove(" ") - while "," in admins: admins.remove(",") - while "\r" in admins: admins.remove("\r") - while "\n" in admins: admins.remove("\n") - - return list(map(int, 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 index 026e5ee..7a5da8b 100644 --- a/tgbot/database/__init__.py +++ b/tgbot/database/__init__.py @@ -1,2 +1,5 @@ -from .db_settings import Settingsx, ModelBase as ModelSettings -from .db_users import Usersx, ModelBase as ModelUsers +from .db_settings import SettingsModel, SettingsRepository +from .db_users import UserModel, UsersRepository + +ModelSettings = SettingsModel +ModelUsers = UserModel diff --git a/tgbot/database/adb_helper.py b/tgbot/database/adb_helper.py deleted file mode 100644 index f0ec111..0000000 --- a/tgbot/database/adb_helper.py +++ /dev/null @@ -1,494 +0,0 @@ -# - *- coding: utf- 8 - *- -import sqlite3 -from typing import Any, Generic, TypeVar, Optional - -from pydantic.fields import FieldInfo -from pydantic_core import PydanticUndefinedType - -from tgbot.data.config import PATH_DATABASE -from tgbot.database.adb_migration import CONST_MIGRATE_TABLES, CONST_MIGRATE_COLUMNS -from tgbot.utils.const_functions import ded - - -# noinspection PyProtectedMember - -################################################################################ -############################### SUPPORT FUNCTIONS ############################## -# Converting the resulting list into a dictionary -def dict_factory(cursor, row) -> dict: - save_dict = {} - - for idx, col in enumerate(cursor.description): - save_dict[col[0]] = row[idx] - - return save_dict - - -# Formatting a query without arguments -def update_format(sql, parameters: dict) -> tuple[str, list]: - values = ", ".join([ - f"{item} = ?" for item in parameters - ]) - sql += f" {values}" - - return sql, list(parameters.values()) - - -# Formatting a query with arguments -def update_format_where(sql, parameters: dict) -> tuple[str, list]: - sql += " WHERE " - - sql += " AND ".join([ - f"{item} = ?" for item in parameters - ]) - - return sql, list(parameters.values()) - - -################################################################################ -################# BASIC CLASS FOR WORKING WITH DATABASE TABLES ################# -ModelTranslator = TypeVar("ModelTranslator", bound="ModelBase") - - -class Databasex(Generic[ModelTranslator]): - def __init__(self): - self.storage_name = "storage" - self.column_one = False - self.table_model = None - - # Delete entry - def delete(self, **kwargs): - with sqlite3.connect(PATH_DATABASE) as con: - con.row_factory = dict_factory - sql = f"DELETE FROM {self.storage_name}" - sql, parameters = update_format_where(sql, kwargs) - - con.execute(sql, parameters) - - # Clear all entries - def clear(self): - with sqlite3.connect(PATH_DATABASE) as con: - con.row_factory = dict_factory - sql = f"DELETE FROM {self.storage_name}" - - con.execute(sql) - - # Get entry - def get(self, **kwargs) -> Optional[ModelTranslator]: - with sqlite3.connect(PATH_DATABASE) as con: - con.row_factory = dict_factory - sql = f"SELECT * FROM {self.storage_name}" - sql, parameters = update_format_where(sql, kwargs) - - response = con.execute(sql, parameters).fetchone() - - if response is not None: - response = self.table_model(**response) - - return response - - # Get entries - def gets(self, **kwargs) -> list[Optional[ModelTranslator]]: - with sqlite3.connect(PATH_DATABASE) as con: - con.row_factory = dict_factory - sql = f"SELECT * FROM {self.storage_name}" - sql, parameters = update_format_where(sql, kwargs) - - response = con.execute(sql, parameters).fetchall() - - return [self.table_model(**cache_object) for cache_object in response] - - # Get all entries - def get_all(self) -> list[Optional[ModelTranslator]]: - with sqlite3.connect(PATH_DATABASE) as con: - con.row_factory = dict_factory - sql = f"SELECT * FROM {self.storage_name}" - - response = con.execute(sql).fetchall() - - return [self.table_model(**cache_object) for cache_object in response] - - # Edit entry - def update(self, **kwargs): - ... - - # Export column names, default data, and type hints - def fields(self) -> dict[str, list[dict[str, Any]]]: - for field_name, field_data in self.table_model.model_fields.items(): - field_name: str - field_data: FieldInfo - - column_data = [ - { - 'column': field_name, - 'type': field_data.annotation, - 'value': field_data.default, - } for field_name, field_data in self.table_model.model_fields.items() - ] - - return {'column_data': column_data, 'column_one': self.column_one} - - -################################################################################ -################################################################################ -# Export all tables from the Database to a list -def database_export_fields_db() -> list: - with sqlite3.connect(PATH_DATABASE) as con: - con.row_factory = dict_factory - - database_tables = con.execute("SELECT * FROM sqlite_master where type='table'").fetchall() - database_tables_db = [table['name'] for table in database_tables] - - # If there are service tables, remove them from the list of available tables. - if "sqlite_sequence" in database_tables_db: - database_tables_db.remove("sqlite_sequence") - if "sqlite_master" in database_tables_db: - database_tables_db.remove("sqlite_master") - - return database_tables_db - - -# Export all tables, columns, and typehints from structures -def database_export_fields_structure() -> dict: - # Import all table models - import tgbot.database as import_modeles - - # Storing all structure files (db_X.py) in a single list - all_tables = [ - getattr(import_modeles, name) - for name in dir(import_modeles) - if ( - isinstance(getattr(import_modeles, name), type) and - (name.istitle() and name.endswith("x")) - ) - ] - - save_tables = {} # Storage of all sorted and converted tables/columns - - # Iterating through tables and columns - for table in all_tables: - table_fields = table().fields() - - column_data = table_fields['column_data'] - column_one = table_fields['column_one'] - - cache_columns = [] - - # If the table name is not in the dictionary, add it. - if table().storage_name not in save_tables: - save_tables[table().storage_name] = { - 'column_one': column_one, - 'column_datas': [], - } - - # Column sorting - for table_field in column_data: - column_name = table_field['column'] - column_type = table_field['type'].__name__ - column_value = table_field['value'] - - # Replacing python date types with SQL - column_type = column_type.replace( - "str", "TEXT", - ).replace( - "int", "INTEGER", - ).replace( - "float", "REAL", - ) - - # If there is no default value, replace it with - None - if isinstance(column_value, PydanticUndefinedType): - column_value = None - - # Default values based on type hints - if column_type == "TEXT": - if "status" in column_name.lower(): - column_value = "False" - else: - column_value = "None" - elif column_type == "INTEGER": - column_value = 0 - elif column_type == "REAL": - column_value = 0.0 - else: - column_value = table_field['value'] - - save_tables[table().storage_name]['column_datas'].append( - { - 'column': column_name, - 'type': column_type, - 'value': column_value, - } - ) - - return save_tables - - -################################################################################ -################################################################################ -# Database migration -def database_migration(): - with sqlite3.connect(PATH_DATABASE) as con: - con.row_factory = dict_factory - - # If there are values for tables or columns, perform migration. - if len(CONST_MIGRATE_TABLES) > 0 or len(CONST_MIGRATE_COLUMNS) > 0: - print(f"|| Migrating | Data was found") - - # Export existing tables in the Database - database_tables_db = database_export_fields_db() - - # Renaming tables - iterating through tables - for table_name_later, table_name_new in CONST_MIGRATE_TABLES.items(): - if table_name_later in database_tables_db: - print(f"|| Migrating | Table '{table_name_later}' renamed to '{table_name_new}' (✓)") - con.execute(f"ALTER TABLE {table_name_later} RENAME TO {table_name_new}") - else: - print(f"|| Migrating | Table '{table_name_later}' for renamed not found (X)") - - # Renaming columns - iterating through tables with columns inside - for table_name in CONST_MIGRATE_COLUMNS: - if table_name in database_tables_db: - # Unloading existing columns from the current table - columns_export = [ - column['name'] for column in con.execute(f"PRAGMA table_info({table_name})").fetchall() - ] - - # Browsing columns to rename them - for column_name_later, column_name_new in CONST_MIGRATE_COLUMNS[table_name].items(): - if column_name_later in columns_export: - print( - f"|| Migrating | Column '{column_name_later}' renamed to '{column_name_new}' from table '{table_name}' (✓)" - ) - con.execute( - f"ALTER TABLE {table_name} RENAME COLUMN {column_name_later} TO {column_name_new}" - ) - else: - print( - f"|| Migrating | Column '{column_name_later}' from table '{table_name}' not found (X)" - ) - else: - print(f"|| Migrating | Table '{table_name}' for renamed column(s) not found (X)") - - else: - print(f"|| Migrating | Data not found") - - print(f"||") - - -# Checking the relevance of columns (adding new ones, deleting old ones) -def database_check_columns(): - with sqlite3.connect(PATH_DATABASE) as con: - con.row_factory = dict_factory - - symbol_log = True - - # Exporting tables and columns from structures - fields_structure = database_export_fields_structure() - - for number, field in enumerate(fields_structure.items()): - table_name: str = field[0] # Table name - table_data: dict = field[1] # Table data - - column_datas = table_data['column_datas'] # Column name, type, and value - - columns_cache_add = [] # Columns that will be added to the database - columns_cache_edit_columns = [] # Columns whose date types will be changed in the database - columns_cache_edit_data = None # Which table columns will change date types in the database? - - # Unloading existing columns from the current table - column_datas_types_db = [column for column in con.execute(f"PRAGMA table_info({table_name})").fetchall()] - - # Unloading existing column names from the current table - columns_export_db = [column['name'] for column in column_datas_types_db] - - # Iterating through columns from a structure - for column_structure in column_datas: - # If a column from the structure is present in the database table - if column_structure['column'] in columns_export_db: - columns_export_db.remove(column_structure['column']) - - # Date type of the current column in the Structure - column_type_structure = column_structure['type'] - - # Date type of the current column in the database - column_type_database = [ - column for column in con.execute(f"PRAGMA table_info({table_name})").fetchall() if - column_structure['column'] == column['name'] - ] - - # If the date column type (DB) is in the list, extract it. - if len(column_type_database) > 0: - column_type_database = column_type_database[0]['type'] - - # If the date types differ and have not been saved before, add them to the dictionary. - if column_type_structure != column_type_database: - if columns_cache_edit_data is None: - columns_cache_edit_data = column_datas - - if column_structure['column'] not in columns_cache_edit_columns: - columns_cache_edit_columns.append( - f"{column_structure['column']}: {column_type_database} -> {column_type_structure} " - ) - else: - # If a column from the structure is not present in the database, save it for addition. - columns_cache_add.append(column_structure) - - # The remaining columns that are present in the database table but are missing from the structure - columns_cache_delete = columns_export_db - - # Adding new columns - for column in columns_cache_add: - print(f"|| Modification | Column '{column['column']}' added to table '{table_name}'") - con.execute( - f"ALTER TABLE {table_name} ADD COLUMN {column['column']} {column['type']} DEFAULT '{column['value']}'" - ) - - # Deleting existing obsolete columns - for column in columns_cache_delete: - print(f"|| Modification | Column '{column}' deleted from table '{table_name}'") - con.execute(f"ALTER TABLE {table_name} DROP COLUMN {column}") - - # Changing the date types of columns in a table - sql_edit_columns_create_table = [] - sql_edit_columns_insert_values = [] - columns_cache_edit_columns = ", ".join(columns_cache_edit_columns) - - if columns_cache_edit_data is not None: - for column in columns_cache_edit_data: - if column['type'] == "TEXT": - sql_edit_columns_create_table.append( - f"{column['column']} {column['type']} DEFAULT '{column['value']}'" - ) - else: - sql_edit_columns_create_table.append( - f"{column['column']} {column['type']} DEFAULT {column['value']}" - ) - - sql_edit_columns_insert_values.append(column['column']) - - sql_edit_columns_create_table = ", ".join(sql_edit_columns_create_table) - sql_edit_columns_create_table = f"CREATE TABLE {table_name}_new ({sql_edit_columns_create_table})" - - sql_edit_columns_insert_values = ", ".join(sql_edit_columns_insert_values) - sql_edit_columns_insert_values = f"INSERT INTO {table_name}_new ({sql_edit_columns_insert_values}) SELECT {sql_edit_columns_insert_values} FROM {table_name}" - - con.execute(sql_edit_columns_create_table) # Creating a new table - con.execute(sql_edit_columns_insert_values) # Transferring data from old columns to new ones - con.execute(f"DROP TABLE {table_name};") - con.execute(f"ALTER TABLE {table_name}_new RENAME TO {table_name};") - - print( - f"|| Modification | Columns '{columns_cache_edit_columns}' was edit data type for table '{table_name}'" - ) - - -# Creating/deleting tables, columns, and records -def database_initialization(): - print("======== DATABASE PROCESSING ========") - database_migration() # Migration check - - with sqlite3.connect(PATH_DATABASE) as con: - con.row_factory = dict_factory - - # List of exported tables and columns from structures - database_fields = database_export_fields_structure() - - # List of exported tables from the structure - database_tables_import = [table for table in database_fields.keys()] - - # List of tables available in the Database - database_tables_db = database_export_fields_db() - - # List of tables to be deleted that are not present in imports but are present in the database - database_tables_delete = [ - table for table in database_tables_db if table not in database_tables_import - ] - - # Browse the list of imported tables, columns, and default records to create them. - for number, db_field in enumerate(database_fields.items()): - table_name: str = db_field[0] # Table name - table_data: dict = db_field[1] # Table data - - column_one = table_data['column_one'] # Will the table have one record or more - column_datas = table_data['column_datas'] # Column name, data type, and value - column_len = len(column_datas) # Length (number) of all columns in the table - - # Checking for the required number of records in a table - if len(con.execute(f"PRAGMA table_info({table_name})").fetchall()) == column_len: - print(f"|| Creating | Table '{table_name}' was found ({number + 1}/{len(database_fields)})") - else: - print(f"|| Creating | Table '{table_name}' not found ({number + 1}/{len(database_fields)})") - - # Column names with type hints (user_id INTEGER, user_name TEXT) - columns_cache_create = [] - - # Column names without type hints (user_id, user_name) - columns_cache_insert = [] - - # Default data for automatic entry into columns (settings, payment systems) - columns_values = [] - - ############################################################ - ##################### CREATING TABLES ###################### - for column in column_datas: - column_name = column['column'] # Table name - column_value = column['value'] # Default value in column - column_type = column['type'] # Entry data type (INTEGER, TEXT, REAL, ...) - - # Incrementable entry check - if column_name == "increment": - column_execute_create = "increment INTEGER PRIMARY KEY AUTOINCREMENT" - else: - column_execute_create = f"{column_name} {column_type}" - - columns_cache_insert.append(column_name) - columns_cache_create.append(column_execute_create) - - # If default data is available, add it. - if column_one and column_value is not None: - columns_values.append(column_value) - - # Collecting a request with all added columns - column_execute_create = ",\n".join(columns_cache_create) - column_execute_create = ded(f""" - CREATE TABLE IF NOT EXISTS {table_name}( - {column_execute_create} - ) - """) - - # Request to create a table and columns - con.execute(column_execute_create) - - ############################################################ - ####################### ADDING ENTRY ####################### - # If there is a record to add and the table supports only one record - if column_one and len(columns_values) > 0: - column_check_have = con.execute(f"SELECT * FROM {table_name}").fetchall() - - # If there is no entry in the table, add it. - if len(column_check_have) == 0: - # Request collection - column_execute_insert = ",\n".join(columns_cache_insert) - column_execute_insert = ded(f""" - INSERT INTO {table_name}( - {column_execute_insert} - ) - VALUES ({"?," * (len(columns_cache_insert) - 1) + "?"}) - """) - - # Request to add entries to the table - con.execute(column_execute_insert, columns_values) - - print(f"|| Creating | Table '{table_name}' was created ({number + 1}/{len(database_fields)})") - - # If there are tables to be deleted, delete them. - if len(database_tables_delete) > 0: - for table_name in database_tables_delete: - print(f"|| Database | Table '{table_name}' was deleted") - con.execute(f"DROP TABLE {table_name}") - - print("||") - database_check_columns() # Checking the relevance of columns - print("=====================================") - print() diff --git a/tgbot/database/adb_migration.py b/tgbot/database/adb_migration.py deleted file mode 100644 index 7b4961c..0000000 --- a/tgbot/database/adb_migration.py +++ /dev/null @@ -1,36 +0,0 @@ -# Example with migration - Tables -# -# CONST_MIGRATE_TABLES = { -# 'old_table_name1': 'new_table_name1', -# 'old_table_name2': 'new_table_name2', -# } - - -# Example with migration - Columns -# CONST_MIGRATE_COLUMNS = { -# 'name_of_the_table_in_which_the_column_is_located1': { -# 'old_column_name11': 'new_column_name11', -# 'old_column_name12': 'new_column_name12', -# }, -# 'name_of_the_table_in_which_the_column_is_located2': { -# 'old_column_name21': 'new_column_name21', -# 'old_column_name22': 'new_column_name22', -# }, -# } - - -################################################################################ -############################### MIGRATION TABLES ############################### -CONST_MIGRATE_TABLES = { - # 'storage_refillme': 'storage_refill', - # 'storage_purchasesme': 'storage_purchases', -} - -################################################################################ -############################### MIGRATION COLUMNS ############################## -CONST_MIGRATE_COLUMNS = { - # 'storage_refill': { - # 'refill_methodme': 'refill_method', - # 'refill_amountme': 'refill_amount', - # }, -} 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 index 5e0d103..9fab138 100644 --- a/tgbot/database/db_settings.py +++ b/tgbot/database/db_settings.py @@ -1,43 +1,61 @@ # - *- coding: utf- 8 - *- -import sqlite3 +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 pydantic import BaseModel - -from tgbot.data.config import PATH_DATABASE -from tgbot.database.adb_helper import Databasex, dict_factory, update_format +from tgbot.database.core import Base, session_scope +from tgbot.database.repository import BaseRepository -# Table model -class ModelBase(BaseModel): - status_work: str # Status bot work +# Модель настроек бота +class SettingsModel(Base): + __tablename__ = "storage_settings" - class Config: - json_schema_extra = { - 'storage_name': 'storage_settings' - } + id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) + status_work: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) -# Settings -class Settingsx(Databasex[ModelBase]): +ModelBase = SettingsModel +BaseModel = SettingsModel + + +# Репозиторий настроек бота +class SettingsRepository(BaseRepository[SettingsModel]): def __init__(self): super().__init__() - self.table_model = ModelBase - self.storage_name = self.table_model.model_json_schema()['storage_name'] - self.column_one = True + self.table_model = SettingsModel + self.storage_name = SettingsModel.__tablename__ - # Get entry - def get(self) -> ModelBase: - with sqlite3.connect(PATH_DATABASE) as con: - con.row_factory = dict_factory - sql = f"SELECT * FROM {self.storage_name}" + # Создание строки настроек если их еще нет + 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]) - return ModelBase(**con.execute(sql).fetchone()) + async with session_scope() as session: + await session.execute(statement) - # Edit entry - def update(self, **kwargs): - with sqlite3.connect(PATH_DATABASE) as con: - con.row_factory = dict_factory - sql = f"UPDATE {self.storage_name} SET" - sql, parameters = update_format(sql, kwargs) + # Настройки должны быть всегда, поэтому при пустой таблице создаем дефолт + async def get(self) -> SettingsModel: + settings = await super().get(id=1) - con.execute(sql, parameters) + 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 index e8713a4..4c0aaba 100644 --- a/tgbot/database/db_users.py +++ b/tgbot/database/db_users.py @@ -1,78 +1,106 @@ # - *- coding: utf- 8 - *- -import sqlite3 +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 pydantic import BaseModel - -from tgbot.data.config import PATH_DATABASE -from tgbot.database.adb_helper import Databasex, dict_factory, update_format -from tgbot.utils.const_functions import get_unix, ded +from tgbot.database.core import Base, session_scope +from tgbot.database.repository import BaseRepository +from tgbot.utils.const_functions import get_unix -# Table model -class ModelBase(BaseModel): - increment: int # increment - user_id: int # User id - user_login: str # User username - user_name: str # User name - user_surname: str # User surname - user_fullname: str # User fullname (name + surname) - user_unix: int # Date of user registration in the bot (in UNIX time) +# Модель пользователя Telegram +class UserModel(Base): + __tablename__ = "storage_users" - class Config: - json_schema_extra = { - 'storage_name': '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) -# Users -class Usersx(Databasex[ModelBase]): +ModelBase = UserModel +BaseModel = UserModel + + +# Репозиторий пользователей +class UsersRepository(BaseRepository[UserModel]): def __init__(self): super().__init__() - self.table_model = ModelBase - self.storage_name = self.table_model.model_json_schema()['storage_name'] - self.column_one = False + self.table_model = UserModel + self.storage_name = UserModel.__tablename__ - # Add entry - def add( + # Для совместимости add ведет себя как upsert + async def add( self, user_id: int, user_login: str, user_name: str, user_surname: str, user_fullname: str, - ): - user_unix = get_unix() + ) -> 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, + ) - with sqlite3.connect(PATH_DATABASE) as con: - con.row_factory = dict_factory + # Создание пользователя или обновление его данных по 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, + ), + ) - con.execute( - ded(f""" - INSERT INTO {self.storage_name} ( - user_id, - user_login, - user_name, - user_surname, - user_fullname, - user_unix - ) VALUES (?, ?, ?, ?, ?, ?) - """), - [ - user_id, - user_login, - user_name, - user_surname, - user_fullname, - user_unix, - ], + 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) ) - - # Edit entry - def update(self, user_id: int, **kwargs): - with sqlite3.connect(PATH_DATABASE) as con: - con.row_factory = dict_factory - sql = f"UPDATE {self.storage_name} SET" - sql, parameters = update_format(sql, kwargs) - parameters.append(user_id) - - con.execute(sql + "WHERE user_id = ?", parameters) 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/inline_main.py b/tgbot/keyboards/inline_main.py index b204771..f64b40f 100644 --- a/tgbot/keyboards/inline_main.py +++ b/tgbot/keyboards/inline_main.py @@ -5,31 +5,31 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder from tgbot.utils.const_functions import ikb -# Inline keyboards for users +# Инлайн-клавиатура для пользователя def user_finl() -> InlineKeyboardMarkup: keyboard = InlineKeyboardBuilder() keyboard.row( - ikb("User X", data="user_inline_x"), - ikb("User 1", data="user_inline:user_btn"), - ikb("User 2", data="..."), + ikb("Действие", data="user_inline_x"), + ikb("Раздел", data="user_inline:user_btn"), + ikb("Скоро", data="..."), ).row( - ikb("User Unknown", data="unknown"), + ikb("Неизвестная кнопка", data="unknown"), ) return keyboard.as_markup() -# Inline keyboards for admins +# Инлайн-клавиатура для админа def admin_finl() -> InlineKeyboardMarkup: keyboard = InlineKeyboardBuilder() keyboard.row( - ikb("Admin X", data="admin_inline_x"), - ikb("Admin 1", data="admin_inline:admin_btn"), - ikb("Admin 2", data="..."), + ikb("Действие", data="admin_inline_x"), + ikb("Раздел", data="admin_inline:admin_btn"), + ikb("Скоро", data="..."), ).row( - ikb("Admin Unknown", data="unknown"), + ikb("Неизвестная кнопка", data="unknown"), ) return keyboard.as_markup() diff --git a/tgbot/keyboards/reply_main.py b/tgbot/keyboards/reply_main.py index dd19eef..68651a8 100644 --- a/tgbot/keyboards/reply_main.py +++ b/tgbot/keyboards/reply_main.py @@ -11,12 +11,12 @@ def menu_frep(user_id: int) -> ReplyKeyboardMarkup: keyboard = ReplyKeyboardBuilder() keyboard.row( - rkb("User button"), + rkb("Пользовательское меню"), ) if user_id in get_admins(): keyboard.row( - rkb("Admin button"), + rkb("Админ-меню"), ) return keyboard.as_markup(resize_keyboard=True) diff --git a/tgbot/middlewares/__init__.py b/tgbot/middlewares/__init__.py index 9ca5772..39a7007 100644 --- a/tgbot/middlewares/__init__.py +++ b/tgbot/middlewares/__init__.py @@ -5,9 +5,16 @@ from tgbot.middlewares.middleware_throttling import ThrottlingMiddleware from tgbot.middlewares.middleware_user import ExistsUserMiddleware -# Register all middlewares -def register_all_middlwares(dp: Dispatcher): +# Подключение всех мидлварей +def register_all_middlewares(dp: Dispatcher): dp.callback_query.outer_middleware(ExistsUserMiddleware()) dp.message.outer_middleware(ExistsUserMiddleware()) - dp.message.middleware(ThrottlingMiddleware()) + 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 index 2d27667..196d278 100644 --- a/tgbot/middlewares/middleware_throttling.py +++ b/tgbot/middlewares/middleware_throttling.py @@ -1,55 +1,78 @@ # - *- coding: utf- 8 - *- import time -from typing import Any, Awaitable, Callable, Dict, Union +from typing import Any, Awaitable, Callable, Dict, Optional, Union from aiogram import BaseMiddleware from aiogram.dispatcher.flags import get_flag -from aiogram.types import Message, User +from aiogram.types import CallbackQuery, Message, TelegramObject, User from cachetools import TTLCache +from tgbot.data.config import BOT_THROTTLE_RATE -# Antiflood + +# Простая защита от спама class ThrottlingMiddleware(BaseMiddleware): - def __init__(self, default_rate: Union[int, float] = 0.5) -> None: + def __init__(self, default_rate: Union[int, float] = BOT_THROTTLE_RATE) -> None: + # Базовая задержка между сообщениями self.default_rate = default_rate - self.users = TTLCache(maxsize=10_000, ttl=600) + self.message_users = TTLCache(maxsize=10_000, ttl=600) + self.callback_users = TTLCache(maxsize=10_000, ttl=600) - async def __call__(self, handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]], event: Message, data): - this_user: User = data.get("event_from_user") + 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 get_flag(data, "rate") is not None: - self.default_rate = get_flag(data, "rate") - - if self.default_rate == 0: + if this_user is None: return await handler(event, data) - if this_user.id not in self.users: - self.users[this_user.id] = { - 'last_throttled': int(time.time()), + 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': self.default_rate, + 'now_rate': rate, } return await handler(event, data) else: - if int(time.time()) - self.users[this_user.id]['last_throttled'] >= self.users[this_user.id]['now_rate']: - self.users.pop(this_user.id) + if now_time - bucket[user_key]['last_throttled'] >= bucket[user_key]['now_rate']: + bucket.pop(user_key) return await handler(event, data) else: - self.users[this_user.id]['last_throttled'] = int(time.time()) + bucket[user_key]['last_throttled'] = now_time + bucket[user_key]['count_throttled'] += 1 - if self.users[this_user.id]['count_throttled'] == 0: - self.users[this_user.id]['count_throttled'] = 1 - self.users[this_user.id]['now_rate'] = self.default_rate + 2 + 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 await handler(event, data) - elif self.users[this_user.id]['count_throttled'] == 1: - self.users[this_user.id]['count_throttled'] = 2 - self.users[this_user.id]['now_rate'] = self.default_rate + 3 + return None - await event.reply("❗ Please, do not spam.") - elif self.users[this_user.id]['count_throttled'] == 2: - self.users[this_user.id]['count_throttled'] = 3 - self.users[this_user.id]['now_rate'] = self.default_rate + 5 + # Для сообщений и колбэков держим разные лимиты, чтобы они не мешали друг другу + 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 index d54e75c..35c97b2 100644 --- a/tgbot/middlewares/middleware_user.py +++ b/tgbot/middlewares/middleware_user.py @@ -1,48 +1,55 @@ # - *- coding: utf- 8 - *- from aiogram import BaseMiddleware +from cachetools import TTLCache -from tgbot.database.db_users import Usersx +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 not this_user.is_bot: - get_user = Usersx().get(user_id=this_user.id) - + if this_user is not None and not this_user.is_bot: user_id = this_user.id - user_login = this_user.username + 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) - user_language = this_user.language_code - if user_login is None: user_login = "" if user_name is None: user_name = "" if user_surname is None: user_surname = "" if user_fullname is None: user_fullname = "" - if user_language != "ru": user_language = "en" if len(user_surname) >= 1: user_fullname += f" {user_surname}" - if get_user is None: - Usersx().add(user_id, user_login.lower(), user_name, user_surname, user_fullname) + 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: - if user_name != get_user.user_name: - Usersx().update(get_user.user_id, user_name=user_name) + user = cached_user["user"] - if user_surname != get_user.user_surname: - Usersx().update(get_user.user_id, user_surname=user_surname) - - if user_fullname != get_user.user_fullname: - Usersx().update(get_user.user_id, user_fullname=user_fullname) - - if user_login.lower() != get_user.user_login: - Usersx().update(get_user.user_id, user_login=user_login.lower()) - - data['User'] = Usersx().get(user_id=user_id) + data['User'] = user return await handler(event, data) diff --git a/tgbot/routers/__init__.py b/tgbot/routers/__init__.py index 5226391..31c1a92 100644 --- a/tgbot/routers/__init__.py +++ b/tgbot/routers/__init__.py @@ -1,30 +1,33 @@ # - *- coding: utf- 8 - *- -from aiogram import Dispatcher, F +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 +from tgbot.utils.misc.bot_filters import IsAdmin, IsPrivate -# Register all routers +# Подключение всех роутеров def register_all_routers(dp: Dispatcher): - # Connect filters - main_errors.router.message.filter(F.chat.type == "private") - main_start.router.message.filter(F.chat.type == "private") + # Общие фильтры для приватных чатов + main_errors.router.message.filter(IsPrivate()) + main_start.router.message.filter(IsPrivate()) - user_menu.router.message.filter(F.chat.type == "private") - admin_menu.router.message.filter(F.chat.type == "private", IsAdmin()) + 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(F.chat.type == "private") + main_missed.router.message.filter(IsPrivate()) + main_missed.router.callback_query.filter(IsPrivate()) - # Connect needed routers - dp.include_router(main_errors.router) # Router - error - dp.include_router(main_start.router) # Router - main + # Базовые роутеры, которые нужны всегда + dp.include_router(main_errors.router) # Ошибки + dp.include_router(main_start.router) # Старт и главное меню - # Connect usebles routers (users и admins) - dp.include_router(user_menu.router) # Router - user - dp.include_router(admin_menu.router) # Router - admin + # Роутеры для пользователей и админов + dp.include_router(user_menu.router) # Пользовательские обработчики + dp.include_router(admin_menu.router) # Админские обработчики - # Connect needed routers - dp.include_router(main_missed.router) # Router - missed handlers + # Обработка всего, что не поймали выше + dp.include_router(main_missed.router) # Пропущенные сообщения и колбэки diff --git a/tgbot/routers/admin/admin_menu.py b/tgbot/routers/admin/admin_menu.py index 8326ba0..e1e2836 100644 --- a/tgbot/routers/admin/admin_menu.py +++ b/tgbot/routers/admin/admin_menu.py @@ -1,5 +1,5 @@ # - *- coding: utf- 8 - *- -import os +from pathlib import Path import aiofiles from aiogram import Router, Bot, F @@ -7,87 +7,105 @@ from aiogram.filters import Command from aiogram.types import FSInputFile, Message, CallbackQuery from aiogram.utils.media_group import MediaGroupBuilder -from tgbot.data.config import PATH_DATABASE, PATH_LOGS -from tgbot.database.db_users import BaseModel as UserModel +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", +) -# Message - Admin button -@router.message(F.text == 'Admin button') +# Кнопка админского меню +@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( - "Inline admin keyboards", + "Инлайн-клавиатура админа", reply_markup=admin_finl() ) -# Callback - Admin X +# Колбэк для демо-действия админа @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(f"Click Admin X") + await call.answer("Админское действие выполнено") -# Callback - Admin +# Колбэк с параметром из админской кнопки @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"Click Admin - {get_data}", True) + await call.answer(f"Выбран админский раздел: {get_data}", True) -# Get Database file +# Отправка файла базы админам @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"📦 #BACKUP | {get_date()}", + caption=f"📦 #БЭКАП | {get_date()}", ) -# Get Logs file +# Отправка логов админам @router.message(Command(commands=['log', 'logs'])) async def admin_log(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel): await state.clear() - media_group = MediaGroupBuilder( - caption=f"🖨 #LOGS | {get_date(full=False)}", - ) + log_files = [] - if os.path.isfile(PATH_LOGS): - media_group.add_document(media=FSInputFile(PATH_LOGS)) + if Path(PATH_LOGS).is_file(): + log_files.append(PATH_LOGS) - if os.path.isfile("tgbot/data/sv_log_err.log"): - media_group.add_document(media=FSInputFile("tgbot/data/sv_log_err.log")) + for log_file in SERVICE_LOG_FILES: + if log_file.is_file(): + log_files.append(str(log_file)) - if os.path.isfile("tgbot/data/sv_log_out.log"): - media_group.add_document(media=FSInputFile("tgbot/data/sv_log_out.log")) + caption = f"🖨 #ЛОГИ | {get_date(full=False)}" - await message.answer_media_group(media=media_group.build()) + 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()) -# Clear logs file +# Очистка файлов логов @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() - if os.path.isfile(PATH_LOGS): - async with aiofiles.open(PATH_LOGS, "w") as file: - await file.write(f"{get_date()} | LOGS WAS CLEAR") + log_files = [Path(PATH_LOGS), *SERVICE_LOG_FILES] - if os.path.isfile("tgbot/data/sv_log_err.log"): - async with aiofiles.open("tgbot/data/sv_log_err.log", "w") as file: - await file.write(f"{get_date()} | LOGS WAS CLEAR") + for log_file in log_files: + if not log_file.is_file(): + continue - if os.path.isfile("tgbot/data/sv_log_out.log"): - async with aiofiles.open("tgbot/data/sv_log_out.log", "w") as file: - await file.write(f"{get_date()} | LOGS WAS CLEAR") + async with aiofiles.open(log_file, "w", encoding="utf-8") as file: + await file.write(f"{get_date()} | ЛОГИ ОЧИЩЕНЫ") - await message.answer("🖨 The logs have been cleared") + await message.answer("🖨 Логи очищены") diff --git a/tgbot/routers/main_errors.py b/tgbot/routers/main_errors.py index 75ce878..da2c95a 100644 --- a/tgbot/routers/main_errors.py +++ b/tgbot/routers/main_errors.py @@ -1,6 +1,7 @@ # - *- coding: utf- 8 - *- from aiogram import Router -from aiogram.filters import ExceptionMessageFilter +from aiogram.exceptions import TelegramBadRequest +from aiogram.filters import ExceptionTypeFilter from aiogram.handlers import ErrorHandler from tgbot.utils.misc.bot_logging import bot_logger @@ -8,22 +9,19 @@ from tgbot.utils.misc.bot_logging import bot_logger router = Router(name=__name__) -# Error with block forbidden user +# Ошибка при отправке сообщения пользователю, который заблокировал бота # @router.errors(ExceptionTypeFilter(TelegramForbiddenError)) -# class MyHandler(ErrorHandler): +# class ForbiddenErrorHandler(ErrorHandler): # async def handle(self): # ... -# Error with edit duplicate message -@router.errors(ExceptionMessageFilter( - "Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message") -) -class MyHandler(ErrorHandler): +# Безопасно игнорируем повторное редактирование сообщения без изменений +@router.errors(ExceptionTypeFilter(TelegramBadRequest)) +class MessageNotModifiedHandler(ErrorHandler): async def handle(self): - bot_logger.exception( - f"====================\n" - f"Exception name: {self.exception_name}\n" - f"Exception message: {self.exception_message}\n" - f"====================" - ) + 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 index 9338bcc..2ce013b 100644 --- a/tgbot/routers/main_missed.py +++ b/tgbot/routers/main_missed.py @@ -9,28 +9,28 @@ from tgbot.utils.misc.bot_models import FSM, ARS router = Router(name=__name__) -# Callback with delete message +# Колбэк для удаления текущего сообщения @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) -# Callback with processing miss button +# Колбэк-заглушка для пустых кнопок @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) -# Callback with processing miss callback data +# Ответ на колбэк, который никто не обработал @router.callback_query() async def main_callback_missed(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: ModelUsers): - await call.answer(f"❗️ Miss callback: {call.data}", True) + await call.answer(f"❗️ Неизвестный колбэк: {call.data}", True) -# Processing all unknowns callback datas +# Ответ на неизвестные сообщения @router.message() async def main_message_missed(message: Message, bot: Bot, state: FSM, arSession: ARS, User: ModelUsers): await message.answer( - "♦️ Unknown command.\n" - "♦️ Enter /start", + "♦️ Неизвестная команда\n" + "♦️ Введите /start", ) diff --git a/tgbot/routers/main_start.py b/tgbot/routers/main_start.py index 2948726..03dbc03 100644 --- a/tgbot/routers/main_start.py +++ b/tgbot/routers/main_start.py @@ -3,7 +3,7 @@ from aiogram import Router, Bot, F from aiogram.filters import Command from aiogram.types import Message -from tgbot.database.db_users import BaseModel as UserModel +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 @@ -11,7 +11,7 @@ from tgbot.utils.misc.bot_models import FSM, ARS router = Router(name=__name__) -# Main menu +# Главное меню пользователя @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): @@ -19,8 +19,8 @@ async def main_start(message: Message, bot: Bot, state: FSM, arSession: ARS, Use await message.answer( ded(f""" - 🔸 Hello, {User.user_name} - 🔸 Enter /start or /menu + 🔸 Привет, {User.user_name} + 🔸 Введи /start или /menu """), reply_markup=menu_frep(message.from_user.id), ) diff --git a/tgbot/routers/user/user_menu.py b/tgbot/routers/user/user_menu.py index 4c4c1dc..2385858 100644 --- a/tgbot/routers/user/user_menu.py +++ b/tgbot/routers/user/user_menu.py @@ -3,7 +3,7 @@ from aiogram import Router, Bot, F from aiogram.filters import Command from aiogram.types import Message, CallbackQuery -from tgbot.database.db_users import BaseModel as UserModel +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 @@ -11,37 +11,37 @@ from tgbot.utils.misc.bot_models import FSM, ARS router = Router(name=__name__) -# Message - User button -@router.message(F.text == 'User button') +# Кнопка пользовательского меню +@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( - "Inline user keyboards", + "Инлайн-клавиатура пользователя", reply_markup=user_finl() ) -# Command - /menu +# Команда возврата в меню @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( - "Enter command - /menu", + "Команда /menu открывает главное меню", reply_markup=menu_frep(message.from_user.id), ) -# Callback - User X +# Колбэк для демо-действия @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(f"Click User X") + await call.answer("Действие выполнено") -# Callback - User +# Колбэк с параметром из пользовательской кнопки @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"Click User - {get_data}", True) + await call.answer(f"Выбран раздел: {get_data}", True) diff --git a/tgbot/services/api_session.py b/tgbot/services/api_session.py index 738917b..11c6cf5 100644 --- a/tgbot/services/api_session.py +++ b/tgbot/services/api_session.py @@ -4,20 +4,21 @@ from typing import Optional import aiohttp -# In handler +# Пример использования в обработчике # session = await arSession.get_session() # response = await session.get(...) # response = await session.post(...) # Асинхронная сессия для запросов class AsyncRequestSession: - def __init__(self) -> None: + 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() + new_session = aiohttp.ClientSession(timeout=self._timeout) self._session = new_session return self._session @@ -25,6 +26,7 @@ class AsyncRequestSession: # Закрытие сессии async def close(self) -> None: if self._session is None: - return None + return await self._session.close() + self._session = None diff --git a/tgbot/utils/const_functions.py b/tgbot/utils/const_functions.py index 07e08bf..f5b07ee 100644 --- a/tgbot/utils/const_functions.py +++ b/tgbot/utils/const_functions.py @@ -1,26 +1,35 @@ # - *- coding: utf- 8 - *- -import random +import html +import secrets +import string +import textwrap import time -import uuid from datetime import datetime -from typing import Union +from typing import List, Optional, Union -import pytz 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 ################################### -# Generate replay button +# Быстрая сборка реплай-кнопки def rkb(text: str) -> KeyboardButton: return KeyboardButton(text=text) -# Generate inline button -def ikb(text: str, data: str = None, url: str = None, switch: str = None, web: str = None) -> InlineKeyboardButton: +# Быстрая сборка инлайн-кнопки +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: @@ -30,24 +39,24 @@ def ikb(text: str, data: str = None, url: str = None, switch: str = None, web: s elif web is not None: return InlineKeyboardButton(text=text, web_app=WebAppInfo(url=web)) else: - raise "Unknown data" + raise ValueError("Не указано действие для инлайн-кнопки") -# Deleting a message with error handling from Telegram +# Удаление сообщения без падения на ошибках Telegram async def del_message(message: Message): try: await message.delete() - except: - ... + except Exception: + bot_logger.debug("Не удалось удалить сообщение", exc_info=True) -# Smart messaging (automatic sending of messages with or without photos) +# Отправка текста с фото, если оно передано или обычным сообщением async def smart_message( bot: Bot, user_id: int, text: str, - keyboard: Union[InlineKeyboardMarkup, ReplyKeyboardMarkup] = None, - photo: Union[str, None] = None, + keyboard: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = None, + photo: Optional[str] = None, ): if photo is not None and photo.title() != "None": await bot.send_photo( @@ -64,7 +73,7 @@ async def smart_message( ) -# Send a message to all administrators +# Отправка сообщения всем админам async def send_admins(bot: Bot, text: str, markup=None, not_me=0): for admin in get_admins(): try: @@ -75,69 +84,44 @@ async def send_admins(bot: Bot, text: str, markup=None, not_me=0): reply_markup=markup, disable_web_page_preview=True, ) - except: - ... + except Exception: + bot_logger.warning("Не удалось отправить сообщение админу %s", admin, exc_info=True) -##################################### MISC ##################################### -# Removing indents in a multi-line string ("""text""") +################################## РАЗНОЕ ###################################### +# Убирает лишние отступы в многострочном тексте def ded(get_text: str) -> str: - if get_text is not None: - split_text = get_text.split("\n") - if split_text[0] == "": split_text.pop(0) - if split_text[-1] == "": split_text.pop() - save_text = [] - - for text in split_text: - while text.startswith(" "): - text = text[1:].strip() - - save_text.append(text) - get_text = "\n".join(save_text) - else: - get_text = "" - - return get_text + return textwrap.dedent(get_text or "").strip() -# Cleaning text of HTML tags ('test' -> *b*test*/b*) +# Чистит HTML-символы, чтобы Telegram не сломал разметку def clear_html(get_text: str) -> str: - if get_text is not None: - if "" in get_text: get_text = get_text.replace(">", "*") - else: - get_text = "" - - return get_text + return html.escape(get_text or "", quote=False) -# Cleaning up gaps in the list (['', 1, ' ', 2] -> [1, 2]) +# Убирает пустые и мусорные элементы из списка def clear_list(get_list: list) -> list: - while "" in get_list: get_list.remove("") - while " " in get_list: get_list.remove(" ") - while "." in get_list: get_list.remove(".") - while "," in get_list: get_list.remove(",") - while "\r" in get_list: get_list.remove("\r") - while "\n" in get_list: get_list.remove("\n") + trash = {"", " ", ".", ",", "\r", "\n"} - return get_list + return [value for value in get_list if value not in trash] -# Split the list into several parts ([1, 2, 3, 4] 2 -> [[1, 2], [3, 4]]) -def split_list(get_list: list, count: int) -> list[list]: +# Делит список на части нужного размера +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)] -# Get the current date (True - date with time, False - date without time) +# Возвращает текущую дату, при full=True еще и время def get_date(full: bool = True) -> str: + bot_timezone = timezone(BOT_TIMEZONE) + if full: - return datetime.now(pytz.timezone(BOT_TIMEZONE)).strftime("%d.%m.%Y %H:%M:%S") + return datetime.now(bot_timezone).strftime("%d.%m.%Y %H:%M:%S") else: - return datetime.now(pytz.timezone(BOT_TIMEZONE)).strftime("%d.%m.%Y") + return datetime.now(bot_timezone).strftime("%d.%m.%Y") -# Get the current Unix time (True - time in nanoseconds, False - time in seconds) +# Возвращает Unix-время: секунды или наносекунды def get_unix(full: bool = False) -> int: if full: return time.time_ns() @@ -145,89 +129,90 @@ def get_unix(full: bool = False) -> int: return int(time.time()) -# Converting Unix to date and dates to Unix +# Конвертирует дату в Unix и обратно def convert_date(from_time, full=True, second=True) -> Union[str, int]: - from tgbot.data.config import BOT_TIMEZONE + bot_timezone = timezone(BOT_TIMEZONE) + from_time = str(from_time).strip().replace("-", ".") - if "-" in str(from_time): - from_time = from_time.replace("-", ".") - - if str(from_time).isdigit(): + if from_time.isdigit(): + from_timestamp = int(from_time) if full: - to_time = datetime.fromtimestamp(from_time, pytz.timezone(BOT_TIMEZONE)).strftime("%d.%m.%Y %H:%M:%S") + to_time = datetime.fromtimestamp(from_timestamp, bot_timezone).strftime("%d.%m.%Y %H:%M:%S") elif second: - to_time = datetime.fromtimestamp(from_time, pytz.timezone(BOT_TIMEZONE)).strftime("%d.%m.%Y %H:%M") + to_time = datetime.fromtimestamp(from_timestamp, bot_timezone).strftime("%d.%m.%Y %H:%M") else: - to_time = datetime.fromtimestamp(from_time, pytz.timezone(BOT_TIMEZONE)).strftime("%d.%m.%Y") + to_time = datetime.fromtimestamp(from_timestamp, bot_timezone).strftime("%d.%m.%Y") else: - if " " in str(from_time): - cache_time = from_time.split(" ") + parts = from_time.split() - if ":" in cache_time[0]: - cache_date = cache_time[1].split(".") - cache_time = cache_time[0].split(":") - else: - cache_date = cache_time[0].split(".") - cache_time = cache_time[1].split(":") - - if len(cache_date[0]) == 4: - x_year, x_month, x_day = cache_date[0], cache_date[1], cache_date[2] - else: - x_year, x_month, x_day = cache_date[2], cache_date[1], cache_date[0] - - x_hour, x_minute, x_second = cache_time[0], cache_time[1], cache_time[2] - - from_time = f"{x_day}.{x_month}.{x_year} {x_hour}:{x_minute}:{x_second}" + if len(parts) == 2 and ":" in parts[0]: + time_part, date_part = parts + elif len(parts) == 2: + date_part, time_part = parts else: - cache_date = from_time.split(".") + date_part, time_part = from_time, "00:00:00" - if len(cache_date[0]) == 4: - x_year, x_month, x_day = cache_date[0], cache_date[1], cache_date[2] - else: - x_year, x_month, x_day = cache_date[2], cache_date[1], cache_date[0] + date_values = date_part.split(".") + time_values = time_part.split(":") - from_time = f"{x_day}.{x_month}.{x_year}" + if len(time_values) == 2: + time_values.append("0") - if " " in str(from_time): - to_time = int(datetime.strptime(from_time, "%d.%m.%Y %H:%M:%S").timestamp()) + if len(date_values[0]) == 4: + x_year, x_month, x_day = date_values[0], date_values[1], date_values[2] else: - to_time = int(datetime.strptime(from_time, "%d.%m.%Y").timestamp()) + 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 -# Generation of a unique ID +# Генерация числового уникального ID def gen_id(len_id: int = 16) -> int: - mac_address = uuid.getnode() - time_unix = int(str(time.time_ns())[:len_id]) - random_int = int(''.join(random.choices('0123456789', k=len_id))) + if len_id <= 0: + raise ValueError("Длина ID должна быть больше нуля") - return mac_address + time_unix + random_int + 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}") -# Password generation | default, number, letter, onechar +# Генерация пароля под разные сценарии def gen_password(len_password: int = 16, type_password: str = "default") -> str: - char_password = list("1234567890abcdefghigklmnopqrstuvyxwzABCDEFGHIGKLMNOPQRSTUVYXWZ") + if len_password <= 0: + raise ValueError("Длина пароля должна быть больше нуля") if type_password == "default": - char_password = list("1234567890abcdefghigklmnopqrstuvyxwzABCDEFGHIGKLMNOPQRSTUVYXWZ") + alphabet = string.ascii_letters + string.digits elif type_password == "letter": - char_password = list("abcdefghigklmnopqrstuvyxwzABCDEFGHIGKLMNOPQRSTUVYXWZ") + alphabet = string.ascii_letters elif type_password == "number": - char_password = list("1234567890") + alphabet = string.digits elif type_password == "onechar": - char_password = list("1234567890") + alphabet = string.digits + else: + raise ValueError("Неизвестный тип пароля") - random.shuffle(char_password) - random_chars = "".join([random.choice(char_password) for x in range(len_password)]) + random_chars = "".join(secrets.choice(alphabet) for _ in range(len_password)) if type_password == "onechar": - random_chars = f"{random.choice('abcdefghigklmnopqrstuvyxwzABCDEFGHIGKLMNOPQRSTUVYXWZ')}{random_chars[1:]}" + random_chars = f"{secrets.choice(string.ascii_letters)}{random_chars[1:]}" return random_chars -# Addition to the correct time (1 -> 1 day, 3 -> 3 days) +# Склоняет единицы времени под число def convert_times(get_time: int, get_type: str = "day") -> str: get_time = int(get_time) if get_time < 0: get_time = 0 @@ -255,20 +240,20 @@ def convert_times(get_time: int, get_type: str = "day") -> str: return f"{get_time} {get_list[count]}" -# Boolean type check +# Приводит строку или число к bool def is_bool(value: Union[bool, str, int]) -> bool: - value = str(value).lower() + 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"invalid truth value {value}") + raise ValueError(f"Некорректное bool-значение: {value}") -################################### NUMBERS #################################### -# Converting exponential numbers to a readable form (1e-06 -> 0.000001) +################################### ЧИСЛА ###################################### +# Приводит число к читаемой строке без лишних нулей def snum(amount: Union[int, float], remains: int = 2) -> str: format_str = "{:." + str(remains) + "f}" str_amount = format_str.format(float(amount)) @@ -288,38 +273,20 @@ def snum(amount: Union[int, float], remains: int = 2) -> str: return str(str_amount) -# Convert any number to a real number, removing trailing zeros (remains - rounding) +# Приводит входное значение к int или float def to_float(get_number, remains: int = 2) -> Union[int, float]: - if "," in str(get_number): - get_number = str(get_number).replace(",", ".") + value = str(get_number).strip().replace(" ", "").replace(",", ".") + number = round(float(value), remains) - if "." in str(get_number): - get_last = str(get_number).split(".") + if number.is_integer(): + return int(number) - if str(get_last[1]).endswith("0"): - while True: - if str(get_number).endswith("0"): - get_number = str(get_number)[:-1] - else: - break - - get_number = round(float(get_number), remains) - - str_number = snum(get_number) - if "." in str_number: - if str_number.split(".")[1] == "0": - get_number = int(get_number) - else: - get_number = float(get_number) - else: - get_number = int(get_number) - - return get_number + return number -# Converting a real number to an integer +# Округляет число до int def to_int(get_number: float) -> int: - if "," in get_number: + if "," in str(get_number): get_number = str(get_number).replace(",", ".") get_number = int(round(float(get_number))) @@ -327,7 +294,7 @@ def to_int(get_number: float) -> int: return get_number -# Data validation for numbers +# Проверяет, является ли значение числом def is_number(get_number: Union[str, int, float]) -> bool: if str(get_number).isdigit(): return True @@ -337,39 +304,17 @@ def is_number(get_number: Union[str, int, float]) -> bool: try: float(get_number) return True - except ValueError: + except (TypeError, ValueError): return False -# Converting a number to a readable form (123456789 -> 123,456,789) +# Форматирует число с разделением тысяч def format_rate(amount: Union[float, int], around: int = 2) -> str: - if "," in str(amount): amount = float(str(amount).replace(",", ".")) - if " " in str(amount): amount = float(str(amount).replace(" ", "")) - amount = str(round(amount, around)) + value = str(amount).strip().replace(" ", "").replace(",", ".") + number = round(float(value), around) + response = f"{number:,.{around}f}".replace(",", " ") - out_amount, save_remains = [], "" - - if "." in amount: save_remains = amount.split(".")[1] - save_amount = [char for char in str(int(float(amount)))] - - if len(save_amount) % 3 != 0: - if (len(save_amount) - 1) % 3 == 0: - out_amount.extend([save_amount[0]]) - save_amount.pop(0) - elif (len(save_amount) - 2) % 3 == 0: - out_amount.extend([save_amount[0], save_amount[1]]) - save_amount.pop(1) - save_amount.pop(0) - else: - print("Error 4388326") - - for x, char in enumerate(save_amount): - if x % 3 == 0: out_amount.append(" ") - out_amount.append(char) - - response = "".join(out_amount).strip() + "." + save_remains - - if response.endswith("."): - response = response[:-1] + if "." in response: + response = response.rstrip("0").rstrip(".") return response diff --git a/tgbot/utils/misc/bot_commands.py b/tgbot/utils/misc/bot_commands.py index bd62b83..d41e556 100644 --- a/tgbot/utils/misc/bot_commands.py +++ b/tgbot/utils/misc/bot_commands.py @@ -2,29 +2,32 @@ from aiogram import Bot from aiogram.types import BotCommand, BotCommandScopeChat, BotCommandScopeDefault -from tgbot.data.config import get_admins +from tgbot.data.config import BOT_DATABASE_EXPORT, get_admins +from tgbot.utils.misc.bot_logging import bot_logger -# Commands for users +# Команды для обычных пользователей user_commands = [ - BotCommand(command="start", description="♻️ Restart bot"), - BotCommand(command="menu", description="🌀 Get keyboards"), + BotCommand(command="start", description="♻️ Перезапуск бота"), + BotCommand(command="menu", description="🌀 Получение клавиатуры"), ] -# Commands for admins +# Команды для админов admin_commands = [ - BotCommand(command="start", description="♻️ Restart bot"), - BotCommand(command="menu", description="🌀 Get keyboards"), - BotCommand(command="log", description="🖨 Get Logs"), - BotCommand(command="db", description="📦 Get Database"), + BotCommand(command="start", description="♻️ Перезапуск бота"), + BotCommand(command="menu", description="🌀 Получение клавиатуры"), + BotCommand(command="log", description="🖨 Получить логи"), ] +if BOT_DATABASE_EXPORT: + admin_commands.append(BotCommand(command="db", description="📦 Получить БД")) -# Set commands + +# Обновление списка команд в 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: - ... + 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 index ffbde4b..8cd88f4 100644 --- a/tgbot/utils/misc/bot_filters.py +++ b/tgbot/utils/misc/bot_filters.py @@ -1,14 +1,30 @@ # - *- coding: utf- 8 - *- +from typing import Union + from aiogram.filters import BaseFilter -from aiogram.types import Message +from aiogram.types import CallbackQuery, Message from tgbot.data.config import get_admins -# Filter on admin right +# Проверка, что действия совершает админ class IsAdmin(BaseFilter): - async def __call__(self, message: Message) -> bool: - if message.from_user.id in get_admins(): + 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 - else: - return False + + return chat.type == "private" diff --git a/tgbot/utils/misc/bot_logging.py b/tgbot/utils/misc/bot_logging.py index 043a720..6fb5dc3 100644 --- a/tgbot/utils/misc/bot_logging.py +++ b/tgbot/utils/misc/bot_logging.py @@ -1,32 +1,58 @@ # - *- coding: utf- 8 - *- -import logging as bot_logger +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path import colorlog from tgbot.data.config import PATH_LOGS -# Logging format -log_formatter_file = bot_logger.Formatter("%(levelname)s | %(asctime)s | %(filename)s:%(lineno)d | %(message)s") -log_formatter_console = colorlog.ColoredFormatter( - "%(purple)s%(levelname)s %(blue)s|%(purple)s %(asctime)s %(blue)s|%(purple)s %(filename)s:%(lineno)d %(blue)s|%(purple)s %(message)s%(red)s", - datefmt="%d-%m-%Y %H:%M:%S", -) +LOG_FILE_MAX_BYTES = 5 * 1024 * 1024 +LOG_FILE_BACKUP_COUNT = 5 -# Logging in file logs.log -file_handler = bot_logger.FileHandler(PATH_LOGS, "w", "utf-8") -file_handler.setFormatter(log_formatter_file) -file_handler.setLevel(bot_logger.INFO) +# Папка под логи создается сама, чтобы бот не падал на старте +log_path = Path(PATH_LOGS) +log_path.parent.mkdir(parents=True, exist_ok=True) -# Logging in console -console_handler = bot_logger.StreamHandler() -console_handler.setFormatter(log_formatter_console) -console_handler.setLevel(bot_logger.CRITICAL) +# Один общий логгер для всего шаблона +bot_logger = logging.getLogger("tgbot") +bot_logger.setLevel(logging.INFO) +bot_logger.propagate = False -# Connect logging settings -bot_logger.basicConfig( - format="%(levelname)s | %(asctime)s | %(filename)s:%(lineno)d | %(message)s", - handlers=[ - file_handler, - console_handler - ] -) +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 index 0e726de..22e4afb 100644 --- a/tgbot/utils/misc/bot_models.py +++ b/tgbot/utils/misc/bot_models.py @@ -3,5 +3,6 @@ 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 index 0c34545..8a72f65 100644 --- a/tgbot/utils/misc_functions.py +++ b/tgbot/utils/misc_functions.py @@ -2,24 +2,28 @@ from aiogram import Bot from aiogram.types import FSInputFile -from tgbot.data.config import get_admins, PATH_DATABASE, BOT_STATUS_NOTIFICATION +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 -# Notification after run bot (for all admins) +# Уведомление админам после запуска async def startup_notify(bot: Bot): if len(get_admins()) >= 1 and BOT_STATUS_NOTIFICATION: - await send_admins(bot, "✅ Bot was started") + await send_admins(bot, "✅ Бот запущен") -# Autobackup Database +# Автобэкап базы для админов 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"📦 #AUTOBACKUP | {get_date()}", + caption=f"📦 #АВТОБЭКАП | {get_date()}", ) - except: - ... + except Exception: + bot_logger.warning("Не удалось отправить автобэкап админу %s", admin, exc_info=True)