mirror of
https://github.com/djimboy/djimbo_template_aio3.git
synced 2026-07-25 09:44:29 +00:00
Update aiogram 3 template
This commit is contained in:
@@ -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/
|
||||||
@@ -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
|
||||||
+39
-176
@@ -1,189 +1,52 @@
|
|||||||
# Byte-compiled / optimized / DLL files
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
|
||||||
# C extensions
|
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
.Python
|
||||||
|
|
||||||
|
# Виртуальное окружение
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
env/
|
env/
|
||||||
|
ENV/
|
||||||
|
pyvenv.cfg
|
||||||
|
|
||||||
|
# Зависимости и сборка
|
||||||
build/
|
build/
|
||||||
develop-eggs/
|
|
||||||
dist/
|
dist/
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
.eggs/
|
||||||
*.egg
|
*.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
|
.env
|
||||||
|
.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
|
|
||||||
settings.ini
|
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/
|
||||||
|
|||||||
+19
@@ -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"]
|
||||||
@@ -1,3 +1,302 @@
|
|||||||
### [](https://www.python.org/downloads/release/python-399/) [](https://pypi.org/project/aiogram/) [](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`, если экспорт БД реально не нужен.
|
||||||
|
- Для нового проекта сначала менять тексты, команды и клавиатуры под свою логику, а потом уже добавлять бизнес-код.
|
||||||
|
|||||||
+42
@@ -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
|
||||||
+8
-7
@@ -1,12 +1,13 @@
|
|||||||
# supervisorctl
|
# Пример конфига для supervisorctl
|
||||||
[program:dj_bot]
|
[program:dj_bot]
|
||||||
directory=/root/djimbo_template/
|
directory=/path/to/djimbo_template/
|
||||||
command=python3.10 main.py
|
command=python3.11 -u main.py
|
||||||
|
environment=PYTHONUNBUFFERED="1"
|
||||||
|
|
||||||
autostart=True
|
autostart=True
|
||||||
autorestart=True
|
autorestart=True
|
||||||
|
|
||||||
stderr_logfile=/root/djimbo_template/tgbot/data/sv_log_err.log
|
stderr_logfile=/path/to/djimbo_template/tgbot/data/sv_log_err.log
|
||||||
; stderr_logfile_maxbytes=10MB
|
stderr_logfile_maxbytes=50MB
|
||||||
stdout_logfile=/root/djimbo_template/tgbot/data/sv_log_out.log
|
stdout_logfile=/path/to/djimbo_template/tgbot/data/sv_log_out.log
|
||||||
; stdout_logfile_maxbytes=10MB
|
stdout_logfile_maxbytes=50MB
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
# - *- coding: utf- 8 - *-
|
# - *- coding: utf- 8 - *-
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
@@ -8,28 +7,52 @@ from aiogram import Bot, Dispatcher
|
|||||||
from aiogram.client.default import DefaultBotProperties
|
from aiogram.client.default import DefaultBotProperties
|
||||||
from aiogram.enums import ParseMode
|
from aiogram.enums import ParseMode
|
||||||
|
|
||||||
from tgbot.data.config import BOT_TOKEN, BOT_SCHEDULER, get_admins
|
from tgbot.data.config import BOT_DATABASE_EXPORT, BOT_TOKEN, BOT_SCHEDULER, get_admins, validate_bot_config
|
||||||
from tgbot.database.adb_helper import database_initialization
|
from tgbot.database.core import close_database
|
||||||
from tgbot.middlewares import register_all_middlwares
|
from tgbot.database.repository import prepare_database
|
||||||
|
from tgbot.middlewares import register_all_middlewares
|
||||||
from tgbot.routers import register_all_routers
|
from tgbot.routers import register_all_routers
|
||||||
from tgbot.services.api_session import AsyncRequestSession
|
from tgbot.services.api_session import AsyncRequestSession
|
||||||
from tgbot.utils.misc.bot_commands import set_commands
|
from tgbot.utils.misc.bot_commands import set_commands
|
||||||
from tgbot.utils.misc.bot_logging import bot_logger
|
from tgbot.utils.misc.bot_logging import bot_logger
|
||||||
from tgbot.utils.misc_functions import autobackup_admin, startup_notify
|
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()
|
colorama.init()
|
||||||
|
|
||||||
|
|
||||||
# Start schedulers
|
# Запуск задач по расписанию
|
||||||
async def scheduler_start(bot):
|
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():
|
async def main():
|
||||||
BOT_SCHEDULER.start() # Start scheduler
|
validate_bot_config()
|
||||||
dp = Dispatcher() # Dispatcher image
|
await prepare_database() # Проверка готовности БД
|
||||||
arSession = AsyncRequestSession() # Async session pool (aiohttp)
|
|
||||||
|
dp = Dispatcher() # Диспетчер событий
|
||||||
|
arSession = AsyncRequestSession() # Общая сессия aiohttp
|
||||||
|
|
||||||
bot = Bot( # Образ Бота
|
bot = Bot( # Образ Бота
|
||||||
token=BOT_TOKEN,
|
token=BOT_TOKEN,
|
||||||
@@ -38,44 +61,43 @@ async def main():
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
register_all_middlwares(dp) # Register all middlewares
|
register_all_middlewares(dp) # Подключение мидлварей
|
||||||
register_all_routers(dp) # Register all routers
|
register_all_routers(dp) # Подключение роутера
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await set_commands(bot) # Set commands for users
|
await set_commands(bot) # Обновление команды в Telegram
|
||||||
await startup_notify(bot) # Notification that bot was started
|
await startup_notify(bot) # Сообщаем админам о старте
|
||||||
await scheduler_start(bot) # Connect schedulers
|
await scheduler_start(bot) # Подключение задач по расписанию
|
||||||
|
|
||||||
bot_logger.warning("BOT WAS STARTED")
|
bot_info = await bot.get_me()
|
||||||
print(colorama.Fore.LIGHTYELLOW_EX + f"~~~~~ Bot was started - @{(await bot.get_me()).username} ~~~~~")
|
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.LIGHTBLUE_EX + "~~~~~ TG developer - @djimbox ~~~~~")
|
||||||
print(colorama.Fore.RESET)
|
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.delete_webhook() # Сбрасывание вебхука, если он был
|
||||||
await bot.get_updates(offset=-1) # Reset update pengings
|
await bot.get_updates(offset=-1) # Чистка старых апдейтов
|
||||||
|
|
||||||
# Run bot (polling method)
|
# Запуск бота (polling режим)
|
||||||
await dp.start_polling(
|
await dp.start_polling(
|
||||||
bot,
|
bot,
|
||||||
arSession=arSession,
|
arSession=arSession,
|
||||||
allowed_updates=dp.resolve_used_update_types(),
|
allowed_updates=dp.resolve_used_update_types(),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
await arSession.close() # Close async session (aiohttp)
|
if BOT_SCHEDULER.running:
|
||||||
await bot.session.close() # Close bot session
|
BOT_SCHEDULER.shutdown(wait=False)
|
||||||
|
|
||||||
|
await arSession.close() # Закрытие сессии aiohttp
|
||||||
|
await bot.session.close() # Закрытие сессии API Telegram
|
||||||
|
await close_database() # Закрытие соединений с БД
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
database_initialization() # Initializate Database, tables and columns
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
except (KeyboardInterrupt, SystemExit):
|
except (KeyboardInterrupt, SystemExit):
|
||||||
bot_logger.warning("Bot was stopped")
|
bot_logger.warning("Бот остановлен")
|
||||||
finally:
|
|
||||||
if sys.platform.startswith("win"):
|
|
||||||
os.system("cls")
|
|
||||||
else:
|
|
||||||
os.system("clear")
|
|
||||||
|
|||||||
+182
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -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"}
|
||||||
@@ -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")
|
||||||
@@ -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*"]
|
||||||
+11
-6
@@ -1,8 +1,13 @@
|
|||||||
APScheduler==3.11.0
|
APScheduler==3.11.1
|
||||||
aiogram==3.22.0
|
aiogram==3.28.2
|
||||||
colorlog==6.9.0
|
|
||||||
pytz==2025.2
|
|
||||||
DateTime==5.5
|
|
||||||
typing==3.7.4.3
|
|
||||||
aiofiles==25.1.0
|
aiofiles==25.1.0
|
||||||
|
aiohttp==3.13.2
|
||||||
|
aiosqlite==0.22.1
|
||||||
|
alembic==1.18.4
|
||||||
|
cachetools==6.1.0
|
||||||
colorama==0.4.6
|
colorama==0.4.6
|
||||||
|
colorlog==6.10.1
|
||||||
|
pydantic==2.12.4
|
||||||
|
pydantic-settings==2.14.1
|
||||||
|
pytz==2025.2
|
||||||
|
SQLAlchemy==2.0.50
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
[settings]
|
|
||||||
bot_token=
|
|
||||||
admin_id=
|
|
||||||
+117
-31
@@ -1,42 +1,128 @@
|
|||||||
# - *- coding: utf- 8 - *-
|
# - *- 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 apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from pydantic import AliasChoices, Field, field_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from pytz import UnknownTimeZoneError, timezone
|
||||||
|
|
||||||
# Токен бота
|
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||||
BOT_TOKEN = configparser.ConfigParser()
|
ENV_PATH = BASE_DIR / ".env"
|
||||||
BOT_TOKEN.read("settings.ini")
|
|
||||||
BOT_TOKEN = BOT_TOKEN['settings']['bot_token'].strip().replace(' ', '')
|
|
||||||
|
|
||||||
# Пути к файлам
|
|
||||||
PATH_DATABASE = "tgbot/data/database.db" # Путь к БД
|
|
||||||
PATH_LOGS = "tgbot/data/logs.log" # Путь к Логам
|
|
||||||
|
|
||||||
# Образы и конфиги
|
# Все настройки из окружения или локального .env
|
||||||
BOT_STATUS_NOTIFICATION = True # Оповещение админам о запуске бота (True или False)
|
class Settings(BaseSettings):
|
||||||
BOT_TIMEZONE = "Europe/Moscow" # Временная зона бота
|
model_config = SettingsConfigDict(
|
||||||
BOT_SCHEDULER = AsyncIOScheduler(timezone=BOT_TIMEZONE) # Образ шедулера
|
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]:
|
def get_admins() -> List[int]:
|
||||||
read_admins = configparser.ConfigParser()
|
return settings.admins
|
||||||
read_admins.read('settings.ini')
|
|
||||||
|
|
||||||
admins = read_admins['settings']['admin_id'].strip().replace(" ", "")
|
|
||||||
|
|
||||||
if "," in admins:
|
# Проверка настроек, которые нужны именно для запуска бота
|
||||||
admins = admins.split(",")
|
def validate_bot_config() -> None:
|
||||||
else:
|
if not BOT_TOKEN:
|
||||||
if len(admins) >= 1:
|
raise RuntimeError("В .env не заполнен параметр BOT_TOKEN")
|
||||||
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))
|
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
from .db_settings import Settingsx, ModelBase as ModelSettings
|
from .db_settings import SettingsModel, SettingsRepository
|
||||||
from .db_users import Usersx, ModelBase as ModelUsers
|
from .db_users import UserModel, UsersRepository
|
||||||
|
|
||||||
|
ModelSettings = SettingsModel
|
||||||
|
ModelUsers = UserModel
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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',
|
|
||||||
# },
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
@@ -1,43 +1,61 @@
|
|||||||
# - *- coding: utf- 8 - *-
|
# - *- 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.database.core import Base, session_scope
|
||||||
|
from tgbot.database.repository import BaseRepository
|
||||||
from tgbot.data.config import PATH_DATABASE
|
|
||||||
from tgbot.database.adb_helper import Databasex, dict_factory, update_format
|
|
||||||
|
|
||||||
|
|
||||||
# Table model
|
# Модель настроек бота
|
||||||
class ModelBase(BaseModel):
|
class SettingsModel(Base):
|
||||||
status_work: str # Status bot work
|
__tablename__ = "storage_settings"
|
||||||
|
|
||||||
class Config:
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||||
json_schema_extra = {
|
status_work: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
'storage_name': 'storage_settings'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Settings
|
ModelBase = SettingsModel
|
||||||
class Settingsx(Databasex[ModelBase]):
|
BaseModel = SettingsModel
|
||||||
|
|
||||||
|
|
||||||
|
# Репозиторий настроек бота
|
||||||
|
class SettingsRepository(BaseRepository[SettingsModel]):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.table_model = ModelBase
|
self.table_model = SettingsModel
|
||||||
self.storage_name = self.table_model.model_json_schema()['storage_name']
|
self.storage_name = SettingsModel.__tablename__
|
||||||
self.column_one = True
|
|
||||||
|
|
||||||
# Get entry
|
# Создание строки настроек если их еще нет
|
||||||
def get(self) -> ModelBase:
|
async def ensure_default(self) -> None:
|
||||||
with sqlite3.connect(PATH_DATABASE) as con:
|
statement = insert(SettingsModel).values(id=1, status_work=False)
|
||||||
con.row_factory = dict_factory
|
statement = statement.on_conflict_do_nothing(index_elements=[SettingsModel.id])
|
||||||
sql = f"SELECT * FROM {self.storage_name}"
|
|
||||||
|
|
||||||
return ModelBase(**con.execute(sql).fetchone())
|
async with session_scope() as session:
|
||||||
|
await session.execute(statement)
|
||||||
|
|
||||||
# Edit entry
|
# Настройки должны быть всегда, поэтому при пустой таблице создаем дефолт
|
||||||
def update(self, **kwargs):
|
async def get(self) -> SettingsModel:
|
||||||
with sqlite3.connect(PATH_DATABASE) as con:
|
settings = await super().get(id=1)
|
||||||
con.row_factory = dict_factory
|
|
||||||
sql = f"UPDATE {self.storage_name} SET"
|
|
||||||
sql, parameters = update_format(sql, kwargs)
|
|
||||||
|
|
||||||
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)
|
||||||
|
)
|
||||||
|
|||||||
+87
-59
@@ -1,78 +1,106 @@
|
|||||||
# - *- coding: utf- 8 - *-
|
# - *- 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.database.core import Base, session_scope
|
||||||
|
from tgbot.database.repository import BaseRepository
|
||||||
from tgbot.data.config import PATH_DATABASE
|
from tgbot.utils.const_functions import get_unix
|
||||||
from tgbot.database.adb_helper import Databasex, dict_factory, update_format
|
|
||||||
from tgbot.utils.const_functions import get_unix, ded
|
|
||||||
|
|
||||||
|
|
||||||
# Table model
|
# Модель пользователя Telegram
|
||||||
class ModelBase(BaseModel):
|
class UserModel(Base):
|
||||||
increment: int # increment
|
__tablename__ = "storage_users"
|
||||||
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)
|
|
||||||
|
|
||||||
class Config:
|
increment: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
json_schema_extra = {
|
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
|
||||||
'storage_name': 'storage_users'
|
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
|
ModelBase = UserModel
|
||||||
class Usersx(Databasex[ModelBase]):
|
BaseModel = UserModel
|
||||||
|
|
||||||
|
|
||||||
|
# Репозиторий пользователей
|
||||||
|
class UsersRepository(BaseRepository[UserModel]):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.table_model = ModelBase
|
self.table_model = UserModel
|
||||||
self.storage_name = self.table_model.model_json_schema()['storage_name']
|
self.storage_name = UserModel.__tablename__
|
||||||
self.column_one = False
|
|
||||||
|
|
||||||
# Add entry
|
# Для совместимости add ведет себя как upsert
|
||||||
def add(
|
async def add(
|
||||||
self,
|
self,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
user_login: str,
|
user_login: str,
|
||||||
user_name: str,
|
user_name: str,
|
||||||
user_surname: str,
|
user_surname: str,
|
||||||
user_fullname: str,
|
user_fullname: str,
|
||||||
):
|
) -> UserModel:
|
||||||
user_unix = get_unix()
|
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:
|
# Создание пользователя или обновление его данных по user_id
|
||||||
con.row_factory = dict_factory
|
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(
|
async with session_scope() as session:
|
||||||
ded(f"""
|
await session.execute(statement)
|
||||||
INSERT INTO {self.storage_name} (
|
|
||||||
user_id,
|
user = await self.get(user_id=user_id)
|
||||||
user_login,
|
|
||||||
user_name,
|
if user is None:
|
||||||
user_surname,
|
raise RuntimeError("Пользователь не сохранился")
|
||||||
user_fullname,
|
|
||||||
user_unix
|
return user
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
"""),
|
# Обновление пользователя по Telegram ID
|
||||||
[
|
async def update(self, user_id: int, **kwargs) -> None:
|
||||||
user_id,
|
if not kwargs:
|
||||||
user_login,
|
return
|
||||||
user_name,
|
|
||||||
user_surname,
|
async with session_scope() as session:
|
||||||
user_fullname,
|
await session.execute(
|
||||||
user_unix,
|
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)
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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("База данных готова")
|
||||||
@@ -5,31 +5,31 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|||||||
from tgbot.utils.const_functions import ikb
|
from tgbot.utils.const_functions import ikb
|
||||||
|
|
||||||
|
|
||||||
# Inline keyboards for users
|
# Инлайн-клавиатура для пользователя
|
||||||
def user_finl() -> InlineKeyboardMarkup:
|
def user_finl() -> InlineKeyboardMarkup:
|
||||||
keyboard = InlineKeyboardBuilder()
|
keyboard = InlineKeyboardBuilder()
|
||||||
|
|
||||||
keyboard.row(
|
keyboard.row(
|
||||||
ikb("User X", data="user_inline_x"),
|
ikb("Действие", data="user_inline_x"),
|
||||||
ikb("User 1", data="user_inline:user_btn"),
|
ikb("Раздел", data="user_inline:user_btn"),
|
||||||
ikb("User 2", data="..."),
|
ikb("Скоро", data="..."),
|
||||||
).row(
|
).row(
|
||||||
ikb("User Unknown", data="unknown"),
|
ikb("Неизвестная кнопка", data="unknown"),
|
||||||
)
|
)
|
||||||
|
|
||||||
return keyboard.as_markup()
|
return keyboard.as_markup()
|
||||||
|
|
||||||
|
|
||||||
# Inline keyboards for admins
|
# Инлайн-клавиатура для админа
|
||||||
def admin_finl() -> InlineKeyboardMarkup:
|
def admin_finl() -> InlineKeyboardMarkup:
|
||||||
keyboard = InlineKeyboardBuilder()
|
keyboard = InlineKeyboardBuilder()
|
||||||
|
|
||||||
keyboard.row(
|
keyboard.row(
|
||||||
ikb("Admin X", data="admin_inline_x"),
|
ikb("Действие", data="admin_inline_x"),
|
||||||
ikb("Admin 1", data="admin_inline:admin_btn"),
|
ikb("Раздел", data="admin_inline:admin_btn"),
|
||||||
ikb("Admin 2", data="..."),
|
ikb("Скоро", data="..."),
|
||||||
).row(
|
).row(
|
||||||
ikb("Admin Unknown", data="unknown"),
|
ikb("Неизвестная кнопка", data="unknown"),
|
||||||
)
|
)
|
||||||
|
|
||||||
return keyboard.as_markup()
|
return keyboard.as_markup()
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ def menu_frep(user_id: int) -> ReplyKeyboardMarkup:
|
|||||||
keyboard = ReplyKeyboardBuilder()
|
keyboard = ReplyKeyboardBuilder()
|
||||||
|
|
||||||
keyboard.row(
|
keyboard.row(
|
||||||
rkb("User button"),
|
rkb("Пользовательское меню"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if user_id in get_admins():
|
if user_id in get_admins():
|
||||||
keyboard.row(
|
keyboard.row(
|
||||||
rkb("Admin button"),
|
rkb("Админ-меню"),
|
||||||
)
|
)
|
||||||
|
|
||||||
return keyboard.as_markup(resize_keyboard=True)
|
return keyboard.as_markup(resize_keyboard=True)
|
||||||
|
|||||||
@@ -5,9 +5,16 @@ from tgbot.middlewares.middleware_throttling import ThrottlingMiddleware
|
|||||||
from tgbot.middlewares.middleware_user import ExistsUserMiddleware
|
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.callback_query.outer_middleware(ExistsUserMiddleware())
|
||||||
dp.message.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)
|
||||||
|
|||||||
@@ -1,55 +1,78 @@
|
|||||||
# - *- coding: utf- 8 - *-
|
# - *- coding: utf- 8 - *-
|
||||||
import time
|
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 import BaseMiddleware
|
||||||
from aiogram.dispatcher.flags import get_flag
|
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 cachetools import TTLCache
|
||||||
|
|
||||||
|
from tgbot.data.config import BOT_THROTTLE_RATE
|
||||||
|
|
||||||
# Antiflood
|
|
||||||
|
# Простая защита от спама
|
||||||
class ThrottlingMiddleware(BaseMiddleware):
|
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.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):
|
async def __call__(self, handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], event: TelegramObject, data):
|
||||||
this_user: User = data.get("event_from_user")
|
# Если юзер спамит, постепенно увеличиваем паузу
|
||||||
|
this_user: Optional[User] = data.get("event_from_user")
|
||||||
|
|
||||||
if get_flag(data, "rate") is not None:
|
if this_user is None:
|
||||||
self.default_rate = get_flag(data, "rate")
|
|
||||||
|
|
||||||
if self.default_rate == 0:
|
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
if this_user.id not in self.users:
|
flag_rate = get_flag(data, "rate")
|
||||||
self.users[this_user.id] = {
|
rate = float(self.default_rate if flag_rate is None else flag_rate)
|
||||||
'last_throttled': int(time.time()),
|
|
||||||
|
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,
|
'count_throttled': 0,
|
||||||
'now_rate': self.default_rate,
|
'now_rate': rate,
|
||||||
}
|
}
|
||||||
|
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
else:
|
else:
|
||||||
if int(time.time()) - self.users[this_user.id]['last_throttled'] >= self.users[this_user.id]['now_rate']:
|
if now_time - bucket[user_key]['last_throttled'] >= bucket[user_key]['now_rate']:
|
||||||
self.users.pop(this_user.id)
|
bucket.pop(user_key)
|
||||||
|
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
else:
|
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:
|
if bucket[user_key]['count_throttled'] == 1:
|
||||||
self.users[this_user.id]['count_throttled'] = 1
|
bucket[user_key]['now_rate'] = rate + 2
|
||||||
self.users[this_user.id]['now_rate'] = self.default_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)
|
return None
|
||||||
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
|
|
||||||
|
|
||||||
await event.reply("<b>❗ Please, do not spam.")
|
# Для сообщений и колбэков держим разные лимиты, чтобы они не мешали друг другу
|
||||||
elif self.users[this_user.id]['count_throttled'] == 2:
|
def _get_bucket(self, event: TelegramObject) -> TTLCache:
|
||||||
self.users[this_user.id]['count_throttled'] = 3
|
if isinstance(event, CallbackQuery):
|
||||||
self.users[this_user.id]['now_rate'] = self.default_rate + 5
|
return self.callback_users
|
||||||
|
|
||||||
|
return self.message_users
|
||||||
|
|
||||||
|
# Предупреждаем там, где это возможно для конкретного типа апдейта
|
||||||
|
async def _warn_user(self, event: TelegramObject) -> None:
|
||||||
|
if isinstance(event, Message):
|
||||||
|
await event.reply("<b>❗ Пожалуйста, не спамьте</b>")
|
||||||
|
elif isinstance(event, CallbackQuery) or hasattr(event, "answer"):
|
||||||
|
await event.answer("❗ Пожалуйста, не спамьте", cache_time=1)
|
||||||
|
|||||||
@@ -1,48 +1,55 @@
|
|||||||
# - *- coding: utf- 8 - *-
|
# - *- coding: utf- 8 - *-
|
||||||
from aiogram import BaseMiddleware
|
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
|
from tgbot.utils.const_functions import clear_html
|
||||||
|
|
||||||
|
|
||||||
# Проверка юзера в БД и его добавление
|
# Проверка юзера в БД и его добавление/обновление
|
||||||
class ExistsUserMiddleware(BaseMiddleware):
|
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):
|
async def __call__(self, handler, event, data):
|
||||||
this_user = data.get("event_from_user")
|
this_user = data.get("event_from_user")
|
||||||
|
|
||||||
if not this_user.is_bot:
|
if this_user is not None and not this_user.is_bot:
|
||||||
get_user = Usersx().get(user_id=this_user.id)
|
|
||||||
|
|
||||||
user_id = this_user.id
|
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_name = clear_html(this_user.first_name)
|
||||||
user_surname = clear_html(this_user.last_name)
|
user_surname = clear_html(this_user.last_name)
|
||||||
user_fullname = clear_html(this_user.first_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_name is None: user_name = ""
|
||||||
if user_surname is None: user_surname = ""
|
if user_surname is None: user_surname = ""
|
||||||
if user_fullname is None: user_fullname = ""
|
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 len(user_surname) >= 1: user_fullname += f" {user_surname}"
|
||||||
|
|
||||||
if get_user is None:
|
user_data = (
|
||||||
Usersx().add(user_id, user_login.lower(), user_name, user_surname, user_fullname)
|
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:
|
else:
|
||||||
if user_name != get_user.user_name:
|
user = cached_user["user"]
|
||||||
Usersx().update(get_user.user_id, user_name=user_name)
|
|
||||||
|
|
||||||
if user_surname != get_user.user_surname:
|
data['User'] = user
|
||||||
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)
|
|
||||||
|
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|||||||
+20
-17
@@ -1,30 +1,33 @@
|
|||||||
# - *- coding: utf- 8 - *-
|
# - *- 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 import main_errors, main_missed, main_start
|
||||||
from tgbot.routers.admin import admin_menu
|
from tgbot.routers.admin import admin_menu
|
||||||
from tgbot.routers.user import user_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):
|
def register_all_routers(dp: Dispatcher):
|
||||||
# Connect filters
|
# Общие фильтры для приватных чатов
|
||||||
main_errors.router.message.filter(F.chat.type == "private")
|
main_errors.router.message.filter(IsPrivate())
|
||||||
main_start.router.message.filter(F.chat.type == "private")
|
main_start.router.message.filter(IsPrivate())
|
||||||
|
|
||||||
user_menu.router.message.filter(F.chat.type == "private")
|
user_menu.router.message.filter(IsPrivate())
|
||||||
admin_menu.router.message.filter(F.chat.type == "private", IsAdmin())
|
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_errors.router) # Ошибки
|
||||||
dp.include_router(main_start.router) # Router - main
|
dp.include_router(main_start.router) # Старт и главное меню
|
||||||
|
|
||||||
# Connect usebles routers (users и admins)
|
# Роутеры для пользователей и админов
|
||||||
dp.include_router(user_menu.router) # Router - user
|
dp.include_router(user_menu.router) # Пользовательские обработчики
|
||||||
dp.include_router(admin_menu.router) # Router - admin
|
dp.include_router(admin_menu.router) # Админские обработчики
|
||||||
|
|
||||||
# Connect needed routers
|
# Обработка всего, что не поймали выше
|
||||||
dp.include_router(main_missed.router) # Router - missed handlers
|
dp.include_router(main_missed.router) # Пропущенные сообщения и колбэки
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# - *- coding: utf- 8 - *-
|
# - *- coding: utf- 8 - *-
|
||||||
import os
|
from pathlib import Path
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
from aiogram import Router, Bot, F
|
from aiogram import Router, Bot, F
|
||||||
@@ -7,87 +7,105 @@ from aiogram.filters import Command
|
|||||||
from aiogram.types import FSInputFile, Message, CallbackQuery
|
from aiogram.types import FSInputFile, Message, CallbackQuery
|
||||||
from aiogram.utils.media_group import MediaGroupBuilder
|
from aiogram.utils.media_group import MediaGroupBuilder
|
||||||
|
|
||||||
from tgbot.data.config import PATH_DATABASE, PATH_LOGS
|
from tgbot.data.config import BOT_DATABASE_EXPORT, PATH_DATABASE, PATH_LOGS
|
||||||
from tgbot.database.db_users import BaseModel as UserModel
|
from tgbot.database.db_users import UserModel
|
||||||
from tgbot.keyboards.inline_main import admin_finl
|
from tgbot.keyboards.inline_main import admin_finl
|
||||||
from tgbot.utils.const_functions import get_date
|
from tgbot.utils.const_functions import get_date
|
||||||
from tgbot.utils.misc.bot_models import FSM, ARS
|
from tgbot.utils.misc.bot_models import FSM, ARS
|
||||||
|
|
||||||
router = Router(name=__name__)
|
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):
|
async def admin_button_inline(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"Inline admin keyboards",
|
"Инлайн-клавиатура админа",
|
||||||
reply_markup=admin_finl()
|
reply_markup=admin_finl()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Callback - Admin X
|
# Колбэк для демо-действия админа
|
||||||
@router.callback_query(F.data == 'admin_inline_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):
|
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:'))
|
@router.callback_query(F.data.startswith('admin_inline:'))
|
||||||
async def admin_callback_inline(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
async def admin_callback_inline(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||||
get_data = call.data.split(":")[1]
|
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']))
|
@router.message(Command(commands=['db', 'database']))
|
||||||
async def admin_database(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
async def admin_database(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
if not BOT_DATABASE_EXPORT:
|
||||||
|
await message.answer("<b>📦 Экспорт базы данных отключён в настройках</b>")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not Path(PATH_DATABASE).is_file():
|
||||||
|
await message.answer("<b>📦 Файл базы данных не найден</b>")
|
||||||
|
return
|
||||||
|
|
||||||
await message.answer_document(
|
await message.answer_document(
|
||||||
FSInputFile(PATH_DATABASE),
|
FSInputFile(PATH_DATABASE),
|
||||||
caption=f"<b>📦 #BACKUP | <code>{get_date()}</code></b>",
|
caption=f"<b>📦 #БЭКАП | <code>{get_date()}</code></b>",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Get Logs file
|
# Отправка логов админам
|
||||||
@router.message(Command(commands=['log', 'logs']))
|
@router.message(Command(commands=['log', 'logs']))
|
||||||
async def admin_log(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
async def admin_log(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
media_group = MediaGroupBuilder(
|
log_files = []
|
||||||
caption=f"<b>🖨 #LOGS | <code>{get_date(full=False)}</code></b>",
|
|
||||||
)
|
|
||||||
|
|
||||||
if os.path.isfile(PATH_LOGS):
|
if Path(PATH_LOGS).is_file():
|
||||||
media_group.add_document(media=FSInputFile(PATH_LOGS))
|
log_files.append(PATH_LOGS)
|
||||||
|
|
||||||
if os.path.isfile("tgbot/data/sv_log_err.log"):
|
for log_file in SERVICE_LOG_FILES:
|
||||||
media_group.add_document(media=FSInputFile("tgbot/data/sv_log_err.log"))
|
if log_file.is_file():
|
||||||
|
log_files.append(str(log_file))
|
||||||
|
|
||||||
if os.path.isfile("tgbot/data/sv_log_out.log"):
|
caption = f"<b>🖨 #ЛОГИ | <code>{get_date(full=False)}</code></b>"
|
||||||
media_group.add_document(media=FSInputFile("tgbot/data/sv_log_out.log"))
|
|
||||||
|
|
||||||
await message.answer_media_group(media=media_group.build())
|
if len(log_files) == 0:
|
||||||
|
await message.answer("<b>🖨 Логи не найдены</b>")
|
||||||
|
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']))
|
@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):
|
async def admin_logs_clear(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
if os.path.isfile(PATH_LOGS):
|
log_files = [Path(PATH_LOGS), *SERVICE_LOG_FILES]
|
||||||
async with aiofiles.open(PATH_LOGS, "w") as file:
|
|
||||||
await file.write(f"{get_date()} | LOGS WAS CLEAR")
|
|
||||||
|
|
||||||
if os.path.isfile("tgbot/data/sv_log_err.log"):
|
for log_file in log_files:
|
||||||
async with aiofiles.open("tgbot/data/sv_log_err.log", "w") as file:
|
if not log_file.is_file():
|
||||||
await file.write(f"{get_date()} | LOGS WAS CLEAR")
|
continue
|
||||||
|
|
||||||
if os.path.isfile("tgbot/data/sv_log_out.log"):
|
async with aiofiles.open(log_file, "w", encoding="utf-8") as file:
|
||||||
async with aiofiles.open("tgbot/data/sv_log_out.log", "w") as file:
|
await file.write(f"{get_date()} | ЛОГИ ОЧИЩЕНЫ")
|
||||||
await file.write(f"{get_date()} | LOGS WAS CLEAR")
|
|
||||||
|
|
||||||
await message.answer("<b>🖨 The logs have been cleared</b>")
|
await message.answer("<b>🖨 Логи очищены</b>")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# - *- coding: utf- 8 - *-
|
# - *- coding: utf- 8 - *-
|
||||||
from aiogram import Router
|
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 aiogram.handlers import ErrorHandler
|
||||||
|
|
||||||
from tgbot.utils.misc.bot_logging import bot_logger
|
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__)
|
router = Router(name=__name__)
|
||||||
|
|
||||||
|
|
||||||
# Error with block forbidden user
|
# Ошибка при отправке сообщения пользователю, который заблокировал бота
|
||||||
# @router.errors(ExceptionTypeFilter(TelegramForbiddenError))
|
# @router.errors(ExceptionTypeFilter(TelegramForbiddenError))
|
||||||
# class MyHandler(ErrorHandler):
|
# class ForbiddenErrorHandler(ErrorHandler):
|
||||||
# async def handle(self):
|
# async def handle(self):
|
||||||
# ...
|
# ...
|
||||||
|
|
||||||
|
|
||||||
# Error with edit duplicate message
|
# Безопасно игнорируем повторное редактирование сообщения без изменений
|
||||||
@router.errors(ExceptionMessageFilter(
|
@router.errors(ExceptionTypeFilter(TelegramBadRequest))
|
||||||
"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 MessageNotModifiedHandler(ErrorHandler):
|
||||||
)
|
|
||||||
class MyHandler(ErrorHandler):
|
|
||||||
async def handle(self):
|
async def handle(self):
|
||||||
bot_logger.exception(
|
if "message is not modified" in self.exception_message.lower():
|
||||||
f"====================\n"
|
bot_logger.debug("Telegram отклонил повторное редактирование сообщения без изменений")
|
||||||
f"Exception name: {self.exception_name}\n"
|
return True
|
||||||
f"Exception message: {self.exception_message}\n"
|
|
||||||
f"===================="
|
raise self.event
|
||||||
)
|
|
||||||
|
|||||||
@@ -9,28 +9,28 @@ from tgbot.utils.misc.bot_models import FSM, ARS
|
|||||||
router = Router(name=__name__)
|
router = Router(name=__name__)
|
||||||
|
|
||||||
|
|
||||||
# Callback with delete message
|
# Колбэк для удаления текущего сообщения
|
||||||
@router.callback_query(F.data == 'close_this')
|
@router.callback_query(F.data == 'close_this')
|
||||||
async def main_callback_close(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: ModelUsers):
|
async def main_callback_close(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: ModelUsers):
|
||||||
await del_message(call.message)
|
await del_message(call.message)
|
||||||
|
|
||||||
|
|
||||||
# Callback with processing miss button
|
# Колбэк-заглушка для пустых кнопок
|
||||||
@router.callback_query(F.data == '...')
|
@router.callback_query(F.data == '...')
|
||||||
async def main_callback_answer(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: ModelUsers):
|
async def main_callback_answer(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: ModelUsers):
|
||||||
await call.answer(cache_time=30)
|
await call.answer(cache_time=30)
|
||||||
|
|
||||||
|
|
||||||
# Callback with processing miss callback data
|
# Ответ на колбэк, который никто не обработал
|
||||||
@router.callback_query()
|
@router.callback_query()
|
||||||
async def main_callback_missed(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: ModelUsers):
|
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()
|
@router.message()
|
||||||
async def main_message_missed(message: Message, bot: Bot, state: FSM, arSession: ARS, User: ModelUsers):
|
async def main_message_missed(message: Message, bot: Bot, state: FSM, arSession: ARS, User: ModelUsers):
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"♦️ Unknown command.\n"
|
"♦️ Неизвестная команда\n"
|
||||||
"♦️ Enter /start",
|
"♦️ Введите /start",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from aiogram import Router, Bot, F
|
|||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
from aiogram.types import Message
|
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.keyboards.reply_main import menu_frep
|
||||||
from tgbot.utils.const_functions import ded
|
from tgbot.utils.const_functions import ded
|
||||||
from tgbot.utils.misc.bot_models import FSM, ARS
|
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__)
|
router = Router(name=__name__)
|
||||||
|
|
||||||
|
|
||||||
# Main menu
|
# Главное меню пользователя
|
||||||
@router.message(F.text.in_(('menu', 'return', 'start')))
|
@router.message(F.text.in_(('menu', 'return', 'start')))
|
||||||
@router.message(Command(commands=['start']))
|
@router.message(Command(commands=['start']))
|
||||||
async def main_start(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
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(
|
await message.answer(
|
||||||
ded(f"""
|
ded(f"""
|
||||||
🔸 Hello, {User.user_name}
|
🔸 Привет, {User.user_name}
|
||||||
🔸 Enter /start or /menu
|
🔸 Введи /start или /menu
|
||||||
"""),
|
"""),
|
||||||
reply_markup=menu_frep(message.from_user.id),
|
reply_markup=menu_frep(message.from_user.id),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from aiogram import Router, Bot, F
|
|||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
from aiogram.types import Message, CallbackQuery
|
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.inline_main import user_finl
|
||||||
from tgbot.keyboards.reply_main import menu_frep
|
from tgbot.keyboards.reply_main import menu_frep
|
||||||
from tgbot.utils.misc.bot_models import FSM, ARS
|
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__)
|
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):
|
async def user_button_inline(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"Inline user keyboards",
|
"Инлайн-клавиатура пользователя",
|
||||||
reply_markup=user_finl()
|
reply_markup=user_finl()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Command - /menu
|
# Команда возврата в меню
|
||||||
@router.message(Command(commands="menu"))
|
@router.message(Command(commands="menu"))
|
||||||
async def user_command_menu(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
async def user_command_menu(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"Enter command - /menu",
|
"Команда /menu открывает главное меню",
|
||||||
reply_markup=menu_frep(message.from_user.id),
|
reply_markup=menu_frep(message.from_user.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Callback - User X
|
# Колбэк для демо-действия
|
||||||
@router.callback_query(F.data == 'user_inline_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):
|
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:'))
|
@router.callback_query(F.data.startswith('user_inline:'))
|
||||||
async def user_callback_inline(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
async def user_callback_inline(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||||
get_data = call.data.split(":")[1]
|
get_data = call.data.split(":")[1]
|
||||||
|
|
||||||
await call.answer(f"Click User - {get_data}", True)
|
await call.answer(f"Выбран раздел: {get_data}", True)
|
||||||
|
|||||||
@@ -4,20 +4,21 @@ from typing import Optional
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
|
|
||||||
# In handler
|
# Пример использования в обработчике
|
||||||
# session = await arSession.get_session()
|
# session = await arSession.get_session()
|
||||||
# response = await session.get(...)
|
# response = await session.get(...)
|
||||||
# response = await session.post(...)
|
# response = await session.post(...)
|
||||||
|
|
||||||
# Асинхронная сессия для запросов
|
# Асинхронная сессия для запросов
|
||||||
class AsyncRequestSession:
|
class AsyncRequestSession:
|
||||||
def __init__(self) -> None:
|
def __init__(self, timeout: int = 30) -> None:
|
||||||
self._session: Optional[aiohttp.ClientSession] = None
|
self._session: Optional[aiohttp.ClientSession] = None
|
||||||
|
self._timeout = aiohttp.ClientTimeout(total=timeout)
|
||||||
|
|
||||||
# Получение сессии
|
# Получение сессии
|
||||||
async def get_session(self) -> aiohttp.ClientSession:
|
async def get_session(self) -> aiohttp.ClientSession:
|
||||||
if self._session is None:
|
if self._session is None:
|
||||||
new_session = aiohttp.ClientSession()
|
new_session = aiohttp.ClientSession(timeout=self._timeout)
|
||||||
self._session = new_session
|
self._session = new_session
|
||||||
|
|
||||||
return self._session
|
return self._session
|
||||||
@@ -25,6 +26,7 @@ class AsyncRequestSession:
|
|||||||
# Закрытие сессии
|
# Закрытие сессии
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
if self._session is None:
|
if self._session is None:
|
||||||
return None
|
return
|
||||||
|
|
||||||
await self._session.close()
|
await self._session.close()
|
||||||
|
self._session = None
|
||||||
|
|||||||
+114
-169
@@ -1,26 +1,35 @@
|
|||||||
# - *- coding: utf- 8 - *-
|
# - *- coding: utf- 8 - *-
|
||||||
import random
|
import html
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
import textwrap
|
||||||
import time
|
import time
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Union
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
import pytz
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiogram.types import (InlineKeyboardButton, KeyboardButton, WebAppInfo, Message, InlineKeyboardMarkup,
|
from aiogram.types import (InlineKeyboardButton, KeyboardButton, WebAppInfo, Message, InlineKeyboardMarkup,
|
||||||
ReplyKeyboardMarkup)
|
ReplyKeyboardMarkup)
|
||||||
|
from pytz import timezone
|
||||||
|
|
||||||
from tgbot.data.config import get_admins, BOT_TIMEZONE
|
from tgbot.data.config import get_admins, BOT_TIMEZONE
|
||||||
|
from tgbot.utils.misc.bot_logging import bot_logger
|
||||||
|
|
||||||
|
|
||||||
#################################### AIOGRAM ###################################
|
#################################### AIOGRAM ###################################
|
||||||
# Generate replay button
|
# Быстрая сборка реплай-кнопки
|
||||||
def rkb(text: str) -> KeyboardButton:
|
def rkb(text: str) -> KeyboardButton:
|
||||||
return KeyboardButton(text=text)
|
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:
|
if data is not None:
|
||||||
return InlineKeyboardButton(text=text, callback_data=data)
|
return InlineKeyboardButton(text=text, callback_data=data)
|
||||||
elif url is not None:
|
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:
|
elif web is not None:
|
||||||
return InlineKeyboardButton(text=text, web_app=WebAppInfo(url=web))
|
return InlineKeyboardButton(text=text, web_app=WebAppInfo(url=web))
|
||||||
else:
|
else:
|
||||||
raise "Unknown data"
|
raise ValueError("Не указано действие для инлайн-кнопки")
|
||||||
|
|
||||||
|
|
||||||
# Deleting a message with error handling from Telegram
|
# Удаление сообщения без падения на ошибках Telegram
|
||||||
async def del_message(message: Message):
|
async def del_message(message: Message):
|
||||||
try:
|
try:
|
||||||
await message.delete()
|
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(
|
async def smart_message(
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
text: str,
|
text: str,
|
||||||
keyboard: Union[InlineKeyboardMarkup, ReplyKeyboardMarkup] = None,
|
keyboard: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = None,
|
||||||
photo: Union[str, None] = None,
|
photo: Optional[str] = None,
|
||||||
):
|
):
|
||||||
if photo is not None and photo.title() != "None":
|
if photo is not None and photo.title() != "None":
|
||||||
await bot.send_photo(
|
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):
|
async def send_admins(bot: Bot, text: str, markup=None, not_me=0):
|
||||||
for admin in get_admins():
|
for admin in get_admins():
|
||||||
try:
|
try:
|
||||||
@@ -75,69 +84,44 @@ async def send_admins(bot: Bot, text: str, markup=None, not_me=0):
|
|||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
disable_web_page_preview=True,
|
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:
|
def ded(get_text: str) -> str:
|
||||||
if get_text is not None:
|
return textwrap.dedent(get_text or "").strip()
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# Cleaning text of HTML tags ('<b>test</b>' -> *b*test*/b*)
|
# Чистит HTML-символы, чтобы Telegram не сломал разметку
|
||||||
def clear_html(get_text: str) -> str:
|
def clear_html(get_text: str) -> str:
|
||||||
if get_text is not None:
|
return html.escape(get_text or "", quote=False)
|
||||||
if "</" in get_text: get_text = get_text.replace("<", "*")
|
|
||||||
if "<" in get_text: get_text = get_text.replace("<", "*")
|
|
||||||
if ">" in get_text: get_text = get_text.replace(">", "*")
|
|
||||||
else:
|
|
||||||
get_text = ""
|
|
||||||
|
|
||||||
return get_text
|
|
||||||
|
|
||||||
|
|
||||||
# Cleaning up gaps in the list (['', 1, ' ', 2] -> [1, 2])
|
# Убирает пустые и мусорные элементы из списка
|
||||||
def clear_list(get_list: list) -> list:
|
def clear_list(get_list: list) -> list:
|
||||||
while "" in get_list: get_list.remove("")
|
trash = {"", " ", ".", ",", "\r", "\n"}
|
||||||
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")
|
|
||||||
|
|
||||||
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)]
|
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:
|
def get_date(full: bool = True) -> str:
|
||||||
|
bot_timezone = timezone(BOT_TIMEZONE)
|
||||||
|
|
||||||
if full:
|
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:
|
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:
|
def get_unix(full: bool = False) -> int:
|
||||||
if full:
|
if full:
|
||||||
return time.time_ns()
|
return time.time_ns()
|
||||||
@@ -145,89 +129,90 @@ def get_unix(full: bool = False) -> int:
|
|||||||
return int(time.time())
|
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]:
|
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):
|
if from_time.isdigit():
|
||||||
from_time = from_time.replace("-", ".")
|
from_timestamp = int(from_time)
|
||||||
|
|
||||||
if str(from_time).isdigit():
|
|
||||||
if full:
|
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:
|
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:
|
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:
|
else:
|
||||||
if " " in str(from_time):
|
parts = from_time.split()
|
||||||
cache_time = from_time.split(" ")
|
|
||||||
|
|
||||||
if ":" in cache_time[0]:
|
if len(parts) == 2 and ":" in parts[0]:
|
||||||
cache_date = cache_time[1].split(".")
|
time_part, date_part = parts
|
||||||
cache_time = cache_time[0].split(":")
|
elif len(parts) == 2:
|
||||||
else:
|
date_part, time_part = parts
|
||||||
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}"
|
|
||||||
else:
|
else:
|
||||||
cache_date = from_time.split(".")
|
date_part, time_part = from_time, "00:00:00"
|
||||||
|
|
||||||
if len(cache_date[0]) == 4:
|
date_values = date_part.split(".")
|
||||||
x_year, x_month, x_day = cache_date[0], cache_date[1], cache_date[2]
|
time_values = time_part.split(":")
|
||||||
else:
|
|
||||||
x_year, x_month, x_day = cache_date[2], cache_date[1], cache_date[0]
|
|
||||||
|
|
||||||
from_time = f"{x_day}.{x_month}.{x_year}"
|
if len(time_values) == 2:
|
||||||
|
time_values.append("0")
|
||||||
|
|
||||||
if " " in str(from_time):
|
if len(date_values[0]) == 4:
|
||||||
to_time = int(datetime.strptime(from_time, "%d.%m.%Y %H:%M:%S").timestamp())
|
x_year, x_month, x_day = date_values[0], date_values[1], date_values[2]
|
||||||
else:
|
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
|
return to_time
|
||||||
|
|
||||||
|
|
||||||
# Generation of a unique ID
|
# Генерация числового уникального ID
|
||||||
def gen_id(len_id: int = 16) -> int:
|
def gen_id(len_id: int = 16) -> int:
|
||||||
mac_address = uuid.getnode()
|
if len_id <= 0:
|
||||||
time_unix = int(str(time.time_ns())[:len_id])
|
raise ValueError("Длина ID должна быть больше нуля")
|
||||||
random_int = int(''.join(random.choices('0123456789', k=len_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:
|
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":
|
if type_password == "default":
|
||||||
char_password = list("1234567890abcdefghigklmnopqrstuvyxwzABCDEFGHIGKLMNOPQRSTUVYXWZ")
|
alphabet = string.ascii_letters + string.digits
|
||||||
elif type_password == "letter":
|
elif type_password == "letter":
|
||||||
char_password = list("abcdefghigklmnopqrstuvyxwzABCDEFGHIGKLMNOPQRSTUVYXWZ")
|
alphabet = string.ascii_letters
|
||||||
elif type_password == "number":
|
elif type_password == "number":
|
||||||
char_password = list("1234567890")
|
alphabet = string.digits
|
||||||
elif type_password == "onechar":
|
elif type_password == "onechar":
|
||||||
char_password = list("1234567890")
|
alphabet = string.digits
|
||||||
|
else:
|
||||||
|
raise ValueError("Неизвестный тип пароля")
|
||||||
|
|
||||||
random.shuffle(char_password)
|
random_chars = "".join(secrets.choice(alphabet) for _ in range(len_password))
|
||||||
random_chars = "".join([random.choice(char_password) for x in range(len_password)])
|
|
||||||
|
|
||||||
if type_password == "onechar":
|
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
|
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:
|
def convert_times(get_time: int, get_type: str = "day") -> str:
|
||||||
get_time = int(get_time)
|
get_time = int(get_time)
|
||||||
if get_time < 0: get_time = 0
|
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]}"
|
return f"{get_time} {get_list[count]}"
|
||||||
|
|
||||||
|
|
||||||
# Boolean type check
|
# Приводит строку или число к bool
|
||||||
def is_bool(value: Union[bool, str, int]) -> 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'):
|
if value in ('y', 'yes', 't', 'true', 'on', '1'):
|
||||||
return True
|
return True
|
||||||
elif value in ('n', 'no', 'f', 'false', 'off', '0'):
|
elif value in ('n', 'no', 'f', 'false', 'off', '0'):
|
||||||
return False
|
return False
|
||||||
else:
|
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:
|
def snum(amount: Union[int, float], remains: int = 2) -> str:
|
||||||
format_str = "{:." + str(remains) + "f}"
|
format_str = "{:." + str(remains) + "f}"
|
||||||
str_amount = format_str.format(float(amount))
|
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)
|
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]:
|
def to_float(get_number, remains: int = 2) -> Union[int, float]:
|
||||||
if "," in str(get_number):
|
value = str(get_number).strip().replace(" ", "").replace(",", ".")
|
||||||
get_number = str(get_number).replace(",", ".")
|
number = round(float(value), remains)
|
||||||
|
|
||||||
if "." in str(get_number):
|
if number.is_integer():
|
||||||
get_last = str(get_number).split(".")
|
return int(number)
|
||||||
|
|
||||||
if str(get_last[1]).endswith("0"):
|
return number
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# Converting a real number to an integer
|
# Округляет число до int
|
||||||
def to_int(get_number: float) -> 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 = str(get_number).replace(",", ".")
|
||||||
|
|
||||||
get_number = int(round(float(get_number)))
|
get_number = int(round(float(get_number)))
|
||||||
@@ -327,7 +294,7 @@ def to_int(get_number: float) -> int:
|
|||||||
return get_number
|
return get_number
|
||||||
|
|
||||||
|
|
||||||
# Data validation for numbers
|
# Проверяет, является ли значение числом
|
||||||
def is_number(get_number: Union[str, int, float]) -> bool:
|
def is_number(get_number: Union[str, int, float]) -> bool:
|
||||||
if str(get_number).isdigit():
|
if str(get_number).isdigit():
|
||||||
return True
|
return True
|
||||||
@@ -337,39 +304,17 @@ def is_number(get_number: Union[str, int, float]) -> bool:
|
|||||||
try:
|
try:
|
||||||
float(get_number)
|
float(get_number)
|
||||||
return True
|
return True
|
||||||
except ValueError:
|
except (TypeError, ValueError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# Converting a number to a readable form (123456789 -> 123,456,789)
|
# Форматирует число с разделением тысяч
|
||||||
def format_rate(amount: Union[float, int], around: int = 2) -> str:
|
def format_rate(amount: Union[float, int], around: int = 2) -> str:
|
||||||
if "," in str(amount): amount = float(str(amount).replace(",", "."))
|
value = str(amount).strip().replace(" ", "").replace(",", ".")
|
||||||
if " " in str(amount): amount = float(str(amount).replace(" ", ""))
|
number = round(float(value), around)
|
||||||
amount = str(round(amount, around))
|
response = f"{number:,.{around}f}".replace(",", " ")
|
||||||
|
|
||||||
out_amount, save_remains = [], ""
|
if "." in response:
|
||||||
|
response = response.rstrip("0").rstrip(".")
|
||||||
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]
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -2,29 +2,32 @@
|
|||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiogram.types import BotCommand, BotCommandScopeChat, BotCommandScopeDefault
|
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 = [
|
user_commands = [
|
||||||
BotCommand(command="start", description="♻️ Restart bot"),
|
BotCommand(command="start", description="♻️ Перезапуск бота"),
|
||||||
BotCommand(command="menu", description="🌀 Get keyboards"),
|
BotCommand(command="menu", description="🌀 Получение клавиатуры"),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Commands for admins
|
# Команды для админов
|
||||||
admin_commands = [
|
admin_commands = [
|
||||||
BotCommand(command="start", description="♻️ Restart bot"),
|
BotCommand(command="start", description="♻️ Перезапуск бота"),
|
||||||
BotCommand(command="menu", description="🌀 Get keyboards"),
|
BotCommand(command="menu", description="🌀 Получение клавиатуры"),
|
||||||
BotCommand(command="log", description="🖨 Get Logs"),
|
BotCommand(command="log", description="🖨 Получить логи"),
|
||||||
BotCommand(command="db", description="📦 Get Database"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if BOT_DATABASE_EXPORT:
|
||||||
|
admin_commands.append(BotCommand(command="db", description="📦 Получить БД"))
|
||||||
|
|
||||||
# Set commands
|
|
||||||
|
# Обновление списка команд в Telegram
|
||||||
async def set_commands(bot: Bot):
|
async def set_commands(bot: Bot):
|
||||||
await bot.set_my_commands(user_commands, scope=BotCommandScopeDefault())
|
await bot.set_my_commands(user_commands, scope=BotCommandScopeDefault())
|
||||||
|
|
||||||
for admin in get_admins():
|
for admin in get_admins():
|
||||||
try:
|
try:
|
||||||
await bot.set_my_commands(admin_commands, scope=BotCommandScopeChat(chat_id=admin))
|
await bot.set_my_commands(admin_commands, scope=BotCommandScopeChat(chat_id=admin))
|
||||||
except:
|
except Exception:
|
||||||
...
|
bot_logger.warning("Не удалось обновить команды для админа %s", admin, exc_info=True)
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
# - *- coding: utf- 8 - *-
|
# - *- coding: utf- 8 - *-
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
from aiogram.filters import BaseFilter
|
from aiogram.filters import BaseFilter
|
||||||
from aiogram.types import Message
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
|
||||||
from tgbot.data.config import get_admins
|
from tgbot.data.config import get_admins
|
||||||
|
|
||||||
|
|
||||||
# Filter on admin right
|
# Проверка, что действия совершает админ
|
||||||
class IsAdmin(BaseFilter):
|
class IsAdmin(BaseFilter):
|
||||||
async def __call__(self, message: Message) -> bool:
|
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
|
||||||
if message.from_user.id in get_admins():
|
user = getattr(event, "from_user", None)
|
||||||
|
|
||||||
|
return bool(user and user.id in get_admins())
|
||||||
|
|
||||||
|
|
||||||
|
# Проверка приватного чата
|
||||||
|
class IsPrivate(BaseFilter):
|
||||||
|
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
|
||||||
|
chat = getattr(event, "chat", None)
|
||||||
|
message = getattr(event, "message", None)
|
||||||
|
|
||||||
|
if chat is None and message is not None:
|
||||||
|
chat = message.chat
|
||||||
|
|
||||||
|
if chat is None:
|
||||||
return True
|
return True
|
||||||
else:
|
|
||||||
return False
|
return chat.type == "private"
|
||||||
|
|||||||
@@ -1,32 +1,58 @@
|
|||||||
# - *- coding: utf- 8 - *-
|
# - *- coding: utf- 8 - *-
|
||||||
import logging as bot_logger
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import colorlog
|
import colorlog
|
||||||
|
|
||||||
from tgbot.data.config import PATH_LOGS
|
from tgbot.data.config import PATH_LOGS
|
||||||
|
|
||||||
# Logging format
|
LOG_FILE_MAX_BYTES = 5 * 1024 * 1024
|
||||||
log_formatter_file = bot_logger.Formatter("%(levelname)s | %(asctime)s | %(filename)s:%(lineno)d | %(message)s")
|
LOG_FILE_BACKUP_COUNT = 5
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Logging in file logs.log
|
# Папка под логи создается сама, чтобы бот не падал на старте
|
||||||
file_handler = bot_logger.FileHandler(PATH_LOGS, "w", "utf-8")
|
log_path = Path(PATH_LOGS)
|
||||||
file_handler.setFormatter(log_formatter_file)
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
file_handler.setLevel(bot_logger.INFO)
|
|
||||||
|
|
||||||
# Logging in console
|
# Один общий логгер для всего шаблона
|
||||||
console_handler = bot_logger.StreamHandler()
|
bot_logger = logging.getLogger("tgbot")
|
||||||
console_handler.setFormatter(log_formatter_console)
|
bot_logger.setLevel(logging.INFO)
|
||||||
console_handler.setLevel(bot_logger.CRITICAL)
|
bot_logger.propagate = False
|
||||||
|
|
||||||
# Connect logging settings
|
if not bot_logger.handlers:
|
||||||
bot_logger.basicConfig(
|
# Формат для файла: без цветов
|
||||||
format="%(levelname)s | %(asctime)s | %(filename)s:%(lineno)d | %(message)s",
|
file_formatter = logging.Formatter(
|
||||||
handlers=[
|
"%(levelname)s | %(asctime)s | %(name)s | %(filename)s:%(lineno)d | %(message)s",
|
||||||
file_handler,
|
datefmt="%d-%m-%Y %H:%M:%S",
|
||||||
console_handler
|
)
|
||||||
]
|
# Формат для консоли: коротко и с цветами
|
||||||
)
|
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)
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ from aiogram.fsm.context import FSMContext
|
|||||||
|
|
||||||
from tgbot.services.api_session import AsyncRequestSession
|
from tgbot.services.api_session import AsyncRequestSession
|
||||||
|
|
||||||
|
# Короткие алиасы для типизации обработчиков
|
||||||
FSM = FSMContext
|
FSM = FSMContext
|
||||||
ARS = AsyncRequestSession
|
ARS = AsyncRequestSession
|
||||||
|
|||||||
@@ -2,24 +2,28 @@
|
|||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiogram.types import FSInputFile
|
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.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):
|
async def startup_notify(bot: Bot):
|
||||||
if len(get_admins()) >= 1 and BOT_STATUS_NOTIFICATION:
|
if len(get_admins()) >= 1 and BOT_STATUS_NOTIFICATION:
|
||||||
await send_admins(bot, "<b>✅ Bot was started</b>")
|
await send_admins(bot, "<b>✅ Бот запущен</b>")
|
||||||
|
|
||||||
|
|
||||||
# Autobackup Database
|
# Автобэкап базы для админов
|
||||||
async def autobackup_admin(bot: Bot):
|
async def autobackup_admin(bot: Bot):
|
||||||
|
if not BOT_DATABASE_EXPORT:
|
||||||
|
return
|
||||||
|
|
||||||
for admin in get_admins():
|
for admin in get_admins():
|
||||||
try:
|
try:
|
||||||
await bot.send_document(
|
await bot.send_document(
|
||||||
admin,
|
admin,
|
||||||
FSInputFile(PATH_DATABASE),
|
FSInputFile(PATH_DATABASE),
|
||||||
caption=f"<b>📦 #AUTOBACKUP | <code>{get_date()}</code></b>",
|
caption=f"<b>📦 #АВТОБЭКАП | <code>{get_date()}</code></b>",
|
||||||
)
|
)
|
||||||
except:
|
except Exception:
|
||||||
...
|
bot_logger.warning("Не удалось отправить автобэкап админу %s", admin, exc_info=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user