Update aiogram 3 template

This commit is contained in:
dj
2026-05-29 09:44:39 +03:00
parent e144cb1424
commit 2eced5dc4f
43 changed files with 1877 additions and 1252 deletions
+36
View File
@@ -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/
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+301 -2
View File
@@ -1,3 +1,302 @@
### [![Python](https://img.shields.io/badge/Python-3.10-blue)](https://www.python.org/downloads/release/python-399/) [![Aiogram](https://img.shields.io/badge/aiogram-3.4.1-blue)](https://pypi.org/project/aiogram/) [![Last UPD](https://img.shields.io/badge/LastUPD-10.10.2025-blue)](https://example.com/) # Djimbo Template для Telegram-ботов на aiogram 3
# Template telegram bot for Aiogram 3 by Djimbo Шаблон для быстрого старта Telegram-бота на `aiogram 3`, `SQLAlchemy`, `aiosqlite`, `Alembic` и `.env`-настройках через `pydantic-settings`.
Внутри уже есть базовая структура проекта, подключение роутеров, middleware для пользователя, админские фильтры, логирование, миграции БД и пример пользовательского/админского меню.
## Стек
- Python 3.11
- aiogram 3.28.2
- SQLAlchemy 2.x
- aiosqlite
- Alembic
- APScheduler
- aiohttp
- pydantic-settings
- pytz
## Быстрый старт
1. Создание виртуального окружения:
```bash
python3.11 -m venv .venv
source .venv/bin/activate
```
2. Установка зависимостей:
```bash
pip install -r requirements.txt
```
3. Создание локального конфига:
```bash
cp .env.example .env
```
4. Заполнение `.env`:
```env
BOT_TOKEN=123456:telegram_bot_token
BOT_ADMIN_IDS=123456789
BOT_DATABASE_EXPORT=False
```
`BOT_ADMIN_IDS` можно указать через запятую: `123456789,987654321`.
5. Применение миграций:
```bash
python migrate.py up
```
Без аргументов `python migrate.py` только показывает подсказки. Бот сам миграции при старте не запускает, чтобы не менять схему базы неожиданно.
6. Запуск бота:
```bash
python main.py
```
## Docker Compose
Запуск через compose:
```bash
docker compose up --build
```
Остановка:
```bash
docker compose down
```
`docker-compose.yml` использует `Dockerfile`: Dockerfile собирает образ, а compose управляет запуском, `.env`, volume и restart-политикой.
При запуске контейнер сначала применяет миграции командой `python migrate.py up`, а потом запускает бота через `python main.py`.
Volume `./tgbot/data:/app/tgbot/data` нужен, чтобы база и логи не пропадали после остановки контейнера.
Если compose не нужен, можно запустить Docker вручную:
```bash
docker build -t djimbo-template .
docker run --rm --env-file .env -v "$(pwd)/tgbot/data:/app/tgbot/data" djimbo-template
```
## pyproject.toml
`pyproject.toml` описывает проект для современных Python-инструментов.
В этом шаблоне он нужен для:
- указания версии Python;
- описания зависимостей проекта;
- установки проекта как пакета через `pip install -e .`;
- настройки сборки через `setuptools`.
`requirements.txt` оставлен для простого запуска и Docker. Если коротко: `requirements.txt` удобен для установки зависимостей, а `pyproject.toml` описывает сам проект.
## Настройки
| Параметр | Что делает |
| --- | --- |
| `BOT_TOKEN` | Токен Telegram-бота от BotFather |
| `BOT_ADMIN_IDS` | Telegram ID админов, один или несколько через запятую |
| `BOT_DATABASE_EXPORT` | Разрешает отправку файла БД админам через `/db` и ежедневный автобэкап |
| `BOT_STATUS_NOTIFICATION` | Включает уведомление админов о запуске |
| `BOT_TIMEZONE` | Временная зона бота |
| `BOT_USER_CACHE_TTL` | Время кеширования пользователя в middleware |
| `BOT_THROTTLE_RATE` | Базовая задержка антиспама |
| `PATH_DATABASE` | Путь к SQLite-базе |
| `PATH_LOGS` | Путь к файлу логов |
По умолчанию `BOT_DATABASE_EXPORT=False`. Это специально: база может содержать персональные данные, поэтому экспорт надо включать руками и осознанно.
## Структура проекта
```text
.
├── main.py # Точка входа
├── migrate.py # Удобная CLI-обертка для Alembic
├── Dockerfile # Запуск шаблона в Docker
├── docker-compose.yml # Удобный запуск Docker-контейнера
├── .dockerignore # Что не попадет в Docker-образ
├── pyproject.toml # Метаданные проекта
├── alembic.ini # Настройки Alembic
├── migrations/ # Миграции базы данных
├── .env.example # Пример локального .env
├── tgbot/
│ ├── data/config.py # Настройки и пути
│ ├── database/ # SQLAlchemy-модели и репозитории
│ ├── keyboards/ # Reply и inline-клавиатуры
│ ├── middlewares/ # Middleware
│ ├── routers/ # Обработчики aiogram
│ ├── services/ # Внешние сервисы и aiohttp-сессия
│ └── utils/ # Общие утилиты
└── requirements.txt
```
## База данных
Проект использует SQLite через `aiosqlite`, но работа с таблицами идёт через async `SQLAlchemy`.
Ключевые файлы БД:
- `core.py` - `Base`, `engine`, `session_factory`, `session_scope`
- `repository.py` - базовый репозиторий и проверка готовности БД
- `migration_runner.py` - запуск Alembic из кода
- `db_users.py` - пользователи Telegram
- `db_settings.py` - настройки бота в БД
### UNIQUE
В таблице пользователей поле `user_id` уникальное.
Это значит, что один Telegram-пользователь не может появиться в таблице два раза. Если пользователь уже есть, база не создаст дубль.
### UPSERT
UPSERT - это логика “создай запись, а если она уже есть, обнови”.
В шаблоне пользователь добавляется по `user_id`. Если он уже есть, обновляются только изменившиеся поля: username, имя, фамилия и полное имя. Если данные не поменялись, лишнего UPDATE в БД не будет.
## Миграции
Миграции управляются через Alembic, но запускать их удобнее через готовый CLI.
Показать справку:
```bash
python migrate.py
```
Применить все миграции:
```bash
python migrate.py up
```
То же самое длинной командой:
```bash
python migrate.py upgrade
```
Посмотреть текущую версию БД:
```bash
python migrate.py status
```
Посмотреть историю:
```bash
python migrate.py history
```
Создать новую миграцию вручную:
```bash
python migrate.py new "add payments table"
```
Создать миграцию по изменениям SQLAlchemy-моделей:
```bash
python migrate.py auto "add payments table"
```
Откатить последнюю миграцию:
```bash
python migrate.py down
```
Полные Alembic-команды тоже доступны: `upgrade`, `downgrade`, `revision`, `current`, `history`, `heads`.
Короткие алиасы: `up`, `down`, `new`, `auto`, `autogen`, `cur`, `hist`, `st`.
## Роутеры
Роутеры подключаются в `tgbot/routers/__init__.py`.
Текущие группы:
- `main_start.py` - старт и главное меню
- `user/user_menu.py` - пользовательские обработчики
- `admin/admin_menu.py` - админские обработчики
- `main_missed.py` - fallback на неизвестные сообщения и callback
- `main_errors.py` - обработка безопасных Telegram-ошибок
Админский роутер уже закрыт фильтром `IsAdmin()` и для сообщений, и для callback query.
## Middleware
`ExistsUserMiddleware` добавляет или обновляет пользователя в БД и прокидывает объект пользователя в обработчик как `User`.
Чтобы не писать в БД на каждый одинаковый update, middleware кеширует пользователя по `user_id`.
Пример:
```python
async def handler(message: Message, User: UserModel):
await message.answer(User.user_fullname)
```
## Логирование
Логи пишутся в `tgbot/data/logs.log`.
Файл не растёт бесконечно: используется `RotatingFileHandler`, который вращает логи по размеру.
Админ может получить логи командой:
```text
/log
```
Очистить логи:
```text
/clear_log
```
## Админские команды
| Команда | Что делает |
| --- | --- |
| `/log` | Отправляет файл логов |
| `/clear_log` | Очищает файлы логов |
| `/db` | Отправляет файл БД, только если `BOT_DATABASE_EXPORT=True` |
Команда `/db` скрывается из меню команд, если экспорт БД выключен.
## Как добавить новую таблицу
1. Создать модель в `tgbot/database/`.
2. Импортировать её в `tgbot/database/__init__.py`.
3. Создать миграцию:
```bash
python migrate.py auto "add new table"
```
4. Проверить созданный файл в `migrations/versions/`.
5. Применить миграцию:
```bash
python migrate.py up
```
## Важно
- Не коммить `.env`, базу данных и логи.
- Если токен попал в Git, его надо перевыпустить у BotFather.
- Перед деплоем проверь `BOT_DATABASE_EXPORT`: на проде лучше держать `False`, если экспорт БД реально не нужен.
- Для нового проекта сначала менять тексты, команды и клавиатуры под свою логику, а потом уже добавлять бизнес-код.
+42
View File
@@ -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
View File
@@ -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
+11
View File
@@ -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
+53 -31
View File
@@ -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
View File
@@ -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()
+79
View File
@@ -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())
+24
View File
@@ -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")
+28
View File
@@ -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*"]
+12 -7
View File
@@ -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
colorama==0.4.6 aiohttp==3.13.2
aiosqlite==0.22.1
alembic==1.18.4
cachetools==6.1.0
colorama==0.4.6
colorlog==6.10.1
pydantic==2.12.4
pydantic-settings==2.14.1
pytz==2025.2
SQLAlchemy==2.0.50
-3
View File
@@ -1,3 +0,0 @@
[settings]
bot_token=
admin_id=
+117 -31
View File
@@ -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))
+5 -2
View File
@@ -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
-494
View File
@@ -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()
-36
View File
@@ -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',
# },
}
+51
View File
@@ -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()
+48 -30
View File
@@ -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
View File
@@ -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)
+59
View File
@@ -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())
+106
View File
@@ -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("База данных готова")
+10 -10
View File
@@ -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()
+2 -2
View File
@@ -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)
+10 -3
View File
@@ -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)
+52 -29
View File
@@ -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)
+30 -23
View File
@@ -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
View File
@@ -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) # Пропущенные сообщения и колбэки
+52 -34
View File
@@ -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>")
+12 -14
View File
@@ -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
)
+7 -7
View File
@@ -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",
) )
+4 -4
View File
@@ -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),
) )
+10 -10
View File
@@ -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)
+6 -4
View File
@@ -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
View File
@@ -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
+15 -12
View File
@@ -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)
+22 -6
View File
@@ -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"
+49 -23
View File
@@ -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)
+1
View File
@@ -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
+11 -7
View File
@@ -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)