Initial commit

This commit is contained in:
Lolzteam
2026-07-30 10:22:59 +00:00
commit 8953b2f4bc
48 changed files with 2564 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
View File
+128
View File
@@ -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")
+5
View File
@@ -0,0 +1,5 @@
from .db_settings import SettingsModel, SettingsRepository
from .db_users import UserModel, UsersRepository
ModelSettings = SettingsModel
ModelUsers = UserModel
+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()
+61
View File
@@ -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)
)
+106
View File
@@ -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)
)
+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("База данных готова")
View File
+35
View File
@@ -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()
+22
View File
@@ -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)
+20
View File
@@ -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)
+55
View File
@@ -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)
+33
View File
@@ -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) # Пропущенные сообщения и колбэки
View File
+111
View File
@@ -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>")
+27
View File
@@ -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
+36
View File
@@ -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",
)
+26
View File
@@ -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),
)
View File
+47
View File
@@ -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)
View File
+32
View File
@@ -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
View File
+320
View File
@@ -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
View File
+33
View File
@@ -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)
+30
View File
@@ -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"
+58
View File
@@ -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)
+8
View File
@@ -0,0 +1,8 @@
# - *- coding: utf- 8 - *-
from aiogram.fsm.context import FSMContext
from tgbot.services.api_session import AsyncRequestSession
# Короткие алиасы для типизации обработчиков
FSM = FSMContext
ARS = AsyncRequestSession
+29
View File
@@ -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)