generated from FOSS/Bot-Template
Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from pydantic import AliasChoices, Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pytz import UnknownTimeZoneError, timezone
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||
ENV_PATH = BASE_DIR / ".env"
|
||||
|
||||
|
||||
# Все настройки из окружения или локального .env
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=ENV_PATH,
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
bot_token: str = Field(default="", validation_alias=AliasChoices("BOT_TOKEN", "bot_token"))
|
||||
admin_ids: List[int] = Field(default_factory=list, validation_alias=AliasChoices("BOT_ADMIN_IDS", "admin_id"))
|
||||
database_export: bool = Field(default=False,
|
||||
validation_alias=AliasChoices("BOT_DATABASE_EXPORT", "allow_database_export"))
|
||||
status_notification: bool = Field(default=True, validation_alias="BOT_STATUS_NOTIFICATION")
|
||||
timezone: str = Field(default="Europe/Moscow", validation_alias="BOT_TIMEZONE")
|
||||
database_path: str = Field(default="tgbot/data/database.db", validation_alias="PATH_DATABASE")
|
||||
logs_path: str = Field(default="tgbot/data/logs.log", validation_alias="PATH_LOGS")
|
||||
user_cache_ttl: int = Field(default=300, ge=0, validation_alias="BOT_USER_CACHE_TTL")
|
||||
throttle_rate: float = Field(default=0.5, ge=0, validation_alias="BOT_THROTTLE_RATE")
|
||||
|
||||
@field_validator("bot_token", "timezone", "database_path", "logs_path", mode="before")
|
||||
@classmethod
|
||||
def _strip_string(cls, value: object) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
@field_validator("admin_ids", mode="before")
|
||||
@classmethod
|
||||
def _parse_admin_ids(cls, value: object) -> List[int]:
|
||||
if value is None or value == "":
|
||||
return []
|
||||
|
||||
if isinstance(value, int):
|
||||
values = [value]
|
||||
elif isinstance(value, str):
|
||||
values = [admin_id for admin_id in value.replace(" ", "").split(",") if admin_id]
|
||||
elif isinstance(value, (list, tuple, set)):
|
||||
values = list(value)
|
||||
else:
|
||||
raise ValueError("BOT_ADMIN_IDS должен быть числом или списком чисел через запятую")
|
||||
|
||||
admin_ids = []
|
||||
|
||||
for admin_id in values:
|
||||
try:
|
||||
parsed_id = int(admin_id)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError("BOT_ADMIN_IDS должен содержать только Telegram ID через запятую") from error
|
||||
|
||||
if parsed_id <= 0:
|
||||
raise ValueError("BOT_ADMIN_IDS должен содержать Telegram ID больше нуля")
|
||||
|
||||
admin_ids.append(parsed_id)
|
||||
|
||||
return admin_ids
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
def _validate_timezone(cls, value: str) -> str:
|
||||
try:
|
||||
timezone(value)
|
||||
except UnknownTimeZoneError as error:
|
||||
raise ValueError("BOT_TIMEZONE должен быть корректной временной зоной, например Europe/Moscow") from error
|
||||
|
||||
return value
|
||||
|
||||
@field_validator("database_path", "logs_path")
|
||||
@classmethod
|
||||
def _validate_path(cls, value: str) -> str:
|
||||
if not value:
|
||||
raise ValueError("Путь к файлу не должен быть пустым")
|
||||
|
||||
return value
|
||||
|
||||
@property
|
||||
def admins(self) -> List[int]:
|
||||
return list(self.admin_ids)
|
||||
|
||||
def resolve_path(self, path_value: str) -> Path:
|
||||
path = Path(path_value)
|
||||
|
||||
if path.is_absolute():
|
||||
return path
|
||||
|
||||
return BASE_DIR / path
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Константы чтобы не ломать старые импорты в шаблоне
|
||||
BOT_TOKEN = settings.bot_token.replace(" ", "")
|
||||
PATH_DATABASE = str(settings.resolve_path(settings.database_path))
|
||||
PATH_LOGS = str(settings.resolve_path(settings.logs_path))
|
||||
BOT_STATUS_NOTIFICATION = settings.status_notification
|
||||
BOT_DATABASE_EXPORT = settings.database_export
|
||||
BOT_TIMEZONE = settings.timezone
|
||||
BOT_USER_CACHE_TTL = settings.user_cache_ttl
|
||||
BOT_THROTTLE_RATE = settings.throttle_rate
|
||||
BOT_SCHEDULER = AsyncIOScheduler(timezone=BOT_TIMEZONE)
|
||||
|
||||
|
||||
# Получение администраторов бота
|
||||
def get_admins() -> List[int]:
|
||||
return settings.admins
|
||||
|
||||
|
||||
# Проверка настроек, которые нужны именно для запуска бота
|
||||
def validate_bot_config() -> None:
|
||||
if not BOT_TOKEN:
|
||||
raise RuntimeError("В .env не заполнен параметр BOT_TOKEN")
|
||||
@@ -0,0 +1,5 @@
|
||||
from .db_settings import SettingsModel, SettingsRepository
|
||||
from .db_users import UserModel, UsersRepository
|
||||
|
||||
ModelSettings = SettingsModel
|
||||
ModelUsers = UserModel
|
||||
@@ -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()
|
||||
@@ -0,0 +1,61 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from sqlalchemy import Boolean, Integer
|
||||
from sqlalchemy import update as sqlalchemy_update
|
||||
from sqlalchemy.dialects.sqlite import insert
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from tgbot.database.core import Base, session_scope
|
||||
from tgbot.database.repository import BaseRepository
|
||||
|
||||
|
||||
# Модель настроек бота
|
||||
class SettingsModel(Base):
|
||||
__tablename__ = "storage_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
status_work: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
ModelBase = SettingsModel
|
||||
BaseModel = SettingsModel
|
||||
|
||||
|
||||
# Репозиторий настроек бота
|
||||
class SettingsRepository(BaseRepository[SettingsModel]):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.table_model = SettingsModel
|
||||
self.storage_name = SettingsModel.__tablename__
|
||||
|
||||
# Создание строки настроек если их еще нет
|
||||
async def ensure_default(self) -> None:
|
||||
statement = insert(SettingsModel).values(id=1, status_work=False)
|
||||
statement = statement.on_conflict_do_nothing(index_elements=[SettingsModel.id])
|
||||
|
||||
async with session_scope() as session:
|
||||
await session.execute(statement)
|
||||
|
||||
# Настройки должны быть всегда, поэтому при пустой таблице создаем дефолт
|
||||
async def get(self) -> SettingsModel:
|
||||
settings = await super().get(id=1)
|
||||
|
||||
if settings is None:
|
||||
await self.ensure_default()
|
||||
settings = await super().get(id=1)
|
||||
|
||||
if settings is None:
|
||||
raise RuntimeError("Настройки бота по умолчанию не сохранились")
|
||||
|
||||
return settings
|
||||
|
||||
# Обновление единственной строки настроек
|
||||
async def update(self, **kwargs) -> None:
|
||||
if not kwargs:
|
||||
return
|
||||
|
||||
async with session_scope() as session:
|
||||
await session.execute(
|
||||
sqlalchemy_update(SettingsModel)
|
||||
.where(SettingsModel.id == 1)
|
||||
.values(**kwargs)
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from sqlalchemy import BigInteger, Integer, String, or_
|
||||
from sqlalchemy import update as sqlalchemy_update
|
||||
from sqlalchemy.dialects.sqlite import insert
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from tgbot.database.core import Base, session_scope
|
||||
from tgbot.database.repository import BaseRepository
|
||||
from tgbot.utils.const_functions import get_unix
|
||||
|
||||
|
||||
# Модель пользователя Telegram
|
||||
class UserModel(Base):
|
||||
__tablename__ = "storage_users"
|
||||
|
||||
increment: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
|
||||
user_login: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
user_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
user_surname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
user_fullname: Mapped[str] = mapped_column(String(511), nullable=False, default="")
|
||||
user_unix: Mapped[int] = mapped_column(Integer, nullable=False, default=get_unix)
|
||||
|
||||
|
||||
ModelBase = UserModel
|
||||
BaseModel = UserModel
|
||||
|
||||
|
||||
# Репозиторий пользователей
|
||||
class UsersRepository(BaseRepository[UserModel]):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.table_model = UserModel
|
||||
self.storage_name = UserModel.__tablename__
|
||||
|
||||
# Для совместимости add ведет себя как upsert
|
||||
async def add(
|
||||
self,
|
||||
user_id: int,
|
||||
user_login: str,
|
||||
user_name: str,
|
||||
user_surname: str,
|
||||
user_fullname: str,
|
||||
) -> UserModel:
|
||||
return await self.upsert(
|
||||
user_id=user_id,
|
||||
user_login=user_login,
|
||||
user_name=user_name,
|
||||
user_surname=user_surname,
|
||||
user_fullname=user_fullname,
|
||||
)
|
||||
|
||||
# Создание пользователя или обновление его данных по user_id
|
||||
async def upsert(
|
||||
self,
|
||||
user_id: int,
|
||||
user_login: str,
|
||||
user_name: str,
|
||||
user_surname: str,
|
||||
user_fullname: str,
|
||||
) -> UserModel:
|
||||
statement = insert(UserModel).values(
|
||||
user_id=user_id,
|
||||
user_login=user_login,
|
||||
user_name=user_name,
|
||||
user_surname=user_surname,
|
||||
user_fullname=user_fullname,
|
||||
user_unix=get_unix(),
|
||||
)
|
||||
statement = statement.on_conflict_do_update(
|
||||
index_elements=[UserModel.user_id],
|
||||
set_={
|
||||
"user_login": statement.excluded.user_login,
|
||||
"user_name": statement.excluded.user_name,
|
||||
"user_surname": statement.excluded.user_surname,
|
||||
"user_fullname": statement.excluded.user_fullname,
|
||||
},
|
||||
where=or_(
|
||||
UserModel.user_login != statement.excluded.user_login,
|
||||
UserModel.user_name != statement.excluded.user_name,
|
||||
UserModel.user_surname != statement.excluded.user_surname,
|
||||
UserModel.user_fullname != statement.excluded.user_fullname,
|
||||
),
|
||||
)
|
||||
|
||||
async with session_scope() as session:
|
||||
await session.execute(statement)
|
||||
|
||||
user = await self.get(user_id=user_id)
|
||||
|
||||
if user is None:
|
||||
raise RuntimeError("Пользователь не сохранился")
|
||||
|
||||
return user
|
||||
|
||||
# Обновление пользователя по Telegram ID
|
||||
async def update(self, user_id: int, **kwargs) -> None:
|
||||
if not kwargs:
|
||||
return
|
||||
|
||||
async with session_scope() as session:
|
||||
await session.execute(
|
||||
sqlalchemy_update(UserModel)
|
||||
.where(UserModel.user_id == user_id)
|
||||
.values(**kwargs)
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
import asyncio
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine
|
||||
|
||||
from tgbot.database.core import database_url
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
ALEMBIC_INI = PROJECT_ROOT / "alembic.ini"
|
||||
MIGRATIONS_DIR = PROJECT_ROOT / "migrations"
|
||||
|
||||
|
||||
# Собираем конфиг Alembic так, чтобы команды работали из любой папки
|
||||
def get_alembic_config(url: str = database_url) -> Config:
|
||||
config = Config(str(ALEMBIC_INI))
|
||||
config.set_main_option("script_location", str(MIGRATIONS_DIR))
|
||||
config.set_main_option("sqlalchemy.url", url)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Применяем все миграции до последней версии
|
||||
async def run_migrations(engine: Optional[AsyncEngine] = None) -> None:
|
||||
if engine is None:
|
||||
config = get_alembic_config()
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, command.upgrade, config, "head")
|
||||
return
|
||||
|
||||
config = get_alembic_config(str(engine.url))
|
||||
|
||||
async with engine_context(engine) as connection:
|
||||
await connection.run_sync(_upgrade_with_connection, config)
|
||||
|
||||
|
||||
# Отдельная обертка нужна, чтобы IDE корректно видела async context manager
|
||||
def engine_context(engine: AsyncEngine) -> AbstractAsyncContextManager[AsyncConnection]:
|
||||
return engine.begin()
|
||||
|
||||
|
||||
# Alembic умеет работать с синхронным соединением внутри async-engine
|
||||
def _upgrade_with_connection(connection, config: Config) -> None:
|
||||
config.attributes["connection"] = connection
|
||||
command.upgrade(config, "head")
|
||||
|
||||
|
||||
# Ручной запуск миграций из консоли
|
||||
async def _main() -> None:
|
||||
await run_migrations()
|
||||
print("Миграции базы данных применены")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(_main())
|
||||
@@ -0,0 +1,106 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from typing import Generic, List, Optional, Type, TypeVar
|
||||
|
||||
from sqlalchemy import delete as sqlalchemy_delete
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import update as sqlalchemy_update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from tgbot.database.core import Base, database_path, session_scope
|
||||
from tgbot.utils.misc.bot_logging import bot_logger
|
||||
|
||||
ModelTranslator = TypeVar("ModelTranslator", bound=Base)
|
||||
|
||||
|
||||
# Базовый репозиторий с общими методами работы с БД
|
||||
class BaseRepository(Generic[ModelTranslator]):
|
||||
def __init__(self):
|
||||
self.storage_name = "storage"
|
||||
self.table_model: Optional[Type[ModelTranslator]] = None
|
||||
|
||||
# Без модели репозиторий работать не должен
|
||||
def _model(self) -> Type[ModelTranslator]:
|
||||
if self.table_model is None:
|
||||
raise RuntimeError("Модель базы данных не настроена")
|
||||
|
||||
return self.table_model
|
||||
|
||||
# Удаление только по явному фильтру, без случайной чистки всей таблицы
|
||||
async def delete(self, **kwargs) -> None:
|
||||
if not kwargs:
|
||||
raise ValueError("Для удаления нужен хотя бы один фильтр")
|
||||
|
||||
model = self._model()
|
||||
|
||||
async with session_scope() as session:
|
||||
await session.execute(sqlalchemy_delete(model).filter_by(**kwargs))
|
||||
|
||||
# Полное удаление всех строк таблицы. Название намеренно прямое.
|
||||
async def delete_all_rows(self) -> None:
|
||||
model = self._model()
|
||||
|
||||
async with session_scope() as session:
|
||||
await session.execute(sqlalchemy_delete(model))
|
||||
|
||||
# Возвращение первой записи по фильтру
|
||||
async def get(self, **kwargs) -> Optional[ModelTranslator]:
|
||||
model = self._model()
|
||||
statement = select(model).filter_by(**kwargs)
|
||||
|
||||
async with session_scope() as session:
|
||||
response = await session.execute(statement)
|
||||
|
||||
return response.scalars().first()
|
||||
|
||||
# Возвращение всех записей по фильтру
|
||||
async def gets(self, **kwargs) -> List[ModelTranslator]:
|
||||
model = self._model()
|
||||
statement = select(model).filter_by(**kwargs)
|
||||
|
||||
async with session_scope() as session:
|
||||
response = await session.execute(statement)
|
||||
|
||||
return list(response.scalars().all())
|
||||
|
||||
# Возвращение всей таблицы
|
||||
async def get_all(self) -> List[ModelTranslator]:
|
||||
model = self._model()
|
||||
|
||||
async with session_scope() as session:
|
||||
response = await session.execute(select(model))
|
||||
|
||||
return list(response.scalars().all())
|
||||
|
||||
# Обновляем только записи, которые попали под фильтр
|
||||
async def update(self, filters: dict, **kwargs) -> None:
|
||||
if not filters:
|
||||
raise ValueError("Для обновления нужен хотя бы один фильтр")
|
||||
|
||||
if not kwargs:
|
||||
return
|
||||
|
||||
model = self._model()
|
||||
|
||||
async with session_scope() as session:
|
||||
await session.execute(
|
||||
sqlalchemy_update(model)
|
||||
.filter_by(**filters)
|
||||
.values(**kwargs)
|
||||
)
|
||||
|
||||
|
||||
# Готовим подключение к БД. Сами миграции запускаются отдельно через migrate.py.
|
||||
async def prepare_database() -> None:
|
||||
database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Импортируем модели, чтобы репозитории работали с уже загруженными таблицами
|
||||
import tgbot.database # noqa: F401
|
||||
|
||||
from tgbot.database.db_settings import SettingsRepository
|
||||
|
||||
try:
|
||||
await SettingsRepository().ensure_default()
|
||||
except SQLAlchemyError as error:
|
||||
raise RuntimeError("База данных не готова. Запусти миграции командой: python migrate.py") from error
|
||||
|
||||
bot_logger.info("База данных готова")
|
||||
@@ -0,0 +1,35 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from tgbot.utils.const_functions import ikb
|
||||
|
||||
|
||||
# Инлайн-клавиатура для пользователя
|
||||
def user_finl() -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardBuilder()
|
||||
|
||||
keyboard.row(
|
||||
ikb("Действие", data="user_inline_x"),
|
||||
ikb("Раздел", data="user_inline:user_btn"),
|
||||
ikb("Скоро", data="..."),
|
||||
).row(
|
||||
ikb("Неизвестная кнопка", data="unknown"),
|
||||
)
|
||||
|
||||
return keyboard.as_markup()
|
||||
|
||||
|
||||
# Инлайн-клавиатура для админа
|
||||
def admin_finl() -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardBuilder()
|
||||
|
||||
keyboard.row(
|
||||
ikb("Действие", data="admin_inline_x"),
|
||||
ikb("Раздел", data="admin_inline:admin_btn"),
|
||||
ikb("Скоро", data="..."),
|
||||
).row(
|
||||
ikb("Неизвестная кнопка", data="unknown"),
|
||||
)
|
||||
|
||||
return keyboard.as_markup()
|
||||
@@ -0,0 +1,22 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from aiogram.types import ReplyKeyboardMarkup
|
||||
from aiogram.utils.keyboard import ReplyKeyboardBuilder
|
||||
|
||||
from tgbot.data.config import get_admins
|
||||
from tgbot.utils.const_functions import rkb
|
||||
|
||||
|
||||
# Кнопки главного меню
|
||||
def menu_frep(user_id: int) -> ReplyKeyboardMarkup:
|
||||
keyboard = ReplyKeyboardBuilder()
|
||||
|
||||
keyboard.row(
|
||||
rkb("Пользовательское меню"),
|
||||
)
|
||||
|
||||
if user_id in get_admins():
|
||||
keyboard.row(
|
||||
rkb("Админ-меню"),
|
||||
)
|
||||
|
||||
return keyboard.as_markup(resize_keyboard=True)
|
||||
@@ -0,0 +1,20 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from aiogram import Dispatcher
|
||||
|
||||
from tgbot.middlewares.middleware_throttling import ThrottlingMiddleware
|
||||
from tgbot.middlewares.middleware_user import ExistsUserMiddleware
|
||||
|
||||
|
||||
# Подключение всех мидлварей
|
||||
def register_all_middlewares(dp: Dispatcher):
|
||||
dp.callback_query.outer_middleware(ExistsUserMiddleware())
|
||||
dp.message.outer_middleware(ExistsUserMiddleware())
|
||||
|
||||
throttling = ThrottlingMiddleware()
|
||||
dp.message.middleware(throttling)
|
||||
dp.callback_query.middleware(throttling)
|
||||
|
||||
|
||||
# Старое имя оставлено, чтобы не ломать импорт в чужих проектах на базе шаблона
|
||||
def register_all_middlwares(dp: Dispatcher):
|
||||
register_all_middlewares(dp)
|
||||
@@ -0,0 +1,78 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional, Union
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.dispatcher.flags import get_flag
|
||||
from aiogram.types import CallbackQuery, Message, TelegramObject, User
|
||||
from cachetools import TTLCache
|
||||
|
||||
from tgbot.data.config import BOT_THROTTLE_RATE
|
||||
|
||||
|
||||
# Простая защита от спама
|
||||
class ThrottlingMiddleware(BaseMiddleware):
|
||||
def __init__(self, default_rate: Union[int, float] = BOT_THROTTLE_RATE) -> None:
|
||||
# Базовая задержка между сообщениями
|
||||
self.default_rate = default_rate
|
||||
|
||||
self.message_users = TTLCache(maxsize=10_000, ttl=600)
|
||||
self.callback_users = TTLCache(maxsize=10_000, ttl=600)
|
||||
|
||||
async def __call__(self, handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], event: TelegramObject, data):
|
||||
# Если юзер спамит, постепенно увеличиваем паузу
|
||||
this_user: Optional[User] = data.get("event_from_user")
|
||||
|
||||
if this_user is None:
|
||||
return await handler(event, data)
|
||||
|
||||
flag_rate = get_flag(data, "rate")
|
||||
rate = float(self.default_rate if flag_rate is None else flag_rate)
|
||||
|
||||
if rate == 0:
|
||||
return await handler(event, data)
|
||||
|
||||
now_time = time.monotonic()
|
||||
bucket = self._get_bucket(event)
|
||||
user_key = this_user.id
|
||||
|
||||
if user_key not in bucket:
|
||||
bucket[user_key] = {
|
||||
'last_throttled': now_time,
|
||||
'count_throttled': 0,
|
||||
'now_rate': rate,
|
||||
}
|
||||
|
||||
return await handler(event, data)
|
||||
else:
|
||||
if now_time - bucket[user_key]['last_throttled'] >= bucket[user_key]['now_rate']:
|
||||
bucket.pop(user_key)
|
||||
|
||||
return await handler(event, data)
|
||||
else:
|
||||
bucket[user_key]['last_throttled'] = now_time
|
||||
bucket[user_key]['count_throttled'] += 1
|
||||
|
||||
if bucket[user_key]['count_throttled'] == 1:
|
||||
bucket[user_key]['now_rate'] = rate + 2
|
||||
await self._warn_user(event)
|
||||
elif bucket[user_key]['count_throttled'] == 2:
|
||||
bucket[user_key]['now_rate'] = rate + 3
|
||||
else:
|
||||
bucket[user_key]['now_rate'] = rate + 5
|
||||
|
||||
return None
|
||||
|
||||
# Для сообщений и колбэков держим разные лимиты, чтобы они не мешали друг другу
|
||||
def _get_bucket(self, event: TelegramObject) -> TTLCache:
|
||||
if isinstance(event, CallbackQuery):
|
||||
return self.callback_users
|
||||
|
||||
return self.message_users
|
||||
|
||||
# Предупреждаем там, где это возможно для конкретного типа апдейта
|
||||
async def _warn_user(self, event: TelegramObject) -> None:
|
||||
if isinstance(event, Message):
|
||||
await event.reply("<b>❗ Пожалуйста, не спамьте</b>")
|
||||
elif isinstance(event, CallbackQuery) or hasattr(event, "answer"):
|
||||
await event.answer("❗ Пожалуйста, не спамьте", cache_time=1)
|
||||
@@ -0,0 +1,55 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from aiogram import BaseMiddleware
|
||||
from cachetools import TTLCache
|
||||
|
||||
from tgbot.data.config import BOT_USER_CACHE_TTL
|
||||
from tgbot.database.db_users import UsersRepository
|
||||
from tgbot.utils.const_functions import clear_html
|
||||
|
||||
|
||||
# Проверка юзера в БД и его добавление/обновление
|
||||
class ExistsUserMiddleware(BaseMiddleware):
|
||||
def __init__(self, cache_ttl: int = BOT_USER_CACHE_TTL) -> None:
|
||||
self.users = UsersRepository()
|
||||
self.cache = TTLCache(maxsize=10_000, ttl=cache_ttl)
|
||||
|
||||
async def __call__(self, handler, event, data):
|
||||
this_user = data.get("event_from_user")
|
||||
|
||||
if this_user is not None and not this_user.is_bot:
|
||||
user_id = this_user.id
|
||||
user_login = this_user.username or ""
|
||||
user_name = clear_html(this_user.first_name)
|
||||
user_surname = clear_html(this_user.last_name)
|
||||
user_fullname = clear_html(this_user.first_name)
|
||||
|
||||
if user_name is None: user_name = ""
|
||||
if user_surname is None: user_surname = ""
|
||||
if user_fullname is None: user_fullname = ""
|
||||
|
||||
if len(user_surname) >= 1: user_fullname += f" {user_surname}"
|
||||
|
||||
user_data = (
|
||||
user_login.lower(),
|
||||
user_name,
|
||||
user_surname,
|
||||
user_fullname,
|
||||
)
|
||||
|
||||
cached_user = self.cache.get(user_id)
|
||||
|
||||
if cached_user is None or cached_user["data"] != user_data:
|
||||
user = await self.users.upsert(
|
||||
user_id=user_id,
|
||||
user_login=user_data[0],
|
||||
user_name=user_data[1],
|
||||
user_surname=user_data[2],
|
||||
user_fullname=user_data[3],
|
||||
)
|
||||
self.cache[user_id] = {"data": user_data, "user": user}
|
||||
else:
|
||||
user = cached_user["user"]
|
||||
|
||||
data['User'] = user
|
||||
|
||||
return await handler(event, data)
|
||||
@@ -0,0 +1,33 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from aiogram import Dispatcher
|
||||
|
||||
from tgbot.routers import main_errors, main_missed, main_start
|
||||
from tgbot.routers.admin import admin_menu
|
||||
from tgbot.routers.user import user_menu
|
||||
from tgbot.utils.misc.bot_filters import IsAdmin, IsPrivate
|
||||
|
||||
|
||||
# Подключение всех роутеров
|
||||
def register_all_routers(dp: Dispatcher):
|
||||
# Общие фильтры для приватных чатов
|
||||
main_errors.router.message.filter(IsPrivate())
|
||||
main_start.router.message.filter(IsPrivate())
|
||||
|
||||
user_menu.router.message.filter(IsPrivate())
|
||||
user_menu.router.callback_query.filter(IsPrivate())
|
||||
admin_menu.router.message.filter(IsPrivate(), IsAdmin())
|
||||
admin_menu.router.callback_query.filter(IsPrivate(), IsAdmin())
|
||||
|
||||
main_missed.router.message.filter(IsPrivate())
|
||||
main_missed.router.callback_query.filter(IsPrivate())
|
||||
|
||||
# Базовые роутеры, которые нужны всегда
|
||||
dp.include_router(main_errors.router) # Ошибки
|
||||
dp.include_router(main_start.router) # Старт и главное меню
|
||||
|
||||
# Роутеры для пользователей и админов
|
||||
dp.include_router(user_menu.router) # Пользовательские обработчики
|
||||
dp.include_router(admin_menu.router) # Админские обработчики
|
||||
|
||||
# Обработка всего, что не поймали выше
|
||||
dp.include_router(main_missed.router) # Пропущенные сообщения и колбэки
|
||||
@@ -0,0 +1,111 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
from aiogram import Router, Bot, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import FSInputFile, Message, CallbackQuery
|
||||
from aiogram.utils.media_group import MediaGroupBuilder
|
||||
|
||||
from tgbot.data.config import BOT_DATABASE_EXPORT, PATH_DATABASE, PATH_LOGS
|
||||
from tgbot.database.db_users import UserModel
|
||||
from tgbot.keyboards.inline_main import admin_finl
|
||||
from tgbot.utils.const_functions import get_date
|
||||
from tgbot.utils.misc.bot_models import FSM, ARS
|
||||
|
||||
router = Router(name=__name__)
|
||||
LOGS_DIR = Path(PATH_LOGS).parent
|
||||
SERVICE_LOG_FILES = (
|
||||
LOGS_DIR / "sv_log_err.log",
|
||||
LOGS_DIR / "sv_log_out.log",
|
||||
)
|
||||
|
||||
|
||||
# Кнопка админского меню
|
||||
@router.message(F.text == 'Админ-меню')
|
||||
async def admin_button_inline(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||
await state.clear()
|
||||
|
||||
await message.answer(
|
||||
"Инлайн-клавиатура админа",
|
||||
reply_markup=admin_finl()
|
||||
)
|
||||
|
||||
|
||||
# Колбэк для демо-действия админа
|
||||
@router.callback_query(F.data == 'admin_inline_x')
|
||||
async def admin_callback_inline_x(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||
await call.answer("Админское действие выполнено")
|
||||
|
||||
|
||||
# Колбэк с параметром из админской кнопки
|
||||
@router.callback_query(F.data.startswith('admin_inline:'))
|
||||
async def admin_callback_inline(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||
get_data = call.data.split(":")[1]
|
||||
|
||||
await call.answer(f"Выбран админский раздел: {get_data}", True)
|
||||
|
||||
|
||||
# Отправка файла базы админам
|
||||
@router.message(Command(commands=['db', 'database']))
|
||||
async def admin_database(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||
await state.clear()
|
||||
|
||||
if not BOT_DATABASE_EXPORT:
|
||||
await message.answer("<b>📦 Экспорт базы данных отключён в настройках</b>")
|
||||
return
|
||||
|
||||
if not Path(PATH_DATABASE).is_file():
|
||||
await message.answer("<b>📦 Файл базы данных не найден</b>")
|
||||
return
|
||||
|
||||
await message.answer_document(
|
||||
FSInputFile(PATH_DATABASE),
|
||||
caption=f"<b>📦 #БЭКАП | <code>{get_date()}</code></b>",
|
||||
)
|
||||
|
||||
|
||||
# Отправка логов админам
|
||||
@router.message(Command(commands=['log', 'logs']))
|
||||
async def admin_log(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||
await state.clear()
|
||||
|
||||
log_files = []
|
||||
|
||||
if Path(PATH_LOGS).is_file():
|
||||
log_files.append(PATH_LOGS)
|
||||
|
||||
for log_file in SERVICE_LOG_FILES:
|
||||
if log_file.is_file():
|
||||
log_files.append(str(log_file))
|
||||
|
||||
caption = f"<b>🖨 #ЛОГИ | <code>{get_date(full=False)}</code></b>"
|
||||
|
||||
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())
|
||||
|
||||
|
||||
# Очистка файлов логов
|
||||
@router.message(Command(commands=['clear_log', 'clear_logs', 'log_clear', 'logs_clear']))
|
||||
async def admin_logs_clear(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||
await state.clear()
|
||||
|
||||
log_files = [Path(PATH_LOGS), *SERVICE_LOG_FILES]
|
||||
|
||||
for log_file in log_files:
|
||||
if not log_file.is_file():
|
||||
continue
|
||||
|
||||
async with aiofiles.open(log_file, "w", encoding="utf-8") as file:
|
||||
await file.write(f"{get_date()} | ЛОГИ ОЧИЩЕНЫ")
|
||||
|
||||
await message.answer("<b>🖨 Логи очищены</b>")
|
||||
@@ -0,0 +1,27 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from aiogram import Router
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.filters import ExceptionTypeFilter
|
||||
from aiogram.handlers import ErrorHandler
|
||||
|
||||
from tgbot.utils.misc.bot_logging import bot_logger
|
||||
|
||||
router = Router(name=__name__)
|
||||
|
||||
|
||||
# Ошибка при отправке сообщения пользователю, который заблокировал бота
|
||||
# @router.errors(ExceptionTypeFilter(TelegramForbiddenError))
|
||||
# class ForbiddenErrorHandler(ErrorHandler):
|
||||
# async def handle(self):
|
||||
# ...
|
||||
|
||||
|
||||
# Безопасно игнорируем повторное редактирование сообщения без изменений
|
||||
@router.errors(ExceptionTypeFilter(TelegramBadRequest))
|
||||
class MessageNotModifiedHandler(ErrorHandler):
|
||||
async def handle(self):
|
||||
if "message is not modified" in self.exception_message.lower():
|
||||
bot_logger.debug("Telegram отклонил повторное редактирование сообщения без изменений")
|
||||
return True
|
||||
|
||||
raise self.event
|
||||
@@ -0,0 +1,36 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from aiogram import Router, Bot, F
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from tgbot.database import ModelUsers
|
||||
from tgbot.utils.const_functions import del_message
|
||||
from tgbot.utils.misc.bot_models import FSM, ARS
|
||||
|
||||
router = Router(name=__name__)
|
||||
|
||||
|
||||
# Колбэк для удаления текущего сообщения
|
||||
@router.callback_query(F.data == 'close_this')
|
||||
async def main_callback_close(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: ModelUsers):
|
||||
await del_message(call.message)
|
||||
|
||||
|
||||
# Колбэк-заглушка для пустых кнопок
|
||||
@router.callback_query(F.data == '...')
|
||||
async def main_callback_answer(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: ModelUsers):
|
||||
await call.answer(cache_time=30)
|
||||
|
||||
|
||||
# Ответ на колбэк, который никто не обработал
|
||||
@router.callback_query()
|
||||
async def main_callback_missed(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: ModelUsers):
|
||||
await call.answer(f"❗️ Неизвестный колбэк: {call.data}", True)
|
||||
|
||||
|
||||
# Ответ на неизвестные сообщения
|
||||
@router.message()
|
||||
async def main_message_missed(message: Message, bot: Bot, state: FSM, arSession: ARS, User: ModelUsers):
|
||||
await message.answer(
|
||||
"♦️ Неизвестная команда\n"
|
||||
"♦️ Введите /start",
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from aiogram import Router, Bot, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
|
||||
from tgbot.database.db_users import UserModel
|
||||
from tgbot.keyboards.reply_main import menu_frep
|
||||
from tgbot.utils.const_functions import ded
|
||||
from tgbot.utils.misc.bot_models import FSM, ARS
|
||||
|
||||
router = Router(name=__name__)
|
||||
|
||||
|
||||
# Главное меню пользователя
|
||||
@router.message(F.text.in_(('menu', 'return', 'start')))
|
||||
@router.message(Command(commands=['start']))
|
||||
async def main_start(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||
await state.clear()
|
||||
|
||||
await message.answer(
|
||||
ded(f"""
|
||||
🔸 Привет, {User.user_name}
|
||||
🔸 Введи /start или /menu
|
||||
"""),
|
||||
reply_markup=menu_frep(message.from_user.id),
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from aiogram import Router, Bot, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
|
||||
from tgbot.database.db_users import UserModel
|
||||
from tgbot.keyboards.inline_main import user_finl
|
||||
from tgbot.keyboards.reply_main import menu_frep
|
||||
from tgbot.utils.misc.bot_models import FSM, ARS
|
||||
|
||||
router = Router(name=__name__)
|
||||
|
||||
|
||||
# Кнопка пользовательского меню
|
||||
@router.message(F.text == 'Пользовательское меню')
|
||||
async def user_button_inline(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||
await state.clear()
|
||||
|
||||
await message.answer(
|
||||
"Инлайн-клавиатура пользователя",
|
||||
reply_markup=user_finl()
|
||||
)
|
||||
|
||||
|
||||
# Команда возврата в меню
|
||||
@router.message(Command(commands="menu"))
|
||||
async def user_command_menu(message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||
await state.clear()
|
||||
|
||||
await message.answer(
|
||||
"Команда /menu открывает главное меню",
|
||||
reply_markup=menu_frep(message.from_user.id),
|
||||
)
|
||||
|
||||
|
||||
# Колбэк для демо-действия
|
||||
@router.callback_query(F.data == 'user_inline_x')
|
||||
async def user_callback_inline_x(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||
await call.answer("Действие выполнено")
|
||||
|
||||
|
||||
# Колбэк с параметром из пользовательской кнопки
|
||||
@router.callback_query(F.data.startswith('user_inline:'))
|
||||
async def user_callback_inline(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS, User: UserModel):
|
||||
get_data = call.data.split(":")[1]
|
||||
|
||||
await call.answer(f"Выбран раздел: {get_data}", True)
|
||||
@@ -0,0 +1,32 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
|
||||
# Пример использования в обработчике
|
||||
# session = await arSession.get_session()
|
||||
# response = await session.get(...)
|
||||
# response = await session.post(...)
|
||||
|
||||
# Асинхронная сессия для запросов
|
||||
class AsyncRequestSession:
|
||||
def __init__(self, timeout: int = 30) -> None:
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
self._timeout = aiohttp.ClientTimeout(total=timeout)
|
||||
|
||||
# Получение сессии
|
||||
async def get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None:
|
||||
new_session = aiohttp.ClientSession(timeout=self._timeout)
|
||||
self._session = new_session
|
||||
|
||||
return self._session
|
||||
|
||||
# Закрытие сессии
|
||||
async def close(self) -> None:
|
||||
if self._session is None:
|
||||
return
|
||||
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
@@ -0,0 +1,320 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
import html
|
||||
import secrets
|
||||
import string
|
||||
import textwrap
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import (InlineKeyboardButton, KeyboardButton, WebAppInfo, Message, InlineKeyboardMarkup,
|
||||
ReplyKeyboardMarkup)
|
||||
from pytz import timezone
|
||||
|
||||
from tgbot.data.config import get_admins, BOT_TIMEZONE
|
||||
from tgbot.utils.misc.bot_logging import bot_logger
|
||||
|
||||
|
||||
#################################### AIOGRAM ###################################
|
||||
# Быстрая сборка реплай-кнопки
|
||||
def rkb(text: str) -> KeyboardButton:
|
||||
return KeyboardButton(text=text)
|
||||
|
||||
|
||||
# Быстрая сборка инлайн-кнопки
|
||||
def ikb(
|
||||
text: str,
|
||||
data: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
switch: Optional[str] = None,
|
||||
web: Optional[str] = None,
|
||||
) -> InlineKeyboardButton:
|
||||
if data is not None:
|
||||
return InlineKeyboardButton(text=text, callback_data=data)
|
||||
elif url is not None:
|
||||
return InlineKeyboardButton(text=text, url=url)
|
||||
elif switch is not None:
|
||||
return InlineKeyboardButton(text=text, switch_inline_query=switch)
|
||||
elif web is not None:
|
||||
return InlineKeyboardButton(text=text, web_app=WebAppInfo(url=web))
|
||||
else:
|
||||
raise ValueError("Не указано действие для инлайн-кнопки")
|
||||
|
||||
|
||||
# Удаление сообщения без падения на ошибках Telegram
|
||||
async def del_message(message: Message):
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception:
|
||||
bot_logger.debug("Не удалось удалить сообщение", exc_info=True)
|
||||
|
||||
|
||||
# Отправка текста с фото, если оно передано или обычным сообщением
|
||||
async def smart_message(
|
||||
bot: Bot,
|
||||
user_id: int,
|
||||
text: str,
|
||||
keyboard: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = None,
|
||||
photo: Optional[str] = None,
|
||||
):
|
||||
if photo is not None and photo.title() != "None":
|
||||
await bot.send_photo(
|
||||
chat_id=user_id,
|
||||
photo=photo,
|
||||
caption=text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
# Отправка сообщения всем админам
|
||||
async def send_admins(bot: Bot, text: str, markup=None, not_me=0):
|
||||
for admin in get_admins():
|
||||
try:
|
||||
if str(admin) != str(not_me):
|
||||
await bot.send_message(
|
||||
admin,
|
||||
text,
|
||||
reply_markup=markup,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception:
|
||||
bot_logger.warning("Не удалось отправить сообщение админу %s", admin, exc_info=True)
|
||||
|
||||
|
||||
################################## РАЗНОЕ ######################################
|
||||
# Убирает лишние отступы в многострочном тексте
|
||||
def ded(get_text: str) -> str:
|
||||
return textwrap.dedent(get_text or "").strip()
|
||||
|
||||
|
||||
# Чистит HTML-символы, чтобы Telegram не сломал разметку
|
||||
def clear_html(get_text: str) -> str:
|
||||
return html.escape(get_text or "", quote=False)
|
||||
|
||||
|
||||
# Убирает пустые и мусорные элементы из списка
|
||||
def clear_list(get_list: list) -> list:
|
||||
trash = {"", " ", ".", ",", "\r", "\n"}
|
||||
|
||||
return [value for value in get_list if value not in trash]
|
||||
|
||||
|
||||
# Делит список на части нужного размера
|
||||
def split_list(get_list: list, count: int) -> List[list]:
|
||||
return [get_list[i:i + count] for i in range(0, len(get_list), count)]
|
||||
|
||||
|
||||
# Возвращает текущую дату, при full=True еще и время
|
||||
def get_date(full: bool = True) -> str:
|
||||
bot_timezone = timezone(BOT_TIMEZONE)
|
||||
|
||||
if full:
|
||||
return datetime.now(bot_timezone).strftime("%d.%m.%Y %H:%M:%S")
|
||||
else:
|
||||
return datetime.now(bot_timezone).strftime("%d.%m.%Y")
|
||||
|
||||
|
||||
# Возвращает Unix-время: секунды или наносекунды
|
||||
def get_unix(full: bool = False) -> int:
|
||||
if full:
|
||||
return time.time_ns()
|
||||
else:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
# Конвертирует дату в Unix и обратно
|
||||
def convert_date(from_time, full=True, second=True) -> Union[str, int]:
|
||||
bot_timezone = timezone(BOT_TIMEZONE)
|
||||
from_time = str(from_time).strip().replace("-", ".")
|
||||
|
||||
if from_time.isdigit():
|
||||
from_timestamp = int(from_time)
|
||||
if full:
|
||||
to_time = datetime.fromtimestamp(from_timestamp, bot_timezone).strftime("%d.%m.%Y %H:%M:%S")
|
||||
elif second:
|
||||
to_time = datetime.fromtimestamp(from_timestamp, bot_timezone).strftime("%d.%m.%Y %H:%M")
|
||||
else:
|
||||
to_time = datetime.fromtimestamp(from_timestamp, bot_timezone).strftime("%d.%m.%Y")
|
||||
else:
|
||||
parts = from_time.split()
|
||||
|
||||
if len(parts) == 2 and ":" in parts[0]:
|
||||
time_part, date_part = parts
|
||||
elif len(parts) == 2:
|
||||
date_part, time_part = parts
|
||||
else:
|
||||
date_part, time_part = from_time, "00:00:00"
|
||||
|
||||
date_values = date_part.split(".")
|
||||
time_values = time_part.split(":")
|
||||
|
||||
if len(time_values) == 2:
|
||||
time_values.append("0")
|
||||
|
||||
if len(date_values[0]) == 4:
|
||||
x_year, x_month, x_day = date_values[0], date_values[1], date_values[2]
|
||||
else:
|
||||
x_day, x_month, x_year = date_values[0], date_values[1], date_values[2]
|
||||
|
||||
date_time = datetime(
|
||||
int(x_year),
|
||||
int(x_month),
|
||||
int(x_day),
|
||||
int(time_values[0]),
|
||||
int(time_values[1]),
|
||||
int(time_values[2]),
|
||||
)
|
||||
date_time = bot_timezone.localize(date_time)
|
||||
to_time = int(date_time.timestamp())
|
||||
|
||||
return to_time
|
||||
|
||||
|
||||
# Генерация числового уникального ID
|
||||
def gen_id(len_id: int = 16) -> int:
|
||||
if len_id <= 0:
|
||||
raise ValueError("Длина ID должна быть больше нуля")
|
||||
|
||||
first_digit = secrets.choice("123456789")
|
||||
other_digits = "".join(secrets.choice(string.digits) for _ in range(len_id - 1))
|
||||
|
||||
return int(f"{first_digit}{other_digits}")
|
||||
|
||||
|
||||
# Генерация пароля под разные сценарии
|
||||
def gen_password(len_password: int = 16, type_password: str = "default") -> str:
|
||||
if len_password <= 0:
|
||||
raise ValueError("Длина пароля должна быть больше нуля")
|
||||
|
||||
if type_password == "default":
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
elif type_password == "letter":
|
||||
alphabet = string.ascii_letters
|
||||
elif type_password == "number":
|
||||
alphabet = string.digits
|
||||
elif type_password == "onechar":
|
||||
alphabet = string.digits
|
||||
else:
|
||||
raise ValueError("Неизвестный тип пароля")
|
||||
|
||||
random_chars = "".join(secrets.choice(alphabet) for _ in range(len_password))
|
||||
|
||||
if type_password == "onechar":
|
||||
random_chars = f"{secrets.choice(string.ascii_letters)}{random_chars[1:]}"
|
||||
|
||||
return random_chars
|
||||
|
||||
|
||||
# Склоняет единицы времени под число
|
||||
def convert_times(get_time: int, get_type: str = "day") -> str:
|
||||
get_time = int(get_time)
|
||||
if get_time < 0: get_time = 0
|
||||
|
||||
if get_type == "second":
|
||||
get_list = ['секунда', 'секунды', 'секунд']
|
||||
elif get_type == "minute":
|
||||
get_list = ['минута', 'минуты', 'минут']
|
||||
elif get_type == "hour":
|
||||
get_list = ['час', 'часа', 'часов']
|
||||
elif get_type == "day":
|
||||
get_list = ['день', 'дня', 'дней']
|
||||
elif get_type == "month":
|
||||
get_list = ['месяц', 'месяца', 'месяцев']
|
||||
else:
|
||||
get_list = ['год', 'года', 'лет']
|
||||
|
||||
if get_time % 10 == 1 and get_time % 100 != 11:
|
||||
count = 0
|
||||
elif 2 <= get_time % 10 <= 4 and (get_time % 100 < 10 or get_time % 100 >= 20):
|
||||
count = 1
|
||||
else:
|
||||
count = 2
|
||||
|
||||
return f"{get_time} {get_list[count]}"
|
||||
|
||||
|
||||
# Приводит строку или число к bool
|
||||
def is_bool(value: Union[bool, str, int]) -> bool:
|
||||
value = str(value).strip().lower()
|
||||
|
||||
if value in ('y', 'yes', 't', 'true', 'on', '1'):
|
||||
return True
|
||||
elif value in ('n', 'no', 'f', 'false', 'off', '0'):
|
||||
return False
|
||||
else:
|
||||
raise ValueError(f"Некорректное bool-значение: {value}")
|
||||
|
||||
|
||||
################################### ЧИСЛА ######################################
|
||||
# Приводит число к читаемой строке без лишних нулей
|
||||
def snum(amount: Union[int, float], remains: int = 2) -> str:
|
||||
format_str = "{:." + str(remains) + "f}"
|
||||
str_amount = format_str.format(float(amount))
|
||||
|
||||
if remains != 0:
|
||||
if "." in str_amount:
|
||||
remains_find = str_amount.find(".")
|
||||
remains_save = remains_find + 8 - (8 - remains) + 1
|
||||
|
||||
str_amount = str_amount[:remains_save]
|
||||
|
||||
if "." in str(str_amount):
|
||||
while str(str_amount).endswith('0'): str_amount = str(str_amount)[:-1]
|
||||
|
||||
if str(str_amount).endswith('.'): str_amount = str(str_amount)[:-1]
|
||||
|
||||
return str(str_amount)
|
||||
|
||||
|
||||
# Приводит входное значение к int или float
|
||||
def to_float(get_number, remains: int = 2) -> Union[int, float]:
|
||||
value = str(get_number).strip().replace(" ", "").replace(",", ".")
|
||||
number = round(float(value), remains)
|
||||
|
||||
if number.is_integer():
|
||||
return int(number)
|
||||
|
||||
return number
|
||||
|
||||
|
||||
# Округляет число до int
|
||||
def to_int(get_number: float) -> int:
|
||||
if "," in str(get_number):
|
||||
get_number = str(get_number).replace(",", ".")
|
||||
|
||||
get_number = int(round(float(get_number)))
|
||||
|
||||
return get_number
|
||||
|
||||
|
||||
# Проверяет, является ли значение числом
|
||||
def is_number(get_number: Union[str, int, float]) -> bool:
|
||||
if str(get_number).isdigit():
|
||||
return True
|
||||
else:
|
||||
if "," in str(get_number): get_number = str(get_number).replace(",", ".")
|
||||
|
||||
try:
|
||||
float(get_number)
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
# Форматирует число с разделением тысяч
|
||||
def format_rate(amount: Union[float, int], around: int = 2) -> str:
|
||||
value = str(amount).strip().replace(" ", "").replace(",", ".")
|
||||
number = round(float(value), around)
|
||||
response = f"{number:,.{around}f}".replace(",", " ")
|
||||
|
||||
if "." in response:
|
||||
response = response.rstrip("0").rstrip(".")
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,33 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from aiogram import Bot
|
||||
from aiogram.types import BotCommand, BotCommandScopeChat, BotCommandScopeDefault
|
||||
|
||||
from tgbot.data.config import BOT_DATABASE_EXPORT, get_admins
|
||||
from tgbot.utils.misc.bot_logging import bot_logger
|
||||
|
||||
# Команды для обычных пользователей
|
||||
user_commands = [
|
||||
BotCommand(command="start", description="♻️ Перезапуск бота"),
|
||||
BotCommand(command="menu", description="🌀 Получение клавиатуры"),
|
||||
]
|
||||
|
||||
# Команды для админов
|
||||
admin_commands = [
|
||||
BotCommand(command="start", description="♻️ Перезапуск бота"),
|
||||
BotCommand(command="menu", description="🌀 Получение клавиатуры"),
|
||||
BotCommand(command="log", description="🖨 Получить логи"),
|
||||
]
|
||||
|
||||
if BOT_DATABASE_EXPORT:
|
||||
admin_commands.append(BotCommand(command="db", description="📦 Получить БД"))
|
||||
|
||||
|
||||
# Обновление списка команд в Telegram
|
||||
async def set_commands(bot: Bot):
|
||||
await bot.set_my_commands(user_commands, scope=BotCommandScopeDefault())
|
||||
|
||||
for admin in get_admins():
|
||||
try:
|
||||
await bot.set_my_commands(admin_commands, scope=BotCommandScopeChat(chat_id=admin))
|
||||
except Exception:
|
||||
bot_logger.warning("Не удалось обновить команды для админа %s", admin, exc_info=True)
|
||||
@@ -0,0 +1,30 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from typing import Union
|
||||
|
||||
from aiogram.filters import BaseFilter
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from tgbot.data.config import get_admins
|
||||
|
||||
|
||||
# Проверка, что действия совершает админ
|
||||
class IsAdmin(BaseFilter):
|
||||
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
|
||||
user = getattr(event, "from_user", None)
|
||||
|
||||
return bool(user and user.id in get_admins())
|
||||
|
||||
|
||||
# Проверка приватного чата
|
||||
class IsPrivate(BaseFilter):
|
||||
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
|
||||
chat = getattr(event, "chat", None)
|
||||
message = getattr(event, "message", None)
|
||||
|
||||
if chat is None and message is not None:
|
||||
chat = message.chat
|
||||
|
||||
if chat is None:
|
||||
return True
|
||||
|
||||
return chat.type == "private"
|
||||
@@ -0,0 +1,58 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
import colorlog
|
||||
|
||||
from tgbot.data.config import PATH_LOGS
|
||||
|
||||
LOG_FILE_MAX_BYTES = 5 * 1024 * 1024
|
||||
LOG_FILE_BACKUP_COUNT = 5
|
||||
|
||||
# Папка под логи создается сама, чтобы бот не падал на старте
|
||||
log_path = Path(PATH_LOGS)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Один общий логгер для всего шаблона
|
||||
bot_logger = logging.getLogger("tgbot")
|
||||
bot_logger.setLevel(logging.INFO)
|
||||
bot_logger.propagate = False
|
||||
|
||||
if not bot_logger.handlers:
|
||||
# Формат для файла: без цветов
|
||||
file_formatter = logging.Formatter(
|
||||
"%(levelname)s | %(asctime)s | %(name)s | %(filename)s:%(lineno)d | %(message)s",
|
||||
datefmt="%d-%m-%Y %H:%M:%S",
|
||||
)
|
||||
# Формат для консоли: коротко и с цветами
|
||||
console_formatter = colorlog.ColoredFormatter(
|
||||
"%(log_color)s%(levelname)s%(reset)s | %(blue)s%(asctime)s%(reset)s | "
|
||||
"%(purple)s%(filename)s:%(lineno)d%(reset)s | %(message)s",
|
||||
datefmt="%d-%m-%Y %H:%M:%S",
|
||||
log_colors={
|
||||
"DEBUG": "cyan",
|
||||
"INFO": "green",
|
||||
"WARNING": "yellow",
|
||||
"ERROR": "red",
|
||||
"CRITICAL": "bold_red",
|
||||
},
|
||||
)
|
||||
|
||||
# Не очищаем файл, а крутим по размеру
|
||||
file_handler = RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=LOG_FILE_MAX_BYTES,
|
||||
backupCount=LOG_FILE_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setFormatter(file_formatter)
|
||||
file_handler.setLevel(logging.INFO)
|
||||
|
||||
# В консоль выводим то, что важно видеть сразу
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(console_formatter)
|
||||
console_handler.setLevel(logging.INFO)
|
||||
|
||||
bot_logger.addHandler(file_handler)
|
||||
bot_logger.addHandler(console_handler)
|
||||
@@ -0,0 +1,8 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
from tgbot.services.api_session import AsyncRequestSession
|
||||
|
||||
# Короткие алиасы для типизации обработчиков
|
||||
FSM = FSMContext
|
||||
ARS = AsyncRequestSession
|
||||
@@ -0,0 +1,29 @@
|
||||
# - *- coding: utf- 8 - *-
|
||||
from aiogram import Bot
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
from tgbot.data.config import BOT_DATABASE_EXPORT, BOT_STATUS_NOTIFICATION, PATH_DATABASE, get_admins
|
||||
from tgbot.utils.const_functions import get_date, send_admins
|
||||
from tgbot.utils.misc.bot_logging import bot_logger
|
||||
|
||||
|
||||
# Уведомление админам после запуска
|
||||
async def startup_notify(bot: Bot):
|
||||
if len(get_admins()) >= 1 and BOT_STATUS_NOTIFICATION:
|
||||
await send_admins(bot, "<b>✅ Бот запущен</b>")
|
||||
|
||||
|
||||
# Автобэкап базы для админов
|
||||
async def autobackup_admin(bot: Bot):
|
||||
if not BOT_DATABASE_EXPORT:
|
||||
return
|
||||
|
||||
for admin in get_admins():
|
||||
try:
|
||||
await bot.send_document(
|
||||
admin,
|
||||
FSInputFile(PATH_DATABASE),
|
||||
caption=f"<b>📦 #АВТОБЭКАП | <code>{get_date()}</code></b>",
|
||||
)
|
||||
except Exception:
|
||||
bot_logger.warning("Не удалось отправить автобэкап админу %s", admin, exc_info=True)
|
||||
Reference in New Issue
Block a user