Первый коммит

This commit is contained in:
2026-02-17 11:24:55 +07:00
commit a06448ca4b
109 changed files with 21165 additions and 0 deletions

4
bot/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
from .core import *
from .handlers import router
from .middlewares import *
from .filters import *

5
bot/core/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""
Модуль управления ботом
"""
from .bots import *
from .webhook import *

398
bot/core/bots.py Normal file
View File

@@ -0,0 +1,398 @@
"""
Ядро PrimoGuard Bot: Инициализация, Управление и Информация
"""
from datetime import datetime
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import User, ChatAdministratorRights, BotDescription, BotShortDescription
from aiogram.utils.i18n import I18n, SimpleI18nMiddleware
from pymorphy3 import MorphAnalyzer
from configs import settings
from middleware.loggers import logger
__all__ = ('bot', 'dp', 'storage', 'i18n', 'morph', 'BotInfo')
# ================= STORAGE И DISPATCHER =================
storage = MemoryStorage()
dp = Dispatcher(storage=storage)
dp["is_active"] = True
# ================= ИНТЕРНАЦИОНАЛИЗАЦИЯ =================
i18n = I18n(path="locales", default_locale="ru", domain="bot")
i18n_middleware = SimpleI18nMiddleware(i18n=i18n)
i18n_middleware.setup(dp)
# ================= БОТ =================
bot = Bot(
token=settings.active_bot_token,
default=DefaultBotProperties(
parse_mode=settings.PARSE_MODE,
disable_notification=settings.DISABLE_NOTIFICATION,
protect_content=settings.PROTECT_CONTENT,
allow_sending_without_reply=settings.ALLOW_SENDING_WITHOUT_REPLY,
link_preview_is_disabled=settings.LINK_PREVIEW_IS_DISABLED,
link_preview_prefer_small_media=settings.LINK_PREVIEW_PREFER_SMALL_MEDIA,
link_preview_prefer_large_media=settings.LINK_PREVIEW_PREFER_LARGE_MEDIA,
link_preview_show_above_text=settings.LINK_PREVIEW_SHOW_ABOVE_TEXT,
show_caption_above_media=settings.SHOW_CAPTION_ABOVE_MEDIA
)
)
# ================= МОРФОАНАЛИЗАТОР =================
morph = MorphAnalyzer()
# ================= КЛАСС УПРАВЛЕНИЯ БОТОМ =================
class BotInfo:
"""Класс для хранения данных и управления ботом"""
# Основные данные бота
id: int = None
url: str = None
first_name: str = None
last_name: str = None
username: str = None
description: str = None
short_description: str = None
is_premium: bool = False
# Возможности бота
can_join_groups: bool = False
can_read_all_group_messages: bool = False
supports_inline_queries: bool = False
can_connect_to_business: bool = False
has_main_web_app: bool = False
added_to_attachment_menu: bool = False
# Данные из конфига
prefix: str = settings.PREFIX
started_at: datetime = None
@classmethod
def mention(cls) -> str:
"""Упоминание бота"""
return f'@{cls.username}' if cls.username else f'id{cls.id}'
@classmethod
async def webhook(cls, bots: Bot = bot) -> None:
"""
Настраивает webhook для бота.
Args:
bots: Объект бота для управления
"""
# Только если включен режим webhook
if not settings.WEBHOOK:
logger.debug("Режим Webhook отключен (WEBHOOK=False)", log_type='WEBHOOK')
return
# Проверяем наличие URL
if not settings.WEBHOOK_URL:
logger.warning(
"⚠️ WEBHOOK_URL не указан в настройках",
log_type='WEBHOOK'
)
return
try:
logger.info("Настройка вебхука бота", log_type='BOT')
# Проверяем текущий webhook
current_info = await bots.get_webhook_info()
# Если уже установлен нужный URL, пропускаем
if current_info.url == settings.WEBHOOK_URL:
logger.info(
f"✓ Вебхук уже установлен: {settings.WEBHOOK_URL}",
log_type='BOT'
)
return
# Устанавливаем webhook
await bots.set_webhook(
url=settings.WEBHOOK_URL,
secret_token=settings.SECRET_TOKEN,
drop_pending_updates=True
)
logger.success(
f"✓ Вебхук установлен: {settings.WEBHOOK_URL}",
log_type='BOT'
)
except Exception as e:
logger.error(
f"❌ Ошибка установки вебхука: {e}",
log_type='BOT'
)
@classmethod
async def info(cls, bots: Bot = bot) -> dict:
"""
Получает и сохраняет информацию о боте.
:param bots: Объект бота для управления
:return: Словарь с данными о боте
"""
logger.info("Получение информации о боте", log_type='BOT')
bot_info: User = await bots.get_me()
cls.id = bot_info.id
cls.url = f'tg://user?id={cls.id}'
cls.first_name = bot_info.first_name
cls.last_name = bot_info.last_name
cls.username = bot_info.username
cls.can_join_groups = getattr(bot_info, 'can_join_groups', False)
cls.can_read_all_group_messages = getattr(bot_info, 'can_read_all_group_messages', False)
cls.supports_inline_queries = bot_info.supports_inline_queries or False
cls.can_connect_to_business = bot_info.can_connect_to_business or False
cls.has_main_web_app = bot_info.has_main_web_app or False
cls.added_to_attachment_menu = bot_info.added_to_attachment_menu or False
cls.started_at = datetime.now()
logger.success(f"Информация о боте @{cls.username} получена", log_type='BOT')
return {
'id': cls.id,
'url': cls.url,
'first_name': cls.first_name,
'last_name': cls.last_name,
'username': cls.username,
'prefix': cls.prefix,
'is_premium': cls.is_premium,
'can_join_groups': cls.can_join_groups,
'can_read_all_group_messages': cls.can_read_all_group_messages,
'supports_inline_queries': cls.supports_inline_queries,
'can_connect_to_business': cls.can_connect_to_business,
'has_main_web_app': cls.has_main_web_app,
'added_to_attachment_menu': cls.added_to_attachment_menu,
}
@staticmethod
async def set_name(bots: Bot = bot, new_name: str = None) -> bool:
"""Устанавливает имя бота"""
new_name = new_name or settings.BOT_NAME
if not (1 <= len(new_name) <= 64):
logger.error(f"Имя бота должно быть от 1 до 64 символов (текущее: {len(new_name)})", log_type='BOT_SETUP')
return False
try:
current_name = (await bots.get_me()).first_name
if current_name == new_name:
logger.debug(f"Имя бота уже установлено: '{current_name}'", log_type='BOT_SETUP')
return False
await bots.set_my_name(new_name)
logger.success(f"Имя бота изменено: '{current_name}''{new_name}'", log_type='BOT_SETUP')
return True
except Exception as e:
logger.error(f"Ошибка установки имени бота: {e}", log_type='BOT_SETUP')
return False
@staticmethod
async def set_description(bots: Bot = bot, new_description: str = None) -> bool:
"""Устанавливает полное описание бота"""
new_description = new_description or settings.BOT_DESCRIPTION
if not (0 < len(new_description) <= 512):
logger.error(f"Описание должно быть от 1 до 512 символов (текущее: {len(new_description)})", log_type='BOT_SETUP')
return False
try:
current_description: BotDescription = await bots.get_my_description()
current_text = current_description.description if current_description else ""
if current_text == new_description:
logger.debug("Описание бота уже установлено", log_type='BOT_SETUP')
return False
await bots.set_my_description(description=new_description)
logger.success("Описание бота обновлено", log_type='BOT_SETUP')
return True
except Exception as e:
logger.error(f"Ошибка установки описания бота: {e}", log_type='BOT_SETUP')
return False
@staticmethod
async def set_short_description(bots: Bot = bot, new_short: str = None) -> bool:
"""Устанавливает короткое описание бота"""
new_short = new_short or settings.BOT_SHORT_DESCRIPTION
if not (0 < len(new_short) <= 120):
logger.error(f"Короткое описание должно быть от 1 до 120 символов (текущее: {len(new_short)})", log_type='BOT_SETUP')
return False
try:
current_short: BotShortDescription = await bots.get_my_short_description()
current_text = current_short.short_description if current_short else ""
if current_text == new_short:
logger.debug("Короткое описание бота уже установлено", log_type='BOT_SETUP')
return False
await bots.set_my_short_description(short_description=new_short)
logger.success("Короткое описание бота обновлено", log_type='BOT_SETUP')
return True
except Exception as e:
logger.error(f"Ошибка установки короткого описания: {e}", log_type='BOT_SETUP')
return False
@staticmethod
async def set_administrator_rights(bots: Bot = bot, rights: ChatAdministratorRights = None) -> bool:
"""Устанавливает права администратора по умолчанию"""
rights = rights or settings.rights
try:
current_rights = await bots.get_my_default_administrator_rights()
if current_rights == rights:
logger.debug("Права администратора уже установлены", log_type='BOT_SETUP')
return False
await bots.set_my_default_administrator_rights(rights)
logger.success("Права администратора обновлены", log_type='BOT_SETUP')
return True
except Exception as e:
logger.error(f"Ошибка установки прав администратора: {e}", log_type='BOT_SETUP')
return False
@classmethod
def print(cls, to_console: bool = True, to_file: bool = True) -> str:
"""
Красиво форматирует и выводит информацию о боте.
:param to_console: Вывести в консоль
:param to_file: Записать в файлы
:return: Отформатированная строка
"""
# Формирование блоков информации
header = f"╔═══════════════════════════════════════════════════════════╗"
title = f"║ 🤖 PRIMOGUARD BOT - ИНФОРМАЦИЯ О ЗАПУСКЕ ║"
separator = f"╠═══════════════════════════════════════════════════════════╣"
footer = f"╚═══════════════════════════════════════════════════════════╝"
lines = [
header,
title,
separator,
f"║ ⏰ Время запуска: {cls.started_at.strftime('%d.%m.%Y %H:%M:%S')}",
f"",
f"║ 📋 ОСНОВНАЯ ИНФОРМАЦИЯ:",
f"║ • Имя: {cls.first_name} {cls.last_name or ''}".ljust(60) + "",
f"║ • Username: @{cls.username}".ljust(60) + "",
f"║ • ID: {cls.id}".ljust(60) + "",
f"",
f"║ ⚙️ ВОЗМОЖНОСТИ БОТА:",
f"║ • Вступать в группы: {'' if cls.can_join_groups else ''}".ljust(60) + "",
f"║ • Читать все сообщения: {'' if cls.can_read_all_group_messages else ''}".ljust(60) + "",
f"║ • Инлайн-запросы: {'' if cls.supports_inline_queries else ''}".ljust(60) + "",
f"║ • Бизнес-аккаунты: {'' if cls.can_connect_to_business else ''}".ljust(60) + "",
f"║ • Веб-приложение: {'' if cls.has_main_web_app else ''}".ljust(60) + "",
f"║ • Меню вложений: {'' if cls.added_to_attachment_menu else ''}".ljust(60) + "",
f"",
f"║ 🔧 НАСТРОЙКИ:",
f"║ • Префикс команд: {cls.prefix}".ljust(60) + "",
f"║ • Режим: {'Webhook' if settings.WEBHOOK else 'Polling'}".ljust(60) + "",
footer
]
output = '\n'.join(lines)
# Вывод в консоль с цветом
if to_console and settings.START_INFO_CONSOLE:
colored_output = f"\033[96m{output}\033[0m" # Cyan цвет
print(colored_output)
# Запись в файлы
if to_file and settings.START_INFO_TO_FILE:
try:
settings.LOG_DIR.mkdir(parents=True, exist_ok=True)
# Полная информация в bot_info.log
info_file = settings.LOG_DIR / 'bot_info.log'
with open(info_file, 'w', encoding='utf-8') as f:
f.write(output)
# Краткая запись в историю запусков
start_file = settings.LOG_DIR / 'bot_starts.log'
with open(start_file, 'a', encoding='utf-8') as f:
start_entry = f"{cls.started_at.strftime('%d.%m.%Y %H:%M:%S')} | @{cls.username} | Mode: {'Webhook' if settings.WEBHOOK else 'Polling'}\n"
f.write(start_entry)
logger.debug(f"Информация о боте записана в {info_file}", log_type='BOT_INFO')
except Exception as e:
logger.error(f"Ошибка записи информации в файл: {e}", log_type='BOT_INFO')
return output
@classmethod
async def setup(
cls,
bots: Bot = bot,
perm: bool = None,
setup_webhook: bool = True
) -> None:
"""
Выполняет полную настройку бота.
Args:
bots: Объект бота для управления
perm: Разрешение на изменения (если None, берется из настроек)
setup_webhook: Устанавливать ли webhook (по умолчанию True)
"""
perm = perm if perm is not None else settings.BOT_EDIT
logger.info("🚀 Процесс запуска бота!", log_type='START')
# Настройка вебхука (только если разрешено)
if setup_webhook:
await cls.webhook(bots=bots)
# Получение информации
await cls.info(bots=bots)
# Обновление профиля (если разрешено)
if perm:
logger.info("Начало настройки профиля бота...", log_type='BOT_SETUP')
results = {
'name': await cls.set_name(bots=bots),
'description': await cls.set_description(bots=bots),
'short_description': await cls.set_short_description(bots=bots),
'admin_rights': await cls.set_administrator_rights(bots=bots)
}
changed_count = sum(results.values())
logger.info(
f"Настройка завершена. Изменено параметров: {changed_count}/4",
log_type='BOT_SETUP'
)
else:
logger.warning(
"⚠️ Изменение настроек бота отключено (BOT_EDIT=False)",
log_type='BOT_SETUP'
)
# Вывод красивой информации
cls.print()

259
bot/core/webhook.py Normal file
View File

@@ -0,0 +1,259 @@
"""
Управление вебхуком бота через класс-менеджер
"""
import secrets
from typing import Optional
from aiohttp import web
from aiogram import Bot, Dispatcher
from aiogram.types import WebhookInfo
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
from configs import settings
from middleware.loggers import logger
__all__ = ('WebhookManager',)
class WebhookManager:
"""
Менеджер для управления webhook режимом.
Инкапсулирует всю логику работы с webhook:
- Создание aiohttp приложения
- Регистрация handlers
- Установка/удаление webhook
- Запуск webhook сервера
Attributes:
bot: Экземпляр бота
dp: Диспетчер
app: aiohttp приложение
secret_token: Секретный токен для webhook
"""
def __init__(self, bot: Bot, dp: Dispatcher):
"""
Args:
bot: Экземпляр бота
dp: Диспетчер
"""
self.bot = bot
self.dp = dp
self.app = web.Application()
self._configured = False
# Генерируем или используем существующий токен
self.secret_token = self._get_or_generate_token()
def _get_or_generate_token(self) -> str:
"""
Получает токен из настроек или генерирует новый.
Returns:
str: Секретный токен
"""
if hasattr(settings, 'SECRET_TOKEN') and settings.SECRET_TOKEN:
logger.debug("Используется SECRET_TOKEN из настроек", log_type='WEBHOOK')
return settings.SECRET_TOKEN
# Генерируем случайный токен (32 символа)
token = secrets.token_urlsafe(32)
logger.info(
f"🔐 Сгенерирован новый SECRET_TOKEN: {token[:8]}...",
log_type='WEBHOOK'
)
return token
async def get_info(self) -> WebhookInfo:
"""
Получает информацию о текущем вебхуке.
Returns:
WebhookInfo: Информация о вебхуке
"""
try:
info = await self.bot.get_webhook_info()
logger.debug(
f"Webhook URL: {info.url or 'не установлен'}",
log_type='WEBHOOK'
)
return info
except Exception as e:
logger.error(
f"Ошибка получения информации о вебхуке: {e}",
log_type='WEBHOOK'
)
raise
async def delete(self, drop_pending_updates: bool = True) -> bool:
"""
Удаляет текущий вебхук.
Args:
drop_pending_updates: Удалить накопленные обновления
Returns:
bool: True если удаление успешно
"""
try:
result = await self.bot.delete_webhook(
drop_pending_updates=drop_pending_updates
)
if result:
logger.success("✓ Вебхук успешно удален", log_type='WEBHOOK')
else:
logger.debug("Вебхук не был установлен", log_type='WEBHOOK')
return result
except Exception as e:
logger.error(f"Ошибка удаления вебхука: {e}", log_type='WEBHOOK')
return False
async def setup(
self,
webhook_url: Optional[str] = None,
secret_token: Optional[str] = None,
drop_pending_updates: bool = True
) -> bool:
"""
Устанавливает вебхук для бота.
Args:
webhook_url: URL вебхука (если None, берется из settings)
secret_token: Секретный токен (если None, используется self.secret_token)
drop_pending_updates: Удалить накопленные обновления
Returns:
bool: True если установка успешна
"""
url = webhook_url or settings.WEBHOOK_URL
token = secret_token or self.secret_token
if not url:
logger.error("WEBHOOK_URL не установлен", log_type='WEBHOOK')
return False
try:
# Проверяем текущий webhook
current_info = await self.bot.get_webhook_info()
# Если уже установлен правильный URL, не трогаем
if current_info.url == url:
logger.info(
f"✓ Webhook уже установлен на {url}",
log_type='WEBHOOK'
)
return True
# Удаляем старый webhook если есть
if current_info.url:
logger.debug(f"Удаление старого webhook: {current_info.url}", log_type='WEBHOOK')
await self.delete(drop_pending_updates=drop_pending_updates)
# Небольшая задержка
import asyncio
await asyncio.sleep(0.5)
# Устанавливаем новый
result = await self.bot.set_webhook(
url=url,
secret_token=token,
drop_pending_updates=drop_pending_updates
)
if result:
logger.success(f"✓ Вебхук установлен: {url}", log_type='WEBHOOK')
else:
logger.error("Не удалось установить вебхук", log_type='WEBHOOK')
return result
except Exception as e:
logger.error(f"❌ Ошибка установки вебхука: {e}", log_type='WEBHOOK')
return False
def configure(
self,
webhook_path: Optional[str] = None,
secret_token: Optional[str] = None
) -> None:
"""
Конфигурирует webhook handler для aiohttp app.
Args:
webhook_path: Путь для webhook (если None, извлекается из WEBHOOK_URL)
secret_token: Секретный токен (если None, используется self.secret_token)
"""
if self._configured:
logger.warning("Webhook уже сконфигурирован", log_type='WEBHOOK')
return
# Определяем путь из WEBHOOK_URL
if webhook_path:
path = webhook_path
elif settings.WEBHOOK_URL:
from urllib.parse import urlparse
parsed = urlparse(settings.WEBHOOK_URL)
path = parsed.path if parsed.path else "/webhook"
else:
path = "/webhook"
# Используем токен
token = secret_token or self.secret_token
# Создаём webhook handler
webhook_handler = SimpleRequestHandler(
dispatcher=self.dp,
bot=self.bot,
secret_token=token
)
# Регистрируем в aiohttp app
webhook_handler.register(self.app, path=path)
setup_application(self.app, self.dp, bot=self.bot)
self._configured = True
logger.success(
f"✓ Webhook handler настроен на путь: {path}",
log_type='WEBHOOK'
)
def run(
self,
host: Optional[str] = None,
port: Optional[int] = None,
access_log: Optional[bool] = None
) -> None:
"""
Запускает webhook сервер (блокирующий вызов).
Args:
host: Хост сервера (если None, берется из settings)
port: Порт сервера (если None, берется из settings)
access_log: Логировать запросы (если None, берется из settings)
"""
if not self._configured:
logger.error(
"Webhook не сконфигурирован! Вызовите configure() перед run()",
log_type='WEBHOOK'
)
return
host = host or settings.WEBAPP_HOST
port = port or settings.WEBAPP_PORT
access_log_enabled = access_log if access_log is not None else settings.ACCES_LOG
logger.info(
f"🌐 Запуск webhook сервера: {host}:{port}",
log_type='WEBHOOK'
)
web.run_app(
self.app,
host=host,
port=port,
access_log=logger if access_log_enabled else None
)

11
bot/filters/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
"""
Модуль фильтров для aiogram
"""
from .subscription import *
from .admin import *
from .spam import *
from .modes import *
from .chat_type import *
from .msg_content import *
from .chat_rights import *
from .callback import *

109
bot/filters/admin.py Normal file
View File

@@ -0,0 +1,109 @@
"""
Фильтры для проверки прав администратора
"""
from typing import Union
from aiogram.filters import BaseFilter
from aiogram.types import Message, CallbackQuery
from configs import settings
from database import get_manager
from middleware.loggers import logger
__all__ = ('IsSuperAdmin', 'IsAdmin', 'IsOwner')
class IsSuperAdmin(BaseFilter):
"""
Проверяет, является ли пользователь суперадминистратором (из .env).
Суперадмины имеют полный доступ ко всем командам бота.
Example:
```python
@router.message(Command("addadmin"), IsSuperAdmin())
async def add_admin_command(message: Message):
await message.answer("Добавление админа...")
```
"""
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
user_id = event.from_user.id
is_super_admin = user_id in settings.OWNER_ID
if not is_super_admin:
logger.warning(
f"Попытка доступа к команде суперадмина от user_id={user_id}",
log_type='SECURITY',
message=event if isinstance(event, Message) else None
)
return is_super_admin
class IsAdmin(BaseFilter):
"""
Проверяет, является ли пользователь администратором (суперадмин или доп. админ).
Администраторы могут управлять банвордами, но не могут добавлять других админов.
Список дополнительных админов загружается из БД через BanWordsManager.
Example:
```python
@router.message(Command("addword"), IsAdmin())
async def add_word_command(message: Message):
await message.answer("Добавление банворда...")
```
"""
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
user_id = event.from_user.id
# Проверка суперадмина
if user_id in settings.OWNER_ID:
return True
# Проверка доп. админа из БД (через кэш)
manager = get_manager()
is_db_admin = manager.is_admin_cached(user_id)
if not is_db_admin:
logger.warning(
f"Попытка доступа к админ-команде от user_id={user_id}",
log_type='SECURITY',
message=event if isinstance(event, Message) else None
)
return is_db_admin
class IsOwner(BaseFilter):
"""
Проверяет, является ли пользователь первым владельцем бота (OWNER_ID[0]).
Используется для критических операций (например, полная очистка данных).
Example:
```python
@router.message(Command("reset_all"), IsOwner())
async def reset_command(message: Message):
await message.answer("⚠️ Сброс всех данных...")
```
"""
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
user_id = event.from_user.id
# Берём первого суперадмина как владельца
owner_id = settings.OWNER_ID[0] if settings.OWNER_ID else None
is_owner = user_id == owner_id
if not is_owner:
logger.warning(
f"Попытка доступа к команде владельца от user_id={user_id}",
log_type='SECURITY',
message=event if isinstance(event, Message) else None
)
return is_owner

253
bot/filters/callback.py Normal file
View File

@@ -0,0 +1,253 @@
"""
Фильтры для обработки callback-запросов
"""
import re
from typing import Union
from aiogram.filters import BaseFilter
from aiogram.types import CallbackQuery
from middleware.loggers import logger
__all__ = (
'CallbackStartsWith',
'CallbackEndsWith',
'CallbackContains',
'CallbackMatches',
'CallbackIn'
)
class CallbackStartsWith(BaseFilter):
"""
Проверяет, начинается ли callback_data с указанного префикса.
Attributes:
prefix: Префикс для проверки (строка или список строк)
ignore_case: Игнорировать регистр
Example:
```python
# Один префикс
@router.callback_query(CallbackStartsWith("menu:"))
async def menu_handler(callback: CallbackQuery):
await callback.answer("Меню")
# Несколько префиксов
@router.callback_query(CallbackStartsWith(["admin:", "mod:"]))
async def admin_handler(callback: CallbackQuery):
await callback.answer("Админ панель")
```
"""
def __init__(self, prefix: Union[str, list[str]], ignore_case: bool = True):
"""
Args:
prefix: Префикс или список префиксов
ignore_case: Игнорировать регистр букв
"""
self.prefixes = [prefix] if isinstance(prefix, str) else prefix
self.ignore_case = ignore_case
if self.ignore_case:
self.prefixes = [p.lower() for p in self.prefixes]
async def __call__(self, callback: CallbackQuery) -> Union[bool, dict]:
if not callback.data:
return False
data = callback.data.lower() if self.ignore_case else callback.data
for prefix in self.prefixes:
if data.startswith(prefix):
# Извлекаем данные после префикса
value = callback.data[len(prefix):]
logger.debug(
f"Callback с префиксом '{prefix}': {callback.data}",
log_type='CALLBACK'
)
return {
'matched': True,
'prefix': prefix,
'value': value,
'full_data': callback.data
}
return False
class CallbackEndsWith(BaseFilter):
"""
Проверяет, заканчивается ли callback_data на указанный суффикс.
Example:
```python
@router.callback_query(CallbackEndsWith(":confirm"))
async def confirm_handler(callback: CallbackQuery, matched: dict):
action = matched['value']
await callback.answer(f"Подтверждение: {action}")
```
"""
def __init__(self, suffix: Union[str, list[str]], ignore_case: bool = True):
"""
Args:
suffix: Суффикс или список суффиксов
ignore_case: Игнорировать регистр букв
"""
self.suffixes = [suffix] if isinstance(suffix, str) else suffix
self.ignore_case = ignore_case
if self.ignore_case:
self.suffixes = [s.lower() for s in self.suffixes]
async def __call__(self, callback: CallbackQuery) -> Union[bool, dict]:
if not callback.data:
return False
data = callback.data.lower() if self.ignore_case else callback.data
for suffix in self.suffixes:
if data.endswith(suffix):
# Извлекаем данные до суффикса
value = callback.data[:-len(suffix)]
return {
'matched': True,
'suffix': suffix,
'value': value,
'full_data': callback.data
}
return False
class CallbackContains(BaseFilter):
"""
Проверяет, содержит ли callback_data указанную подстроку.
Example:
```python
@router.callback_query(CallbackContains("delete"))
async def delete_handler(callback: CallbackQuery):
await callback.answer("Удаление...")
```
"""
def __init__(self, substring: Union[str, list[str]], ignore_case: bool = True):
"""
Args:
substring: Подстрока или список подстрок
ignore_case: Игнорировать регистр букв
"""
self.substrings = [substring] if isinstance(substring, str) else substring
self.ignore_case = ignore_case
if self.ignore_case:
self.substrings = [s.lower() for s in self.substrings]
async def __call__(self, callback: CallbackQuery) -> Union[bool, dict]:
if not callback.data:
return False
data = callback.data.lower() if self.ignore_case else callback.data
for substring in self.substrings:
if substring in data:
return {
'matched': True,
'substring': substring,
'full_data': callback.data
}
return False
class CallbackMatches(BaseFilter):
"""
Проверяет callback_data по regex паттерну.
Example:
```python
# Паттерн: user_123, user_456 и т.д.
@router.callback_query(CallbackMatches(r"^user_(\d+)$"))
async def user_handler(callback: CallbackQuery, matched: dict):
user_id = matched['groups']
await callback.answer(f"Пользователь {user_id}")
```
"""
def __init__(self, pattern: Union[str, re.Pattern], flags: int = 0):
"""
Args:
pattern: Regex паттерн (строка или скомпилированный Pattern)
flags: Флаги для regex (например, re.IGNORECASE)
"""
if isinstance(pattern, str):
self.pattern = re.compile(pattern, flags)
else:
self.pattern = pattern
async def __call__(self, callback: CallbackQuery) -> Union[bool, dict]:
if not callback.data:
return False
match = self.pattern.match(callback.data)
if match:
logger.debug(
f"Callback соответствует паттерну {self.pattern.pattern}: {callback.data}",
log_type='CALLBACK'
)
return {
'matched': True,
'pattern': self.pattern.pattern,
'groups': match.groups(),
'groupdict': match.groupdict(),
'full_data': callback.data
}
return False
class CallbackIn(BaseFilter):
"""
Проверяет, находится ли callback_data в списке разрешенных значений.
Example:
```python
@router.callback_query(CallbackIn(["yes", "no", "cancel"]))
async def choice_handler(callback: CallbackQuery):
choice = callback.data
await callback.answer(f"Выбрано: {choice}")
```
"""
def __init__(self, values: list[str], ignore_case: bool = True):
"""
Args:
values: Список разрешенных значений
ignore_case: Игнорировать регистр букв
"""
self.values = values
self.ignore_case = ignore_case
if self.ignore_case:
self.values = [v.lower() for v in values]
async def __call__(self, callback: CallbackQuery) -> Union[bool, dict]:
if not callback.data:
return False
data = callback.data.lower() if self.ignore_case else callback.data
if data in self.values:
return {
'matched': True,
'value': callback.data
}
return False

324
bot/filters/chat_rights.py Normal file
View File

@@ -0,0 +1,324 @@
"""
Фильтры для проверки прав пользователей в чатах
"""
from typing import Any, Union
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import BaseFilter
from aiogram.types import Message, CallbackQuery
from aiogram.enums import ChatMemberStatus
from configs import settings
from middleware.loggers import logger
__all__ = (
'IsBotOwner',
'IsChatCreator',
'IsChatAdmin',
'IsModerator',
'CanDeleteMessages',
'CanRestrictMembers',
'CanPinMessages'
)
class IsBotOwner(BaseFilter):
"""
Проверяет, является ли пользователь владельцем бота (из .env).
Attributes:
send_error_message: Отправлять ли сообщение об ошибке доступа
Example:
```python
# Без сообщения об ошибке
@router.message(Command("reset"), IsOwner())
async def reset_command(message: Message):
await message.answer("🔄 Сброс данных...")
# С сообщением об ошибке
@router.message(Command("secret"), IsOwner(send_error_message=True))
async def secret_command(message: Message):
await message.answer("🔐 Секретная команда выполнена")
```
"""
def __init__(self, send_error_message: bool = False) -> None:
"""
Args:
send_error_message: Если True, отправляет сообщение при отказе в доступе
"""
self.send_error_message = send_error_message
async def __call__(
self,
event: Union[Message, CallbackQuery],
bot: Bot
) -> Union[bool, dict[str, Any]]:
"""
Проверка владельца бота.
Returns:
bool или dict: True/dict если владелец, False иначе
"""
if not event.from_user:
return False
user_id = event.from_user.id
is_owner = user_id in settings.OWNER_ID
if not is_owner:
logger.warning(
f"Попытка доступа к команде владельца от user_id={user_id}",
log_type='SECURITY',
message=event if isinstance(event, Message) else None
)
if self.send_error_message:
error_text = "⛔ Эта команда доступна только владельцу бота!"
if isinstance(event, Message):
await event.answer(error_text)
elif isinstance(event, CallbackQuery):
await event.answer(error_text, show_alert=True)
return False
# Возвращаем информацию для handler
return {
'is_owner': True,
'user_id': user_id,
'owner_ids': settings.OWNER_ID
}
class IsChatCreator(BaseFilter):
"""
Проверяет, является ли пользователь создателем чата.
Example:
```python
@router.message(Command("transfer"), IsChatCreator())
async def transfer_ownership(message: Message):
await message.answer("👑 Передача владения чатом...")
```
"""
async def __call__(self, message: Message, bot: Bot) -> Union[bool, dict]:
try:
member = await bot.get_chat_member(
chat_id=message.chat.id,
user_id=message.from_user.id
)
is_creator = member.status == ChatMemberStatus.CREATOR
if is_creator:
return {
'is_creator': True,
'user_id': message.from_user.id,
'chat_id': message.chat.id
}
return False
except (TelegramBadRequest, TelegramForbiddenError) as e:
logger.error(
f"Ошибка проверки создателя чата: {e}",
log_type='CHAT_RIGHTS',
message=message
)
return False
class IsChatAdmin(BaseFilter):
"""
Проверяет, является ли пользователь администратором чата (или создателем).
Example:
```python
@router.message(Command("ban"), IsChatAdmin())
async def ban_user(message: Message):
await message.answer("🔨 Бан пользователя...")
```
"""
async def __call__(self, message: Message, bot: Bot) -> Union[bool, dict]:
try:
member = await bot.get_chat_member(
chat_id=message.chat.id,
user_id=message.from_user.id
)
is_admin = member.status in (
ChatMemberStatus.ADMINISTRATOR,
ChatMemberStatus.CREATOR
)
if is_admin:
return {
'is_admin': True,
'status': member.status.value,
'user_id': message.from_user.id,
'chat_id': message.chat.id
}
return False
except (TelegramBadRequest, TelegramForbiddenError) as e:
logger.error(
f"Ошибка проверки администратора чата: {e}",
log_type='CHAT_RIGHTS',
message=message
)
return False
class IsModerator(BaseFilter):
"""
Проверяет, имеет ли администратор модераторские права:
- Удаление сообщений
- Ограничение пользователей
- Закрепление сообщений
Example:
```python
@router.message(Command("warn"), IsModerator())
async def warn_user(message: Message):
await message.answer("⚠️ Предупреждение пользователю...")
```
"""
async def __call__(self, message: Message, bot: Bot) -> Union[bool, dict]:
try:
member = await bot.get_chat_member(
chat_id=message.chat.id,
user_id=message.from_user.id
)
# Создатель всегда модератор
if member.status == ChatMemberStatus.CREATOR:
return {
'is_moderator': True,
'status': 'creator',
'user_id': message.from_user.id
}
# Проверка прав администратора
if member.status != ChatMemberStatus.ADMINISTRATOR:
return False
# Проверка модераторских прав
required_rights = [
getattr(member, 'can_delete_messages', False),
getattr(member, 'can_restrict_members', False),
getattr(member, 'can_pin_messages', False),
]
has_all_rights = all(required_rights)
if has_all_rights:
return {
'is_moderator': True,
'status': 'administrator',
'can_delete': required_rights[0],
'can_restrict': required_rights[1],
'can_pin': required_rights[2],
'user_id': message.from_user.id
}
return False
except (TelegramBadRequest, TelegramForbiddenError) as e:
logger.error(
f"Ошибка проверки модератора: {e}",
log_type='CHAT_RIGHTS',
message=message
)
return False
class CanDeleteMessages(BaseFilter):
"""
Проверяет право на удаление сообщений.
Example:
```python
@router.message(Command("clear"), CanDeleteMessages())
async def clear_messages(message: Message):
await message.answer("🗑️ Очистка сообщений...")
```
"""
async def __call__(self, message: Message, bot: Bot) -> bool:
try:
member = await bot.get_chat_member(
chat_id=message.chat.id,
user_id=message.from_user.id
)
if member.status == ChatMemberStatus.CREATOR:
return True
return getattr(member, 'can_delete_messages', False)
except (TelegramBadRequest, TelegramForbiddenError):
return False
class CanRestrictMembers(BaseFilter):
"""
Проверяет право на ограничение пользователей (бан, мут).
Example:
```python
@router.message(Command("mute"), CanRestrictMembers())
async def mute_user(message: Message):
await message.answer("🔇 Мут пользователя...")
```
"""
async def __call__(self, message: Message, bot: Bot) -> bool:
try:
member = await bot.get_chat_member(
chat_id=message.chat.id,
user_id=message.from_user.id
)
if member.status == ChatMemberStatus.CREATOR:
return True
return getattr(member, 'can_restrict_members', False)
except (TelegramBadRequest, TelegramForbiddenError):
return False
class CanPinMessages(BaseFilter):
"""
Проверяет право на закрепление сообщений.
Example:
```python
@router.message(Command("pin"), CanPinMessages())
async def pin_message(message: Message):
if message.reply_to_message:
await message.reply_to_message.pin()
```
"""
async def __call__(self, message: Message, bot: Bot) -> bool:
try:
member = await bot.get_chat_member(
chat_id=message.chat.id,
user_id=message.from_user.id
)
if member.status == ChatMemberStatus.CREATOR:
return True
return getattr(member, 'can_pin_messages', False)
except (TelegramBadRequest, TelegramForbiddenError):
return False

105
bot/filters/chat_type.py Normal file
View File

@@ -0,0 +1,105 @@
"""
Фильтры для проверки типов чатов
"""
from typing import Union
from aiogram.filters import BaseFilter
from aiogram.types import Message, CallbackQuery
from aiogram.enums import ChatType
__all__ = ('IsPrivateChat', 'IsGroupChat', 'IsSuperGroupChat', 'IsChannelChat', 'IsAnyGroup')
class IsPrivateChat(BaseFilter):
"""
Проверяет, что сообщение из личного чата (приватный диалог с ботом).
Example:
```python
@router.message(Command("start"), IsPrivateChat())
async def start_private(message: Message):
await message.answer("Привет в личке!")
```
"""
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
if isinstance(event, CallbackQuery):
event = event.message
return event.chat.type == ChatType.PRIVATE
class IsGroupChat(BaseFilter):
"""
Проверяет, что сообщение из обычной группы (не супергруппы).
Example:
```python
@router.message(IsGroupChat())
async def group_message(message: Message):
await message.answer("Это обычная группа")
```
"""
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
if isinstance(event, CallbackQuery):
event = event.message
return event.chat.type == ChatType.GROUP
class IsSuperGroupChat(BaseFilter):
"""
Проверяет, что сообщение из супергруппы.
Example:
```python
@router.message(IsSuperGroupChat())
async def supergroup_message(message: Message):
await message.answer("Это супергруппа")
```
"""
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
if isinstance(event, CallbackQuery):
event = event.message
return event.chat.type == ChatType.SUPERGROUP
class IsChannelChat(BaseFilter):
"""
Проверяет, что сообщение из канала.
Example:
```python
@router.message(IsChannelChat())
async def channel_message(message: Message):
await message.answer("Это канал")
```
"""
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
if isinstance(event, CallbackQuery):
event = event.message
return event.chat.type == ChatType.CHANNEL
class IsAnyGroup(BaseFilter):
"""
Проверяет, что сообщение из любой группы (обычная или супергруппа).
Example:
```python
@router.message(Command("admin"), IsAnyGroup())
async def admin_command(message: Message):
await message.answer("Команда доступна только в группах")
```
"""
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
if isinstance(event, CallbackQuery):
event = event.message
return event.chat.type in (ChatType.GROUP, ChatType.SUPERGROUP)

184
bot/filters/modes.py Normal file
View File

@@ -0,0 +1,184 @@
"""
Фильтры для проверки активных режимов бота (silence, conflict)
"""
from datetime import datetime
from typing import Optional
from aiogram.filters import BaseFilter
from aiogram.types import Message
from middleware.loggers import logger
__all__ = ('IsSilenceActive', 'IsConflictModeActive')
class IsSilenceActive(BaseFilter):
"""
Проверяет, активен ли режим тишины (silence mode).
В режиме тишины удаляются ВСЕ сообщения (кроме админов).
Attributes:
silence_until: Время до которого активен режим (None = неактивен)
Example:
```python
# В handler-файле
silence_filter = IsSilenceActive()
@router.message(silence_filter)
async def silence_mode_active(message: Message):
# Удаляем все сообщения в режиме тишины
await message.delete()
```
"""
def __init__(self, silence_until: Optional[datetime] = None):
"""
Args:
silence_until: Datetime до которого активен режим
"""
self.silence_until = silence_until
def update_silence_until(self, new_datetime: Optional[datetime]) -> None:
"""
Обновляет время окончания режима тишины.
Args:
new_datetime: Новое время окончания или None для отключения
"""
self.silence_until = new_datetime
if new_datetime:
logger.info(
f"Режим тишины активирован до {new_datetime.strftime('%H:%M:%S')}",
log_type='SILENCE'
)
else:
logger.info("Режим тишины отключен", log_type='SILENCE')
def is_active(self) -> bool:
"""
Проверяет, активен ли режим сейчас.
Returns:
bool: True если режим активен
"""
if self.silence_until is None:
return False
# Проверка истечения времени
if datetime.now() >= self.silence_until:
logger.info("Режим тишины автоматически завершен", log_type='SILENCE')
self.silence_until = None
return False
return True
async def __call__(self, event: Message) -> Optional[dict]:
"""
Проверка активности режима тишины.
Returns:
dict или None: Информация о режиме если активен, иначе None
"""
if self.is_active():
remaining = (self.silence_until - datetime.now()).total_seconds()
logger.debug(
f"Режим тишины активен (осталось {remaining:.0f}с)",
log_type='SILENCE',
message=event
)
return {
'is_active': True,
'until': self.silence_until,
'remaining_seconds': remaining
}
return None
class IsConflictModeActive(BaseFilter):
"""
Проверяет, активен ли режим антиконфликта (conflict mode).
В режиме антиконфликта удаляются сообщения с конфликтными словами.
Attributes:
conflict_until: Время до которого активен режим (None = неактивен)
Example:
```python
conflict_filter = IsConflictModeActive()
@router.message(conflict_filter)
async def conflict_mode_active(message: Message):
# Проверяем на конфликтные слова и удаляем
if has_conflict_words(message.text):
await message.delete()
```
"""
def __init__(self, conflict_until: Optional[datetime] = None):
"""
Args:
conflict_until: Datetime до которого активен режим
"""
self.conflict_until = conflict_until
def update_conflict_until(self, new_datetime: Optional[datetime]) -> None:
"""
Обновляет время окончания режима антиконфликта.
Args:
new_datetime: Новое время окончания или None для отключения
"""
self.conflict_until = new_datetime
if new_datetime:
logger.info(
f"Режим антиконфликта активирован до {new_datetime.strftime('%H:%M:%S')}",
log_type='CONFLICT'
)
else:
logger.info("Режим антиконфликта отключен", log_type='CONFLICT')
def is_active(self) -> bool:
"""
Проверяет, активен ли режим сейчас.
Returns:
bool: True если режим активен
"""
if self.conflict_until is None:
return False
# Проверка истечения времени
if datetime.now() >= self.conflict_until:
logger.info("Режим антиконфликта автоматически завершен", log_type='CONFLICT')
self.conflict_until = None
return False
return True
async def __call__(self, event: Message) -> Optional[dict]:
"""
Проверка активности режима антиконфликта.
Returns:
dict или None: Информация о режиме если активен, иначе None
"""
if self.is_active():
remaining = (self.conflict_until - datetime.now()).total_seconds()
logger.debug(
f"Режим антиконфликта активен (осталось {remaining:.0f}с)",
log_type='CONFLICT',
message=event
)
return {
'is_active': True,
'until': self.conflict_until,
'remaining_seconds': remaining
}
return None

395
bot/filters/msg_content.py Normal file
View File

@@ -0,0 +1,395 @@
"""
Фильтры для проверки содержимого сообщений
"""
import re
from typing import Optional, Union
from aiogram.filters import BaseFilter
from aiogram.types import Message, ContentType
from middleware.loggers import logger
__all__ = (
'IsReply',
'IsForwarded',
'HasMedia',
'ContainsURL',
'HasText',
'HasCaption',
'HasEntities',
'MediaType'
)
class IsReply(BaseFilter):
"""
Проверяет, является ли сообщение ответом на другое сообщение.
Example:
```python
@router.message(IsReply())
async def handle_reply(message: Message):
original = message.reply_to_message
await message.answer(f"Это ответ на: {original.text}")
```
"""
async def __call__(self, message: Message) -> Union[bool, dict]:
is_reply = message.reply_to_message is not None
if is_reply:
return {
'is_reply': True,
'reply_to_message': message.reply_to_message,
'reply_to_user_id': message.reply_to_message.from_user.id if message.reply_to_message.from_user else None
}
return False
class IsForwarded(BaseFilter):
"""
Проверяет, является ли сообщение пересланным.
Поддерживает:
- Пересылку от пользователей (forward_from)
- Пересылку из каналов/групп (forward_from_chat)
- Скрытую пересылку (forward_sender_name)
Example:
```python
@router.message(IsForwarded())
async def handle_forwarded(message: Message, forward_info: dict):
await message.answer(f"Переслано из: {forward_info['origin']}")
```
"""
async def __call__(self, message: Message) -> Union[bool, dict]:
# Проверка различных типов пересылки
is_forwarded = (
message.forward_origin is not None or # Новый API (aiogram 3.x)
message.forward_from is not None or
message.forward_from_chat is not None or
message.forward_sender_name is not None
)
if is_forwarded:
origin = "неизвестно"
if message.forward_from:
origin = f"пользователь @{message.forward_from.username or message.forward_from.id}"
elif message.forward_from_chat:
origin = f"чат {message.forward_from_chat.title or message.forward_from_chat.id}"
elif message.forward_sender_name:
origin = f"скрытый пользователь ({message.forward_sender_name})"
logger.debug(
f"Обнаружено пересланное сообщение из: {origin}",
log_type='FORWARD',
message=message
)
return {
'is_forwarded': True,
'origin': origin,
'forward_date': message.forward_date
}
return False
class HasMedia(BaseFilter):
"""
Проверяет, содержит ли сообщение медиа-контент.
Attributes:
media_types: Список типов медиа для проверки (если None, проверяются все)
Example:
```python
# Любое медиа
@router.message(HasMedia())
async def handle_media(message: Message):
await message.answer("Получено медиа!")
# Только фото и видео
@router.message(HasMedia(['photo', 'video']))
async def handle_visual(message: Message):
await message.answer("Фото или видео!")
```
"""
def __init__(self, media_types: Optional[list[str]] = None):
"""
Args:
media_types: Список типов медиа ('photo', 'video', 'document', и т.д.)
Если None, проверяются все типы
"""
self.media_types = media_types
async def __call__(self, message: Message) -> Union[bool, dict]:
# Все возможные типы медиа
media_checks = {
'photo': message.photo,
'video': message.video,
'document': message.document,
'audio': message.audio,
'voice': message.voice,
'video_note': message.video_note,
'sticker': message.sticker,
'animation': message.animation,
}
# Если указаны конкретные типы, проверяем только их
if self.media_types:
has_media = any(
media_checks[media_type]
for media_type in self.media_types
if media_type in media_checks
)
detected_type = next(
(media_type for media_type in self.media_types if media_checks.get(media_type)),
None
)
else:
# Проверяем все типы
has_media = any(media_checks.values())
detected_type = next(
(media_type for media_type, value in media_checks.items() if value),
None
)
if has_media:
return {
'has_media': True,
'media_type': detected_type,
'content': media_checks[detected_type]
}
return False
class ContainsURL(BaseFilter):
"""
Проверяет, содержит ли сообщение ссылки.
Поддерживает:
- HTTP/HTTPS ссылки
- Telegram ссылки (t.me, tg://)
- Проверку через entities (более точная)
Attributes:
strict: Использовать строгую проверку через entities
Example:
```python
@router.message(ContainsURL())
async def handle_url(message: Message, url_info: dict):
urls = url_info['urls']
await message.answer(f"Обнаружено {len(urls)} ссылок")
```
"""
def __init__(self, strict: bool = False):
"""
Args:
strict: Если True, проверяет через entities (игнорирует текст в коде/pre)
"""
self.strict = strict
# Паттерн для поиска URL
self.url_pattern = re.compile(
r'https?://[^\s]+|' # http(s)://
r't\.me/[^\s]+|' # t.me/
r'tg://[^\s]+', # tg://
re.IGNORECASE
)
async def __call__(self, message: Message) -> Union[bool, dict]:
if not message.text and not message.caption:
return False
text = message.text or message.caption
if self.strict and message.entities:
# Строгая проверка через entities
url_entities = [
entity for entity in message.entities
if entity.type in ('url', 'text_link')
]
if url_entities:
urls = []
for entity in url_entities:
if entity.type == 'url':
url = text[entity.offset:entity.offset + entity.length]
urls.append(url)
elif entity.type == 'text_link':
urls.append(entity.url)
return {
'contains_url': True,
'urls': urls,
'url_count': len(urls)
}
else:
# Простая проверка через regex
urls = self.url_pattern.findall(text)
if urls:
return {
'contains_url': True,
'urls': urls,
'url_count': len(urls)
}
return False
class HasText(BaseFilter):
"""
Проверяет, содержит ли сообщение текст.
Attributes:
min_length: Минимальная длина текста (по умолчанию 1)
max_length: Максимальная длина текста (по умолчанию None)
Example:
```python
# Любой текст
@router.message(HasText())
async def handle_text(message: Message):
await message.answer("Получен текст!")
# Текст от 10 до 100 символов
@router.message(HasText(min_length=10, max_length=100))
async def handle_medium_text(message: Message):
await message.answer("Текст подходящей длины!")
```
"""
def __init__(self, min_length: int = 1, max_length: Optional[int] = None):
self.min_length = min_length
self.max_length = max_length
async def __call__(self, message: Message) -> Union[bool, dict]:
if not message.text:
return False
text_length = len(message.text)
# Проверка длины
if text_length < self.min_length:
return False
if self.max_length and text_length > self.max_length:
return False
return {
'has_text': True,
'text_length': text_length,
'text': message.text
}
class HasCaption(BaseFilter):
"""
Проверяет, есть ли у медиа подпись.
Example:
```python
@router.message(HasCaption())
async def handle_caption(message: Message):
await message.answer(f"Подпись: {message.caption}")
```
"""
async def __call__(self, message: Message) -> Union[bool, dict]:
if message.caption:
return {
'has_caption': True,
'caption': message.caption,
'caption_length': len(message.caption)
}
return False
class HasEntities(BaseFilter):
"""
Проверяет наличие entities (упоминания, хештеги, команды и т.д.).
Attributes:
entity_types: Список типов entities для проверки
Example:
```python
# Любые entities
@router.message(HasEntities())
async def handle_entities(message: Message):
pass
# Только упоминания и хештеги
@router.message(HasEntities(['mention', 'hashtag']))
async def handle_mentions(message: Message):
pass
```
"""
def __init__(self, entity_types: Optional[list[str]] = None):
"""
Args:
entity_types: Список типов ('mention', 'hashtag', 'bot_command', и т.д.)
"""
self.entity_types = entity_types
async def __call__(self, message: Message) -> Union[bool, dict]:
if not message.entities:
return False
if self.entity_types:
# Фильтруем по типам
matching_entities = [
entity for entity in message.entities
if entity.type in self.entity_types
]
if matching_entities:
return {
'has_entities': True,
'entities': matching_entities,
'entity_count': len(matching_entities)
}
else:
# Любые entities
return {
'has_entities': True,
'entities': message.entities,
'entity_count': len(message.entities)
}
return False
class MediaType(BaseFilter):
"""
Проверяет точный тип контента сообщения.
Attributes:
content_type: Тип контента из ContentType enum
Example:
```python
@router.message(MediaType(ContentType.PHOTO))
async def handle_photo(message: Message):
await message.answer("Это фото!")
```
"""
def __init__(self, content_type: Union[ContentType, str]):
"""
Args:
content_type: Тип контента (ContentType enum или строка)
"""
self.content_type = content_type if isinstance(content_type, str) else content_type.value
async def __call__(self, message: Message) -> bool:
return message.content_type == self.content_type

111
bot/filters/spam.py Normal file
View File

@@ -0,0 +1,111 @@
"""
Фильтры для проверки сообщений на спам и банворды
"""
from typing import Optional, Callable
from aiogram.filters import BaseFilter
from aiogram.types import Message
from middleware.loggers import logger
__all__ = ('HasSpam', 'IsWhitelisted')
class HasSpam(BaseFilter):
"""
Проверяет, содержит ли сообщение запрещенные слова (спам).
Attributes:
check_spam_func: Функция проверки спама (передается при инициализации)
Example:
```python
from utils.spam_checker import check_spam
@router.message(HasSpam(check_spam))
async def spam_detected(message: Message):
await message.delete()
await message.answer("⚠️ Сообщение содержит запрещенные слова")
```
"""
def __init__(self, check_spam_func: Callable[[str], bool]):
"""
Args:
check_spam_func: Функция для проверки спама
"""
self.check_spam = check_spam_func
async def __call__(self, message: Message) -> Optional[dict]:
"""
Проверка сообщения на спам.
Returns:
dict или None: Информация о найденном спаме или None
"""
if not message.text:
return None
text_lower = message.text.lower()
has_spam = self.check_spam(text_lower)
if has_spam:
logger.warning(
f"Обнаружен спам в сообщении",
log_type='SPAM',
message=message
)
return {'has_spam': True, 'text': text_lower}
return None
class IsWhitelisted(BaseFilter):
"""
Проверяет, содержит ли сообщение слова из белого списка (исключения).
Используется для защиты от ложных срабатываний спам-фильтра.
Attributes:
check_whitelist_func: Функция проверки белого списка
Example:
```python
from utils.spam_checker import check_whitelist
@router.message(IsWhitelisted(check_whitelist))
async def whitelisted_message(message: Message):
# Сообщение содержит исключение, пропускаем проверку спама
pass
```
"""
def __init__(self, check_whitelist_func: Callable[[str], bool]):
"""
Args:
check_whitelist_func: Функция для проверки белого списка
"""
self.check_whitelist = check_whitelist_func
async def __call__(self, message: Message) -> Optional[bool]:
"""
Проверка на наличие в белом списке.
Returns:
bool или None: True если в белом списке, None если нет
"""
if not message.text:
return None
text_lower = message.text.lower()
is_whitelisted = self.check_whitelist(text_lower)
if is_whitelisted:
logger.debug(
f"Сообщение содержит исключение из белого списка",
log_type='WHITELIST',
message=message
)
return True
return None

246
bot/filters/subscription.py Normal file
View File

@@ -0,0 +1,246 @@
"""
Фильтр проверки подписки пользователя на каналы/группы
"""
from typing import Union, Optional
from dataclasses import dataclass
from aiogram import Bot
from aiogram.enums import ChatMemberStatus
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import BaseFilter
from aiogram.types import Message, CallbackQuery
from middleware.loggers import logger
__all__ = ('IsSubscribed', 'SubscriptionChecker')
@dataclass
class ChannelInfo:
"""Информация о канале для проверки подписки"""
id: Union[str, int]
name: Optional[str] = None
invite_link: Optional[str] = None
class SubscriptionChecker:
"""
Вспомогательный класс для проверки подписок.
Может использоваться отдельно от фильтра.
"""
# Статусы, считающиеся подпиской
SUBSCRIBED_STATUSES: set[str] = {
ChatMemberStatus.MEMBER,
ChatMemberStatus.ADMINISTRATOR,
ChatMemberStatus.CREATOR
}
# Статусы, означающие отсутствие подписки
NOT_SUBSCRIBED_STATUSES: set[str] = {
ChatMemberStatus.LEFT,
ChatMemberStatus.KICKED,
ChatMemberStatus.RESTRICTED # Опционально
}
@classmethod
async def is_subscribed(
cls,
bot: Bot,
user_id: int,
channel_id: Union[str, int]
) -> bool:
"""
Проверяет подписку одного пользователя на один канал.
Args:
bot: Экземпляр бота
user_id: ID пользователя
channel_id: ID или username канала
Returns:
bool: True если подписан
"""
try:
member = await bot.get_chat_member(
chat_id=channel_id,
user_id=user_id
)
is_sub = member.status in cls.SUBSCRIBED_STATUSES
logger.debug(
f"Проверка подписки user={user_id} на канал={channel_id}: {member.status} ({'' if is_sub else ''})",
log_type='SUBSCRIPTION'
)
return is_sub
except TelegramBadRequest as e:
logger.warning(
f"Канал {channel_id} недоступен или неверный ID: {e}",
log_type='SUBSCRIPTION'
)
return False
except TelegramForbiddenError as e:
logger.error(
f"Бот не имеет доступа к каналу {channel_id}: {e}",
log_type='SUBSCRIPTION'
)
return False
except Exception as e:
logger.error(
f"Непредвиденная ошибка проверки подписки на {channel_id}: {e}",
log_type='SUBSCRIPTION'
)
return False
@classmethod
async def check_all_channels(
cls,
bot: Bot,
user_id: int,
channels: list[Union[str, int]]
) -> dict[Union[str, int], bool]:
"""
Проверяет подписку на несколько каналов одновременно.
Args:
bot: Экземпляр бота
user_id: ID пользователя
channels: Список ID/username каналов
Returns:
dict: Словарь {channel_id: is_subscribed}
"""
results = {}
for channel in channels:
results[channel] = await cls.is_subscribed(bot, user_id, channel)
return results
@classmethod
async def get_not_subscribed_channels(
cls,
bot: Bot,
user_id: int,
channels: list[Union[str, int]]
) -> list[Union[str, int]]:
"""
Возвращает список каналов, на которые пользователь НЕ подписан.
Args:
bot: Экземпляр бота
user_id: ID пользователя
channels: Список ID/username каналов
Returns:
list: Список каналов без подписки
"""
not_subscribed = []
for channel in channels:
if not await cls.is_subscribed(bot, user_id, channel):
not_subscribed.append(channel)
return not_subscribed
class IsSubscribed(BaseFilter):
"""
Фильтр для проверки подписки пользователя на каналы/группы.
Поддерживает:
- Публичные каналы (username: "@channel_name")
- Приватные каналы/группы (ID: -1001234567890)
- Проверку всех или хотя бы одного канала
- Работу с Message и CallbackQuery
Attributes:
channels: Список ID или username каналов для проверки
require_all: Требовать подписку на все каналы (True) или хотя бы один (False)
Examples:
>> # Проверка подписки на один канал
>> @router.message(IsSubscribed(["@my_channel"]))
>> async def handler(message: Message):
... await message.answer("Ты подписан!")
>> # Проверка на несколько каналов (все обязательны)
>> @router.message(IsSubscribed(["@channel1", -1001234567890], require_all=True))
>> async def handler(message: Message):
... await message.answer("Ты подписан на все каналы!")
>> # Проверка на несколько каналов (хотя бы один)
>> @router.message(IsSubscribed(["@channel1", "@channel2"], require_all=False))
>> async def handler(message: Message):
... await message.answer("Ты подписан хотя бы на один канал!")
"""
def __init__(
self,
channels: list[Union[str, int]],
require_all: bool = True
) -> None:
"""
Инициализация фильтра.
Args:
channels: Список ID или username каналов
require_all: True = все каналы, False = хотя бы один
"""
if not channels:
raise ValueError("Список каналов не может быть пустым")
self.channels = channels
self.require_all = require_all
async def __call__(
self,
event: Union[Message, CallbackQuery],
bot: Bot
) -> Union[bool, dict]:
"""
Проверка подписки.
Args:
event: Message или CallbackQuery
bot: Экземпляр бота
Returns:
bool или dict: True/False для простой проверки,
dict с деталями для сложной логики
"""
user_id = event.from_user.id
# Проверка всех каналов
results = await SubscriptionChecker.check_all_channels(
bot, user_id, self.channels
)
# Логика проверки
if self.require_all:
# Все каналы обязательны
is_passed = all(results.values())
else:
# Хотя бы один канал
is_passed = any(results.values())
# Логирование
if not is_passed:
not_subscribed = [ch for ch, sub in results.items() if not sub]
logger.info(
f"Пользователь {user_id} не подписан на: {not_subscribed}",
log_type='SUBSCRIPTION',
message=event if isinstance(event, Message) else None
)
# Возвращаем результат + детали для handler
return {
'is_subscribed': is_passed,
'subscription_results': results,
'not_subscribed_channels': [ch for ch, sub in results.items() if not sub]
} if not is_passed else is_passed

14
bot/handlers/__init__.py Normal file
View File

@@ -0,0 +1,14 @@
from aiogram import Router
from .commands import router as cmd_routers
from .messages import router as messages_routers
# Настройка экспорта и роутера
__all__ = ("router",)
router: Router = Router(name=__name__)
# Подключение роутеров
router.include_routers(
cmd_routers,
messages_routers,
)

View File

@@ -0,0 +1,16 @@
from aiogram import Router
#from .admins import router as admin_cmd_router
from .users import router as users_cmd_router
#from .settings import router as settings_cmd_router
# Настройка экспорта и роутера
__all__ = ("router",)
router: Router = Router(name=__name__)
# Подключение роутеров
router.include_routers(
#settings_cmd_router,
#admin_cmd_router,
users_cmd_router,
)

View File

@@ -0,0 +1,18 @@
from aiogram import Router
#from .ban_cmd import router as ban_cmd_router
from .all_cmd import router as all_cmd_router
from .pin_cmd import router as pin_cmd_router
from .kick_cmd import router as kick_cmd_router
# Настройка экспорта и роутера
__all__ = ("router",)
router: Router = Router(name=__name__)
router.include_routers(
#ban_cmd_router,
kick_cmd_router,
pin_cmd_router,
all_cmd_router,
)

View File

@@ -0,0 +1,81 @@
from asyncio import create_task
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from bot.core.bots import bot, BotInfo
from bot.filters import IsOwner
from bot.utils import status_clear, auto_delete_message, hidden_admins_message
from configs import COMMANDS
from middleware.loggers import logger
__all__ = ("router",)
# Ключ для команды
CMD: str = "all"
# Инициализация роутера
router: Router = Router(name=f"{CMD}_cmd_router")
@router.message(
F.text.lower().regexp(rf"^({'|'.join(COMMANDS[CMD])})\s?.*"), # ловим текст без префикса
F.chat.type.in_({"supergroup", "group"}),
IsOwner()
)
@router.message(
Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True),
F.chat.type.in_({"supergroup", "group"}),
IsOwner()
)
async def notify_all_text(message: Message, state: FSMContext) -> None:
"""
Обработчик команды /all, /call и текстовых эквивалентов типа "Калл Привет всем".
Функционал:
1. Считывает весь текст после команды.
2. Формирует скрытое сообщение для администраторов.
3. Отправляет сообщение в чат.
4. Автоматически удаляет сообщение через неделю.
5. Пытается закрепить сообщение в чате.
Args:
message (Message): Объект входящего сообщения.
state (FSMContext): Контекст FSM, используется для очистки состояния.
"""
# Очистка состояния FSM перед выполнением команды
await status_clear(update=message, state=state)
# Извлечение текста после команды
parts: list[str] = message.text.split(" ", 1)
custom_text: str = parts[1] if len(parts) > 1 else "⚡ Внимание всем!"
# Формирование скрытого текста для администраторов
hidden_text: str = await hidden_admins_message(message=message, text=custom_text)
# Отправка сообщения в чат
sent_message: Message = await message.answer(hidden_text)
# Запуск асинхронной задачи по удалению сообщения через 7 дней
create_task(
auto_delete_message(
chat_id=message.chat.id,
message_id=sent_message.message_id,
delay=604800 # 7 дней в секундах
)
)
# Попытка закрепить сообщение и удалить "системное" сообщение о закреплении
try:
await bot.pin_chat_message(
chat_id=message.chat.id,
message_id=sent_message.message_id,
disable_notification=False
)
# Иногда Telegram создает дополнительное уведомление при закреплении
await bot.delete_message(chat_id=message.chat.id, message_id=sent_message.message_id + 1)
logger.debug(f"[ALL] Сообщение закреплено: {custom_text}")
except TelegramBadRequest as e:
logger.error(f"[ALL] Ошибка закрепления сообщения: {e}")

View File

@@ -0,0 +1,258 @@
from aiogram import Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, User
from html import escape
from bot.filters import IsAdmin
from bot.utils import status_clear
from configs import COMMANDS
from database import db
# Настройки роутера
__all__ = ("router",)
from middleware import logger
CMD: str = "ban"
router: Router = Router(name=f"{CMD}_cmd_router")
@router.message(Command(*COMMANDS[CMD], ignore_case=True), IsAdmin())
async def ban_user_cmd(message: Message, state: FSMContext) -> None:
"""
Команда /ban для блокировки пользователей.
Использование: /ban <user_id> или ответ на сообщение пользователя + /ban
"""
await status_clear(update=message, state=state)
try:
# Проверяем есть ли ответ на сообщение
if message.reply_to_message:
# Бан по ответу на сообщение
target_user: User | None = message.reply_to_message.from_user
if not target_user:
await message.answer("Не удалось определить пользователя")
return
target_user_id: int = target_user.id
target_username: str = target_user.username or target_user.full_name or f"ID{target_user_id}"
# Проверяем, не пытаемся ли забанить бота
if target_user_id == message.bot.id:
await message.answer("❌ Нельзя заблокировать бота!")
return
# Баним пользователя
success: bool = await _ban_user(target_user_id, target_username, message)
if success:
safe_username: str = escape(target_username)
response_text = f"✅ Пользователь {safe_username} (ID: {target_user_id}) заблокирован!"
# Пытаемся забанить в чате (если команда вызвана в группе/чате)
if message.chat.type in ["group", "supergroup"]:
try:
await message.bot.ban_chat_member(
chat_id=message.chat.id,
user_id=target_user_id
)
response_text += "\n🚫 Пользователь исключен из чата."
except Exception as e:
logger.warning(f"Не удалось исключить пользователя из чата: {e}")
response_text += "\n⚠️ Не удалось исключить пользователя из чата."
await message.answer(
text=response_text,
parse_mode=None # Отключаем разметку
)
else:
await message.answer("Не удалось заблокировать пользователя")
else:
# Бан по ID пользователя
command_parts: list[str] = message.text.split()
if len(command_parts) < 2:
await message.answer(
" Использование команды:\n"
"• Ответьте на сообщение пользователя командой /ban\n"
"• Или укажите ID: /ban <user_id>"
)
return
try:
target_user_id: int = int(command_parts[1])
# Проверяем, не пытаемся ли забанить бота
if target_user_id == message.bot.id:
await message.answer("❌ Нельзя заблокировать бота!")
return
success: bool = await _ban_user(target_user_id, f"ID{target_user_id}", message)
if success:
response_text = f"✅ Пользователь (ID: {target_user_id}) заблокирован!"
# Пытаемся забанить в чате
if message.chat.type in ["group", "supergroup"]:
try:
await message.bot.ban_chat_member(
chat_id=message.chat.id,
user_id=target_user_id
)
response_text += "\n🚫 Пользователь исключен из чата."
except Exception as e:
logger.warning(f"Не удалось исключить пользователя из чата: {e}")
response_text += "\n⚠️ Не удалось исключить пользователя из чата."
await message.answer(
text=response_text,
parse_mode=None
)
else:
await message.answer("❌ Пользователь не найден или уже заблокирован")
except ValueError:
await message.answer("❌ Неверный формат ID пользователя")
except Exception as e:
logger.error(f"Ошибка в команде /ban: {e}")
await message.answer(
"⚠️ Произошла непредвиденная ошибка при выполнении команды.\n"
"Попробуйте повторить действие позже или нажмите /start"
)
async def _ban_user(user_id: int, username: str, message: Message) -> bool:
"""
Внутренняя функция для блокировки пользователя.
"""
try:
# Сначала проверяем существует ли пользователь
user: User | None = await db.get_user(user_id)
if not user:
# Если пользователя нет - создаем его забаненным
await db.add_user(
user_id=user_id,
username=username,
full_name=username
)
# Баним пользователя
await db.ban_user(user_id)
# Логируем действие
admin_username = message.from_user.username or message.from_user.full_name or f"ID{message.from_user.id}"
logger.info(f"🛑 Админ @{admin_username} заблокировал пользователя @{username} (ID: {user_id})")
return True
except Exception as e:
logger.error(f"❌ Ошибка при блокировке пользователя {user_id}: {e}")
return False
@router.message(Command("unban", ignore_case=True), IsAdmin())
async def unban_user_cmd(message: Message, state: FSMContext) -> None:
"""
Команда /unban для разблокировки пользователей.
"""
await status_clear(update=message, state=state)
try:
if message.reply_to_message:
target_user: User | None = message.reply_to_message.from_user
if not target_user:
await message.answer("Не удалось определить пользователя")
return
target_user_id: int = target_user.id
target_username: str = target_user.username or target_user.full_name or f"ID{target_user_id}"
else:
command_parts: list[str] = message.text.split()
if len(command_parts) < 2:
await message.answer(
" Использование команды:\n"
"• Ответьте на сообщение пользователя командой /unban\n"
"• Или укажите ID: /unban <user_id>"
)
return
try:
target_user_id: int = int(command_parts[1])
target_username: str = f"ID{target_user_id}"
except ValueError:
await message.answer("❌ Неверный формат ID пользователя")
return
# Разбаниваем пользователя
await db.unban_user(target_user_id)
# Логируем действие
admin_username: str = message.from_user.username or message.from_user.full_name or f"ID{message.from_user.id}"
logger.info(f"🔓 Админ @{admin_username} разблокировал пользователя @{target_username} (ID: {target_user_id})")
# Экранируем специальные символы
safe_username: str = escape(target_username)
response_text = f"✅ Пользователь {safe_username} (ID: {target_user_id}) разблокирован!"
# Пытаемся разбанить в чате
if message.chat.type in ["group", "supergroup"]:
try:
await message.bot.unban_chat_member(
chat_id=message.chat.id,
user_id=target_user_id
)
response_text += "\n👥 Пользователь может вернуться в чат."
except Exception as e:
logger.warning(f"Не удалось разблокировать пользователя в чате: {e}")
await message.answer(
text=response_text,
parse_mode=None
)
except Exception as e:
logger.error(f"❌ Ошибка при разблокировке пользователя: {e}")
await message.answer("Не удалось разблокировать пользователя")
@router.message(Command("banned_list", ignore_case=True), IsAdmin())
async def banned_list_cmd(message: Message, state: FSMContext) -> None:
"""
Команда /banned_list для просмотра списка забаненных пользователей.
"""
await status_clear(update=message, state=state)
try:
# Получаем всех пользователей включая забаненных
all_users: list[User] = await db.get_all_users(include_banned=True)
# Фильтруем только забаненных
banned_users: list[User] = [user for user in all_users if getattr(user, 'status', None) == "banned"]
if not banned_users:
await message.answer("📭 Список забаненных пользователей пуст")
return
# Формируем сообщение со списком
banned_list: str = "🚫 Заблокированные пользователи:\n\n"
for user in banned_users[:50]: # Ограничиваем вывод
username: str = f"@{user.username}" if getattr(user, 'username', None) else getattr(user, 'full_name',
'Неизвестно')
# Экранируем специальные символы
safe_username = escape(username)
user_id = getattr(user, 'id', 'N/A')
banned_list += f"{safe_username} (ID: {user_id})\n"
if len(banned_users) > 50:
banned_list += f"\n... и еще {len(banned_users) - 50} пользователей"
await message.answer(banned_list, parse_mode=None)
except Exception as e:
logger.error(f"❌ Ошибка при получении списка забаненных: {e}")
await message.answer("Не удалось получить список забаненных пользователей")

View File

@@ -0,0 +1,277 @@
from aiogram import Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, User
from html import escape
from bot import bot
from bot.filters import IsAdmin
from bot.utils import status_clear
from configs import COMMANDS
# Настройки роутера
__all__ = ("router",)
from middleware import logger
CMD: str = "kick"
router: Router = Router(name=f"{CMD}_cmd_router")
@router.message(Command(*COMMANDS[CMD], ignore_case=True), IsAdmin())
async def kick_user_cmd(message: Message, state: FSMContext) -> None:
"""
Команда /kick для кика пользователей из чата.
Использование: /kick <user_id> или ответ на сообщение пользователя + /kick
"""
await status_clear(update=message, state=state)
# Проверяем, что команда используется в группе/супергруппе
if message.chat.type not in ["group", "supergroup"]:
await message.answer("❌ Эта команда работает только в группах и супергруппах!")
return
# Проверяем есть ли ответ на сообщение
if message.reply_to_message:
# Кик по ответу на сообщение
target_user: User | None = message.reply_to_message.from_user
target_user_id: int = target_user.id
target_username: str = target_user.username or target_user.full_name or f"ID{target_user_id}"
# Кикаем пользователя
success: bool = await _kick_user(target_user_id, target_username, message)
if success:
safe_username: str = escape(target_username)
await message.answer(
text=f"👢 Пользователь {safe_username} (ID: {target_user_id}) кикнут из чата!",
)
else:
await message.answer("Не удалось кикнуть пользователя")
else:
# Кик по ID пользователя
command_parts: list[str] = message.text.split()
if len(command_parts) < 2:
await message.answer(
" Использование команды:\n"
"• Ответьте на сообщение пользователя командой /kick\n"
"• Или укажите ID: /kick <user_id>"
)
return
try:
target_user_id: int = int(command_parts[1])
success: bool = await _kick_user(target_user_id, f"ID{target_user_id}", message)
if success:
await message.answer(
text=f"👢 Пользователь (ID: {target_user_id}) кикнут из чата!",
parse_mode=None # Отключаем разметку
)
else:
await message.answer("❌ Пользователь не найден или не удалось кикнуть")
except ValueError:
await message.answer("❌ Неверный формат ID пользователя")
async def _kick_user(user_id: int, username: str, message: Message) -> bool:
"""
Внутренняя функция для кика пользователя из чата.
Args:
user_id: ID пользователя для кика
username: Имя пользователя для логов
message: Объект сообщения для контекста
Returns:
bool: Успешно ли кикнут пользователь
"""
try:
# Проверяем, что бот имеет права администратора в чате
bot_member = await bot.get_chat_member(message.chat.id, bot.id)
if not bot_member.can_restrict_members:
await message.answer("У меня нет прав для кика пользователей!")
return False
# Проверяем, что целевой пользователь не является администратором/владельцем
target_member = await bot.get_chat_member(message.chat.id, user_id)
if target_member.status in ["creator", "administrator"]:
await message.answer("❌ Нельзя кикнуть администратора или создателя чата!")
return False
# Проверяем, что отправитель команды имеет права администратора
admin_member = await bot.get_chat_member(message.chat.id, message.from_user.id)
if admin_member.status not in ["creator", "administrator"]:
await message.answer("У вас нет прав для кика пользователей!")
return False
# Кикаем пользователя из чата
await bot.ban_chat_member(
chat_id=message.chat.id,
user_id=user_id,
revoke_messages=False # Не удаляем сообщения пользователя
)
# Сразу разбаниваем, чтобы пользователь мог вернуться по приглашению
await bot.unban_chat_member(
chat_id=message.chat.id,
user_id=user_id
)
# Логируем действие
admin_username = message.from_user.username or message.from_user.full_name
logger.info(
f"👢 Админ @{admin_username} кикнул пользователя @{username} (ID: {user_id}) из чата {message.chat.title}")
return True
except Exception as e:
logger.error(f"❌ Ошибка при кике пользователя {user_id}: {e}")
await message.answer(f"❌ Ошибка при кике пользователя: {str(e)}")
return False
@router.message(Command("kick_ban", ignore_case=True), IsAdmin())
async def kick_ban_user_cmd(message: Message, state: FSMContext) -> None:
"""
Команда /kick_ban для кика пользователя с удалением сообщений.
Использование: /kick_ban <user_id> или ответ на сообщение пользователя + /kick_ban
"""
await status_clear(update=message, state=state)
# Проверяем, что команда используется в группе/супергруппе
if message.chat.type not in ["group", "supergroup"]:
await message.answer("❌ Эта команда работает только в группах и супергруппах!")
return
# Проверяем есть ли ответ на сообщение
if message.reply_to_message:
# Кик по ответу на сообщение
target_user: User | None = message.reply_to_message.from_user
target_user_id: int = target_user.id
target_username: str = target_user.username or target_user.full_name or f"ID{target_user_id}"
# Кикаем пользователя с удалением сообщений
success: bool = await _kick_ban_user(target_user_id, target_username, message)
if success:
safe_username: str = escape(target_username)
await message.answer(
text=f"💥 Пользователь {safe_username} (ID: {target_user_id}) кикнут с удалением сообщений!",
parse_mode=None # Отключаем разметку
)
else:
await message.answer("Не удалось кикнуть пользователя")
else:
# Кик по ID пользователя
command_parts: list[str] = message.text.split()
if len(command_parts) < 2:
await message.answer(
" Использование команды:\n"
"• Ответьте на сообщение пользователя командой /kick_ban\n"
"• Или укажите ID: /kick_ban <user_id>"
)
return
try:
target_user_id: int = int(command_parts[1])
success: bool = await _kick_ban_user(target_user_id, f"ID{target_user_id}", message)
if success:
await message.answer(
text=f"💥 Пользователь (ID: {target_user_id}) кикнут с удалением сообщений!",
parse_mode=None # Отключаем разметку
)
else:
await message.answer("❌ Пользователь не найден или не удалось кикнуть")
except ValueError:
await message.answer("❌ Неверный формат ID пользователя")
async def _kick_ban_user(user_id: int, username: str, message: Message) -> bool:
"""
Внутренняя функция для кика пользователя с удалением сообщений.
Args:
user_id: ID пользователя для кика
username: Имя пользователя для логов
message: Объект сообщения для контекста
Returns:
bool: Успешно ли кикнут пользователь
"""
try:
# Проверяем, что бот имеет права администратора в чате
bot_member = await bot.get_chat_member(message.chat.id, bot.id)
if not bot_member.can_restrict_members:
await message.answer("У меня нет прав для кика пользователей!")
return False
# Проверяем, что целевой пользователь не является администратором/владельцем
target_member = await bot.get_chat_member(message.chat.id, user_id)
if target_member.status in ["creator", "administrator"]:
await message.answer("❌ Нельзя кикнуть администратора или создателя чата!")
return False
# Проверяем, что отправитель команды имеет права администратора
admin_member = await bot.get_chat_member(message.chat.id, message.from_user.id)
if admin_member.status not in ["creator", "administrator"]:
await message.answer("У вас нет прав для кика пользователей!")
return False
# Кикаем пользователя из чата с удалением сообщений
await bot.ban_chat_member(
chat_id=message.chat.id,
user_id=user_id,
revoke_messages=True # Удаляем сообщения пользователя
)
# Сразу разбаниваем, чтобы пользователь мог вернуться по приглашению
await bot.unban_chat_member(
chat_id=message.chat.id,
user_id=user_id
)
# Логируем действие
admin_username = message.from_user.username or message.from_user.full_name
logger.info(
f"💥 Админ @{admin_username} кикнул пользователя @{username} (ID: {user_id}) из чата {message.chat.title} с удалением сообщений")
return True
except Exception as e:
logger.error(f"❌ Ошибка при кике пользователя {user_id} с удалением сообщений: {e}")
await message.answer(f"❌ Ошибка при кике пользователя: {str(e)}")
return False
@router.message(Command("kick_list", ignore_case=True), IsAdmin())
async def kick_help_cmd(message: Message, state: FSMContext) -> None:
"""
Команда /kick_list для показа справки по командам кика.
"""
await status_clear(update=message, state=state)
help_text = """
🤖 **Команды модерации:**
**👢 /kick** - Кикнуть пользователя (может вернуться по приглашению)
• Ответьте на сообщение пользователя с командой /kick
• Или используйте: /kick <user_id>
**💥 /kick_ban** - Кикнуть пользователя с удалением сообщений
• Ответьте на сообщение пользователя с командой /kick_ban
• Или используйте: /kick_ban <user_id>
**🚫 /ban** - Полностью забанить пользователя
**🔓 /unban** - Разбанить пользователя
**📋 /banned_list** - Список забаненных
⚠️ *Команды работают только в группах и требуют прав администратора*
"""
await message.answer(help_text, parse_mode=None)

View File

@@ -0,0 +1,77 @@
from asyncio import create_task
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery
from bot.core.bots import BotInfo, bot
from bot.filters import IsOwner
from bot.templates import msg
from bot.utils import status_clear
from bot.utils.auto_delete import auto_delete_message
from configs import COMMANDS
__all__ = ("router",)
CMD: str = "pin".lower()
router: Router = Router(name=f"{CMD}_cmd_router")
@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True), IsOwner())
async def pin_cmd(message: Message, state: FSMContext) -> None:
"""
Обработчик команды /pin для закрепления последнего сообщения или ответа.
"""
# Если есть reply → закрепляем его, иначе закрепляем предыдущее сообщение
if message.reply_to_message:
target_message_id = message.reply_to_message.message_id
else:
# Закрепляем предыдущее сообщение (команда - 1)
target_message_id = message.message_id - 1
try:
await bot.pin_chat_message(
chat_id=message.chat.id,
message_id=target_message_id,
disable_notification=False
)
# Автоудаление через 7 суток (удаляем закрепленное сообщение)
create_task(
auto_delete_message(
chat_id=message.chat.id,
message_id=target_message_id,
delay=604800
)
)
await msg(update=message, text="✅ Сообщение успешно закреплено", state=state)
except Exception as e:
await msg(update=message, text=f"❌ Ошибка закрепления: {e}", state=state)
@router.callback_query(F.data.casefold().isin(COMMANDS[CMD]), IsOwner())
async def pin_callback(callback: CallbackQuery, state: FSMContext) -> None:
"""
Обработчик кнопки с callback_data="pin".
"""
await status_clear(update=callback.message, state=state)
try:
await bot.pin_chat_message(
chat_id=callback.message.chat.id,
message_id=callback.message.message_id,
disable_notification=False
)
create_task(
auto_delete_message(
chat_id=callback.message.chat.id,
message_id=callback.message.message_id,
delay=604800
)
)
await callback.answer("✅ Сообщение закреплено")
except Exception as e:
await callback.answer(f"❌ Ошибка: {e}", show_alert=True)

View File

@@ -0,0 +1,51 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.utils.i18n import gettext as _
from bot.templates import msg_photo
from bot.utils.interesting_facts import interesting_fact
from bot.core.bots import BotInfo
from configs import COMMANDS, RpValue
# Настройки экспорта и роутера
__all__ = ("router",)
CMD: str = "settings".lower()
router: Router = Router(name=f"{CMD}_cmd_router")
@router.callback_query(F.data.lower() == CMD)
@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True))
async def start_cmd(message: Message | CallbackQuery, state: FSMContext) -> None:
"""Обработчик команды /start"""
await state.clear()
# Создание инлайн-клавиатуры
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
ikb.row(InlineKeyboardButton(text="Инфо-канал🗂", url=CustomConfig.INFO_URL))
ikb.row(InlineKeyboardButton(text="Вступление🚀", callback_data='new'),
InlineKeyboardButton(text="Анкета📖", callback_data='anketa'))
ikb.row(InlineKeyboardButton(text="Связь с администрацией🌐", callback_data='admin'))
# Формируем приветственное сообщение
text: str = _(
"""Добро пожаловать, <a href="{url}">{name}</a>!
Я ваш искусственный помощник по ролевой - <b>{rp_name}</b>!
Моя цель — помочь вам сориентироваться и сделать ваше вступление куда проще!
Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на клавиатуре!
Интересный факт:
<blockquote>{fact}</blockquote>
"""
).format(
url=message.from_user.url if message.from_user else "",
name=message.from_user.first_name if message.from_user else "пользователь",
rp_name=RpValue.RP_NAME,
fact=interesting_fact(),
)
# Отправляем сообщение
await msg_photo(update=message, text=text, file=f'assets/{CMD}.jpg', markup=ikb)

View File

@@ -0,0 +1,19 @@
from aiogram import Router
from .set_description_cmd import router as set_description_cmd_router
from .set_name_cmd import router as set_name_cmd_router
from .set_widget_cmd import router as set_widget_cmd_router
from .settings_cmd import router as settings_cmd_router
# Настройка экспорта и роутера
__all__ = ("router",)
router: Router = Router(name=__name__)
# Подключение роутеров
router.include_routers(
settings_cmd_router,
set_name_cmd_router,
set_description_cmd_router,
set_widget_cmd_router,
)

View File

@@ -0,0 +1,173 @@
from aiogram import Router, F, Bot
from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
from aiogram.filters import Command, CommandObject
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import StatesGroup, State
from aiogram.types import Message, CallbackQuery
from aiogram.utils.i18n import gettext as _
from bot.core.bots import BotInfo
from bot.filters import IsOwner
from bot.handlers.commands.settings.settings_cmd import settings_keyboard
from bot.templates import msg
from bot.utils import format_retry_time, status_clear
from configs import COMMANDS
from middleware.loggers import logger
__all__ = ("router",)
# Название команды
CMD: str = "set_description".lower()
# Роутер для обработки команды /set_description
router: Router = Router(name=f"{CMD}_cmd_router")
class SetBotDescriptionForm(StatesGroup):
"""Состояния FSM для изменения короткого описания бота."""
new_description: State = State()
async def handle_set_bot_description(
description: str,
message: Message | CallbackQuery,
state: FSMContext,
bot: Bot
) -> None:
"""
Установка короткого описания (short description) бота с обработкой FSM и ошибок API.
Args:
description (str): Новый текст описания (до 120 символов).
message (Message | CallbackQuery): Сообщение или callback-запрос.
state (FSMContext): Контекст FSM.
bot (Bot): Экземпляр бота.
"""
# Проверка ограничения Telegram
if len(description) > 120:
await msg(
update=message,
text=_("❌ Короткое описание бота должно быть не более 120 символов. Текущая длина: {length}").format(
length=len(description)
),
markup=settings_keyboard(),
state=state
)
return
try:
# Установка нового короткого описания
await bot.set_my_short_description(short_description=description)
# Сохраняем текущее значение в BotInfo
BotInfo.short_description = description
# Сбрасываем состояние FSM
await state.clear()
# Отправляем сообщение об успехе
await msg(
update=message,
text=_("✅ Короткое описание бота успешно изменено на: <b>{description}</b>").format(
description=description
),
markup=settings_keyboard(),
state=state
)
logger.info(f"Короткое описание бота изменено на: {description}")
except TelegramRetryAfter as e:
retry_text: str = format_retry_time(e.retry_after)
logger.warning(f"Превышен лимит запросов при смене short description. Попробуйте через {retry_text}")
await msg(
update=message,
text=_("⚠️ Слишком частая смена короткого описания!\nПопробуйте снова через: <b>{retry_text}</b>").format(
retry_text=retry_text
),
markup=settings_keyboard(),
state=state
)
except TelegramAPIError as e:
logger.error(f"Ошибка Telegram API при изменении короткого описания: {e}")
await msg(
update=message,
text=_("❌ Ошибка Telegram API при изменении короткого описания: <pre>{error}</pre>").format(error=str(e)),
markup=settings_keyboard(),
state=state
)
except Exception as e:
logger.error(f"Непредвиденная ошибка при изменении короткого описания: {e}")
await msg(
update=message,
text=_("❌ Непредвиденная ошибка при изменении короткого описания: <pre>{error}</pre>").format(error=str(e)),
markup=settings_keyboard(),
state=state
)
@router.callback_query(F.data.lower() == CMD, IsOwner())
@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True), IsOwner())
async def settings_cmd(
message: Message | CallbackQuery,
state: FSMContext,
bot: Bot,
command: CommandObject | None = None
) -> None:
"""
Обработчик команды /set_description для короткого описания.
Поддерживает:
1. Немедленное изменение через аргумент (/set_description TEXT).
2. Callback-запрос.
3. FSM-ввод.
"""
current_description: str = BotInfo.description
# Вариант 1: если пользователь передал аргумент к команде
if command and command.args:
description: str = command.args.strip()
if len(description) > 120:
await msg(
update=message,
text=_("❌ Короткое описание не должно превышать 120 символов. Текущая длина: {length}").format(
length=len(description)
),
markup=settings_keyboard(),
state=state
)
return
await handle_set_bot_description(description, message, state, bot)
return
# Вариант 2: без аргумента → включаем FSM
await status_clear(update=message, state=state)
text: str = _(
"📝 <b>Смена короткого описания бота</b>\n\n"
"Текущее короткое описание: <i>{current}</i>\n\n"
"Введите новое короткое описание (максимум 120 символов):"
).format(current=current_description)
await msg(update=message, text=text, markup=settings_keyboard(), state=state)
await state.set_state(SetBotDescriptionForm.new_description)
@router.message(SetBotDescriptionForm.new_description, IsOwner())
async def process_new_bot_description(
message: Message,
state: FSMContext,
bot: Bot
) -> None:
"""
Обработка ввода нового короткого описания через FSM.
"""
description: str = message.text.strip()
if not description:
await message.answer(_("❌ Пожалуйста, введите корректное короткое описание."))
return
await handle_set_bot_description(description, message, state, bot)

View File

@@ -0,0 +1,157 @@
from aiogram import Router, F, Bot
from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
from aiogram.filters import Command, CommandObject
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import StatesGroup, State
from aiogram.types import Message, CallbackQuery
from aiogram.utils.i18n import gettext as _
from bot.core.bots import BotInfo
from bot.filters import IsOwner
from bot.handlers.commands.settings.settings_cmd import settings_keyboard
from bot.templates import msg
from configs import COMMANDS
from middleware.loggers import logger
__all__ = ("router",)
CMD: str = "set_name".lower()
router: Router = Router(name=f"{CMD}_cmd_router")
class SetNameForm(StatesGroup):
new_name: State = State()
def format_retry_time(retry_after: int) -> str:
"""Форматирование времени повторной попытки в читаемом виде"""
hours, remainder = divmod(retry_after, 3600)
minutes, seconds = divmod(remainder, 60)
if hours > 0:
return f"{hours} часов, {minutes} минут, {seconds} секунд"
elif minutes > 0:
return f"{minutes} минут, {seconds} секунд"
else:
return f"{seconds} секунд"
async def handle_set_name(
new_name: str,
message: Message | CallbackQuery,
state: FSMContext,
bot: Bot
) -> None:
"""
Установка имени бота с проверкой длины, обработкой перегрузки и логированием
"""
if len(new_name) > 64:
await msg(
update=message,
text=_("❌ Имя бота должно быть не более 64 символов. Текущая длина: {length}").format(
length=len(new_name)
),
markup=settings_keyboard(),
state=state
)
return
try:
await bot.set_my_name(new_name)
BotInfo.first_name = new_name
await state.clear()
await msg(
update=message,
text=_("✅ Имя бота успешно изменено на: <b>{new_name}</b>").format(new_name=new_name),
markup=settings_keyboard(),
state=state
)
logger.info(f"Имя бота изменено на: {new_name}")
except TelegramRetryAfter as e:
retry_text: str = format_retry_time(e.retry_after)
logger.warning(f"Превышен контроль перегрузки при смене имени. Попробуйте через {retry_text}")
await msg(
update=message,
text=_("⚠️ Слишком частая смена имени!\nПопробуйте снова через: <b>{retry_text}</b>").format(
retry_text=retry_text
),
markup=settings_keyboard(),
state=state
)
except TelegramAPIError as e:
logger.error(f"Ошибка Telegram API при изменении имени: {e}")
await msg(
update=message,
text=_("❌ Ошибка Telegram API: <pre>{error}</pre>").format(error=str(e)),
markup=settings_keyboard(),
state=state
)
except Exception as e:
logger.error(f"Непредвиденная ошибка при изменении имени: {e}")
await msg(
update=message,
text=_("❌ Непредвиденная ошибка: <pre>{error}</pre>").format(error=str(e)),
markup=settings_keyboard(),
state=state
)
@router.callback_query(F.data.lower() == CMD, IsOwner())
@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True), IsOwner())
async def settings_cmd(
message: Message | CallbackQuery,
state: FSMContext,
bot: Bot,
command: CommandObject | None = None
):
"""
Обработчик команды /set_name с поддержкой:
1. Immediate установки через аргумент команды
2. Callback query
3. FSM ввод
"""
current_name = getattr(BotInfo, "first_name", "") or _("Не установлено")
# Immediate установка через аргумент команды
if command and command.args:
new_name = command.args.strip()
if len(new_name) > 64:
await msg(
update=message,
text=_("❌ Имя не должно превышать 64 символа. Текущая длина: {length}").format(
length=len(new_name)
),
markup=settings_keyboard(),
state=state
)
return
await handle_set_name(new_name, message, state, bot)
return
# Для callback query или пустой команды — показываем текущее имя и запускаем FSM
await state.clear()
if isinstance(message, CallbackQuery):
await message.answer()
text: str = _(
"🤖 <b>Смена имени бота</b>\n\n"
"Текущее имя: <i>{current}</i>\n\n"
"Пожалуйста, введите новое имя для бота (максимум 64 символа):"
).format(current=current_name)
await msg(update=message, text=text, markup=settings_keyboard(), state=state)
await state.set_state(SetNameForm.new_name)
@router.message(SetNameForm.new_name, IsOwner())
async def process_new_name(message: Message, state: FSMContext, bot: Bot):
"""
Обработка ввода нового имени через FSM
"""
new_name: str = message.text.strip()
if not new_name:
await message.answer(_("❌ Пожалуйста, введите корректное имя."))
return
await handle_set_name(new_name, message, state, bot)

View File

@@ -0,0 +1,174 @@
from aiogram import Router, F, Bot
from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
from aiogram.filters import Command, CommandObject
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import StatesGroup, State
from aiogram.types import Message, CallbackQuery
from aiogram.utils.i18n import gettext as _
from bot.core.bots import BotInfo
from bot.filters import IsOwner
from bot.handlers.commands.settings.settings_cmd import settings_keyboard
from bot.templates import msg
from bot.utils import format_retry_time, status_clear
from configs import COMMANDS
from middleware.loggers import logger
__all__ = ("router",)
CMD: str = "set_widget".lower()
router: Router = Router(name=f"{CMD}_cmd_router")
class SetWidgetForm(StatesGroup):
"""Состояния FSM для изменения виджета (описания бота)."""
new_widget: State = State()
async def handle_set_widget(
new_widget: str,
message: Message | CallbackQuery,
state: FSMContext,
bot: Bot
) -> None:
"""
Устанавливает новое значение виджета (описания бота).
Args:
new_widget (str): Новый текст виджета.
message (Message | CallbackQuery): Объект сообщения или callback-запроса.
state (FSMContext): Контекст состояния FSM.
bot (Bot): Экземпляр текущего бота.
"""
# Проверка длины текста (Telegram API ограничивает description до 512 символов)
if len(new_widget) > 512:
await msg(
update=message,
text=_("❌ Виджет бота должен быть не более 512 символов. Текущая длина: {length}").format(
length=len(new_widget)
),
markup=settings_keyboard(),
state=state
)
return
try:
# Устанавливаем описание через Telegram API
await bot.set_my_description(description=new_widget)
# Сохраняем в BotInfo для локального использования
BotInfo.widget = new_widget
# Очищаем состояние FSM
await state.clear()
# Отправляем уведомление пользователю
await msg(
update=message,
text=_("✅ Виджет бота успешно изменён на: <b>{new_widget}</b>").format(
new_widget=new_widget
),
markup=settings_keyboard(),
state=state
)
logger.info(f"Виджет бота изменён на: {new_widget}")
except TelegramRetryAfter as e:
# Если запрос слишком частый
retry_text: str = format_retry_time(e.retry_after)
logger.warning(f"Превышен лимит запросов при смене виджета. Попробуйте через {retry_text}")
await msg(
update=message,
text=_("⚠️ Слишком частая смена виджета!\nПопробуйте снова через: <b>{retry_text}</b>").format(
retry_text=retry_text
),
markup=settings_keyboard(),
state=state
)
except TelegramAPIError as e:
# Ошибка Telegram API
logger.error(f"Ошибка Telegram API при изменении виджета: {e}")
await msg(
update=message,
text=_("❌ Ошибка Telegram API при изменении виджета: <pre>{error}</pre>").format(error=str(e)),
markup=settings_keyboard(),
state=state
)
except Exception as e:
# Непредвиденная ошибка
logger.error(f"Непредвиденная ошибка при изменении виджета: {e}")
await msg(
update=message,
text=_("❌ Непредвиденная ошибка при изменении виджета: <pre>{error}</pre>").format(error=str(e)),
markup=settings_keyboard(),
state=state
)
@router.callback_query(F.data.lower() == CMD, IsOwner())
@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True), IsOwner())
async def settings_cmd(
message: Message | CallbackQuery,
state: FSMContext,
bot: Bot,
command: CommandObject | None = None
) -> None:
"""
Обработчик команды /set_widget.
Поддерживает:
1. Немедленное изменение через аргумент команды (/set_widget TEXT).
2. Callback-запрос.
3. FSM ввод.
"""
# Получаем текущее значение виджета
current_widget: str = BotInfo.short_description
# Вариант 1: пользователь ввёл аргумент сразу (/set_widget TEXT)
if command and command.args:
new_widget: str = command.args.strip()
if len(new_widget) > 512:
await msg(
update=message,
text=_("❌ Виджет не должен превышать 512 символов. Текущая длина: {length}").format(
length=len(new_widget)
),
markup=settings_keyboard(),
state=state
)
return
await handle_set_widget(new_widget, message, state, bot)
return
# Вариант 2: Callback query или пустая команда → запускаем FSM
await status_clear(update=message, state=state)
text: str = _(
"📝 <b>Смена виджета бота</b>\n\n"
"Текущий виджет: <i>{current}</i>\n\n"
"Пожалуйста, введите новый виджет для бота (максимум 512 символов):"
).format(current=current_widget)
await msg(update=message, text=text, markup=settings_keyboard(), state=state)
await state.set_state(SetWidgetForm.new_widget)
@router.message(SetWidgetForm.new_widget, IsOwner())
async def process_new_widget(
message: Message,
state: FSMContext,
bot: Bot
) -> None:
"""
Обрабатывает ввод нового текста виджета через FSM.
"""
new_widget: str = message.text.strip()
# Проверяем, что пользователь что-то ввёл
if not new_widget:
await message.answer(_("❌ Пожалуйста, введите корректный виджет."))
return
await handle_set_widget(new_widget, message, state, bot)

View File

@@ -0,0 +1,48 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.i18n import gettext as _
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot.core.bots import BotInfo
from bot.filters import IsOwner
from bot.templates import msg
from bot.utils import status_clear
from configs import COMMANDS
# Настройки экспорта и роутера
__all__ = ("router", "settings_keyboard",)
CMD: str = "settings".lower()
router: Router = Router(name=f"{CMD}_cmd_router")
def settings_keyboard() -> InlineKeyboardBuilder:
"""Клавиатура настроек"""
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
ikb.row(InlineKeyboardButton(text="🔙 Вернуться", callback_data="settings"))
return ikb
@router.callback_query(F.data.lower() == CMD, IsOwner())
@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True), IsOwner())
async def settings_cmd(message: Message | CallbackQuery, state: FSMContext) -> None:
"""Обработчик команды /settings"""
await status_clear(update=message, state=state)
# Создание инлайн-клавиатуры
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
ikb.row(InlineKeyboardButton(text="Имя бота⚜️", callback_data='set_name'))
ikb.row(InlineKeyboardButton(text="Описание бота📝", callback_data='set_description'))
ikb.row(InlineKeyboardButton(text="Виджет🧩", callback_data='set_widget'))
ikb.row(InlineKeyboardButton(text="Назад◀️", callback_data='menu'))
# Формируем приветственное сообщение
text: str = _("""
⚙️ Настройки
"""
).format(
)
# Отправляем сообщение
await msg(update=message, text=text, markup=ikb, state=state)

View File

@@ -0,0 +1,33 @@
from aiogram import Router
from .start_cmd import router as start_cmd_router
from .listwords import router as listwords_cmd_router
from .word import router as word_cmd_router
from .slience import router as slice_router
from .conflict import router as conflict_router
from .stats import router as stats_router
from .report import router as report_router
from .admins import router as admin_router
from .notifications import router as notifications_router
from .id import router as id_router
from .emoji import router as emoji_router
# Настройка экспорта и роутера
__all__ = ("router",)
router: Router = Router(name=__name__)
# Подключение роутеров
router.include_routers(
notifications_router,
report_router,
admin_router,
start_cmd_router,
listwords_cmd_router,
word_cmd_router,
slice_router,
conflict_router,
stats_router,
id_router,
emoji_router,
)

View File

@@ -0,0 +1,434 @@
"""
Обработчики команд управления администраторами
"""
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot.filters.admin import IsSuperAdmin
from configs import settings, COMMANDS
from database import get_manager
from middleware.loggers import logger
from bot.utils.decorators import log_action
__all__ = ("router",)
router: Router = Router(name="admin_management_router")
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
def parse_user_id(text: str, command: str) -> tuple[bool, str | int]:
"""
Парсит ID пользователя из команды.
Args:
text: Полный текст сообщения
command: Название команды
Returns:
(success, result): result это либо user_id (int), либо текст ошибки (str)
"""
parts = text.split(maxsplit=1)
if len(parts) < 2:
return False, f"❌ Использование: <code>/{command} <ID></code>"
user_id_str = parts[1].strip()
# Валидация ID
try:
user_id = int(user_id_str)
if user_id <= 0:
return False, "❌ ID должен быть положительным числом"
if user_id > 9999999999: # Максимальный Telegram ID
return False, "❌ Некорректный ID пользователя"
return True, user_id
except ValueError:
return False, "❌ ID должен быть числом"
def format_admin_info(user_id: int, username: str | None = None) -> str:
"""Форматирует информацию об админе"""
if username:
return f"<code>{user_id}</code> (@{username})"
return f"<code>{user_id}</code>"
def get_refresh_admins_kb():
"""Клавиатура для обновления списка админов"""
ikb = InlineKeyboardBuilder()
ikb.button(text="🔄 Обновить", callback_data="listadmins:refresh")
ikb.button(text=" Добавить", callback_data="admin:help_add")
ikb.adjust(2)
return ikb.as_markup()
# ================= ДОБАВЛЕНИЕ АДМИНИСТРАТОРА =================
@router.message(Command(*COMMANDS.get("addadmin", ["addadmin"]), prefix=settings.PREFIX, ignore_case=True),
IsSuperAdmin())
@log_action(action_name="ADD_ADMIN", log_args=True)
async def add_admin_cmd(message: Message) -> None:
"""
Добавляет нового администратора бота.
Доступно только владельцам бота (OWNER_ID).
Использование: /addadmin <ID>
Пример: /addadmin 123456789
"""
success, result = parse_user_id(message.text, "addadmin")
if not success:
await message.answer(result, parse_mode="HTML")
return
user_id = result
# Проверка: нельзя добавить самого себя
if user_id == message.from_user.id:
await message.answer(
"⚠️ <b>Вы уже владелец бота</b>\n\n"
"Вам не нужно добавлять себя в администраторы",
parse_mode="HTML"
)
return
# Проверка: нельзя добавить другого владельца
if user_id in settings.OWNER_ID:
await message.answer(
"⚠️ <b>Этот пользователь уже владелец бота</b>\n\n"
"Владельцы имеют полные права автоматически",
parse_mode="HTML"
)
return
manager = get_manager()
try:
# Проверяем, уже админ ли
is_already_admin = await manager.is_admin(user_id)
if is_already_admin:
await message.answer(
f"⚠️ Пользователь {format_admin_info(user_id)} уже является администратором",
parse_mode="HTML"
)
return
# Добавляем администратора
added = await manager.add_admin(
user_id=user_id,
added_by=message.from_user.id
)
if added:
text = (
f"✅ <b>Администратор добавлен</b>\n\n"
f"👤 ID: {format_admin_info(user_id)}\n"
f"👑 Добавил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n"
f"📋 Права администратора:\n"
f"├─ Управление банвордами\n"
f"├─ Просмотр статистики\n"
f"├─ Активация режимов модерации\n"
f"└─ Все команды бота\n\n"
f"⚠️ <i>Не может управлять другими админами</i>\n"
f"Список админов: /listadmins"
)
logger.info(
f"Администратор добавлен: {user_id} (добавил: {message.from_user.id})",
log_type="ADMIN_MGMT"
)
else:
text = "❌ <b>Ошибка добавления администратора</b>\n\nПопробуйте позже"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка добавления администратора: {e}", log_type="ADMIN_MGMT")
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
# ================= УДАЛЕНИЕ АДМИНИСТРАТОРА =================
@router.message(Command(*COMMANDS.get("remadmin", ["remadmin"]), prefix=settings.PREFIX, ignore_case=True),
IsSuperAdmin())
@log_action(action_name="REMOVE_ADMIN", log_args=True)
async def remove_admin_cmd(message: Message) -> None:
"""
Удаляет администратора бота.
Доступно только владельцам бота (OWNER_ID).
Использование: /remadmin <ID>
Пример: /remadmin 123456789
"""
success, result = parse_user_id(message.text, "remadmin")
if not success:
await message.answer(result, parse_mode="HTML")
return
user_id = result
# Проверка: нельзя удалить владельца
if user_id in settings.OWNER_ID:
await message.answer(
"⚠️ <b>Нельзя удалить владельца</b>\n\n"
"Владельцы имеют права постоянно",
parse_mode="HTML"
)
return
# Проверка: нельзя удалить самого себя (если вы владелец)
if user_id == message.from_user.id:
await message.answer(
"⚠️ <b>Нельзя удалить самого себя</b>",
parse_mode="HTML"
)
return
manager = get_manager()
try:
# Проверяем, является ли администратором
is_admin = await manager.is_admin(user_id)
if not is_admin:
await message.answer(
f"⚠️ Пользователь {format_admin_info(user_id)} не является администратором",
parse_mode="HTML"
)
return
# Удаляем администратора
removed = await manager.remove_admin(user_id=user_id)
if removed:
text = (
f"🗑 <b>Администратор удалён</b>\n\n"
f"👤 ID: {format_admin_info(user_id)}\n"
f"👑 Удалил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n"
f"⚠️ <i>Пользователь больше не имеет доступа к командам бота</i>"
)
logger.info(
f"Администратор удалён: {user_id} (удалил: {message.from_user.id})",
log_type="ADMIN_MGMT"
)
else:
text = "❌ <b>Ошибка удаления администратора</b>\n\nПопробуйте позже"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка удаления администратора: {e}", log_type="ADMIN_MGMT")
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
# ================= СПИСОК АДМИНИСТРАТОРОВ =================
@router.callback_query(F.data == "listadmins:refresh")
@router.message(Command(*COMMANDS.get("listadmins", ["listadmins"]), prefix=settings.PREFIX, ignore_case=True),
IsSuperAdmin())
@log_action(action_name="LIST_ADMINS")
async def list_admins_cmd(update: Message | CallbackQuery) -> None:
"""
Показывает список всех администраторов бота.
Доступно только владельцам бота (OWNER_ID).
Использование: /listadmins
"""
# Определяем тип update
if isinstance(update, CallbackQuery):
message = update.message
is_callback = True
else:
message = update
is_callback = False
manager = get_manager()
try:
# Получаем всех админов из БД
db_admins = await manager.repo.get_admins()
# Получаем статистику
stats = await manager.get_stats()
# === ФОРМИРУЕМ ВЫВОД ===
output = "👥 <b>СПИСОК АДМИНИСТРАТОРОВ</b>\n\n"
# Владельцы (OWNER_ID)
output += "👑 <b>Владельцы бота</b> (полные права):\n"
for owner_id in settings.OWNER_ID:
output += f"├─ <code>{owner_id}</code>\n"
output += "\n"
# Администраторы из БД
if db_admins:
output += f"⚙️ <b>Администраторы</b> ({len(db_admins)}):\n"
for admin_id in sorted(db_admins):
output += f"├─ <code>{admin_id}</code>\n"
output += "\n"
output += "📋 <b>Права администраторов:</b>\n"
output += "├─ Управление банвордами\n"
output += "├─ Просмотр статистики\n"
output += "├─ Активация режимов модерации\n"
output += "└─ Все команды бота (кроме управления админами)\n\n"
else:
output += "⚙️ <b>Администраторы:</b>\n"
output += "└─ <i>Нет дополнительных администраторов</i>\n\n"
# Общая статистика
total_admins = len(settings.OWNER_ID) + len(db_admins)
output += f"📊 <b>Итого:</b> {total_admins} администратор(ов)\n\n"
# Команды управления
output += "🔧 <b>Управление:</b>\n"
output += "• /addadmin <code>ID</code> — добавить админа\n"
output += "• /remadmin <code>ID</code> — удалить админа\n\n"
output += "💡 <i>Только владельцы могут управлять администраторами</i>"
# Клавиатура
keyboard = get_refresh_admins_kb()
# Отправка
if is_callback:
await message.edit_text(
text=output,
parse_mode="HTML",
reply_markup=keyboard
)
await update.answer("✅ Список обновлён")
else:
await message.answer(
text=output,
parse_mode="HTML",
reply_markup=keyboard
)
except Exception as e:
logger.error(f"Ошибка получения списка администраторов: {e}", log_type="ADMIN_MGMT")
error_text = "❌ <b>Ошибка загрузки списка</b>\n\nПопробуйте позже"
if is_callback:
await update.answer("❌ Ошибка загрузки", show_alert=True)
else:
await message.answer(error_text, parse_mode="HTML")
# ================= ВСПОМОГАТЕЛЬНЫЕ CALLBACK =================
@router.callback_query(F.data == "admin:help_add")
async def admin_help_add_callback(callback: CallbackQuery) -> None:
"""Показывает помощь по добавлению админа"""
text = (
" <b>Как добавить администратора?</b>\n\n"
"1⃣ Узнайте Telegram ID пользователя\n"
" • Используйте бота @userinfobot\n"
" • Или попросите пользователя написать /start\n\n"
"2⃣ Выполните команду:\n"
" <code>/addadmin ID</code>\n\n"
"Пример:\n"
"<code>/addadmin 123456789</code>"
)
await callback.answer()
await callback.message.answer(text, parse_mode="HTML")
@router.message(Command(*COMMANDS.get("adminhelp", ["adminhelp"]), prefix=settings.PREFIX, ignore_case=True),
IsSuperAdmin())
async def admin_help_cmd(message: Message) -> None:
"""
Показывает подробную справку по управлению администраторами.
Использование: /adminhelp
"""
text = (
"👥 <b>УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ</b>\n\n"
"🔐 <b>Уровни доступа:</b>\n\n"
"👑 <b>Владельцы</b> (OWNER_ID):\n"
"├─ Все права администратора\n"
"├─ Управление другими админами\n"
"└─ Указываются в конфигурации\n\n"
"⚙️ <b>Администраторы:</b>\n"
"├─ Управление банвордами\n"
"├─ Просмотр статистики\n"
"├─ Активация режимов модерации\n"
"└─ НЕ могут управлять админами\n\n"
"📝 <b>Команды:</b>\n"
"• /listadmins — список всех админов\n"
"• /addadmin <code>ID</code> — добавить админа\n"
"• /remadmin <code>ID</code> — удалить админа\n\n"
"💡 <b>Как узнать ID пользователя?</b>\n"
"• Используйте бота @userinfobot\n"
"• Попросите пользователя написать боту\n"
"• ID отображается в логах бота\n\n"
"⚠️ <b>Важно:</b>\n"
"├─ Нельзя удалить владельца\n"
"├─ Нельзя удалить самого себя\n"
"└─ Все действия логируются"
)
await message.answer(text, parse_mode="HTML")
@router.message(Command(*COMMANDS.get("checkadmin", ["checkadmin"]), prefix=settings.PREFIX, ignore_case=True),
IsSuperAdmin())
@log_action(action_name="CHECK_ADMIN")
async def check_admin_cmd(message: Message) -> None:
"""
Проверяет, является ли пользователь администратором.
Использование: /checkadmin <ID>
"""
success, result = parse_user_id(message.text, "checkadmin")
if not success:
await message.answer(result, parse_mode="HTML")
return
user_id = result
manager = get_manager()
try:
# Проверяем статус
is_owner = user_id in settings.OWNER_ID
is_db_admin = await manager.is_admin(user_id)
text = f"🔍 <b>Проверка пользователя</b>\n\n"
text += f"👤 ID: <code>{user_id}</code>\n\n"
if is_owner:
text += "👑 Статус: <b>Владелец бота</b>\n"
text += "✅ Полные права администратора\n"
text += "✅ Может управлять админами"
elif is_db_admin:
text += "⚙️ Статус: <b>Администратор</b>\n"
text += "✅ Доступ к командам бота\n"
text += "Не может управлять админами"
else:
text += "👤 Статус: <b>Обычный пользователь</b>\n"
text += "❌ Нет прав администратора\n\n"
text += f"Добавить в админы: <code>/addadmin {user_id}</code>"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка проверки администратора: {e}", log_type="ADMIN_MGMT")
await message.answer("❌ <b>Ошибка проверки</b>", parse_mode="HTML")

View File

@@ -0,0 +1,435 @@
"""
Обработчики команд режима антиконфликта
"""
from datetime import datetime
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import Message
from bot.filters.admin import IsAdmin
from configs import settings, COMMANDS
from database import get_manager
from database.models import BanWordType
from middleware.loggers import logger
from bot.utils.decorators import log_action
__all__ = ("router",)
router: Router = Router(name="conflict_mode_router")
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
def parse_conflict_args(text: str, command: str, need_minutes: bool = False) -> tuple[bool, str | list]:
"""
Парсит аргументы команды для конфликтного режима.
Args:
text: Полный текст сообщения
command: Название команды
need_minutes: Требуется ли параметр минут
Returns:
(success, result): result это либо список аргументов, либо текст ошибки
"""
parts = text.split(maxsplit=2 if need_minutes else 1)
min_args = 1 if need_minutes else 1
if len(parts) < min_args + 1:
if need_minutes:
return False, f"❌ Использование: <code>/{command} [минуты]</code>"
else:
return False, f"❌ Использование: <code>/{command} [слово]</code>"
args = parts[1:]
# Валидация слова
if not need_minutes:
if len(args[0]) < 2:
return False, "❌ Слово должно содержать минимум 2 символа"
if len(args[0]) > 100:
return False, "❌ Слово слишком длинное (максимум 100 символов)"
return True, args
def format_time_str(minutes: int) -> str:
"""Форматирует время в читабельный формат"""
if minutes < 60:
return f"{minutes} мин"
elif minutes < 1440:
hours = minutes // 60
mins = minutes % 60
return f"{hours}ч {mins}м" if mins else f"{hours}ч"
else:
days = minutes // 1440
hours = (minutes % 1440) // 60
return f"{days}д {hours}ч" if hours else f"{days}д"
def format_datetime(dt: datetime) -> str:
"""Форматирует datetime в читабельный формат"""
return dt.strftime("%d.%m.%Y %H:%M:%S")
# ================= ДОБАВЛЕНИЕ КОНФЛИКТНЫХ СЛОВ =================
@router.message(
Command(*COMMANDS.get("addconflictword", ["addconflictword"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="ADD_CONFLICT_WORD", log_args=True)
async def add_conflict_word_cmd(message: Message) -> None:
"""
Добавляет конфликтное слово-подстроку.
Конфликтные слова работают только в режиме /stopconflict.
Использование: /addconflictword <слово>
"""
success, result = parse_conflict_args(message.text, "addconflictword", need_minutes=False)
if not success:
await message.answer(result, parse_mode="HTML")
return
word = result[0].lower().strip()
manager = get_manager()
try:
added = await manager.add_banword(
word=word,
word_type=BanWordType.CONFLICT_SUBSTRING,
added_by=message.from_user.id,
reason="Конфликтное слово"
)
if added:
text = (
f"✅ <b>Конфликтное слово добавлено</b>\n\n"
f"📝 Слово: <code>{word}</code>\n"
f"🔍 Тип: подстрока\n\n"
f"⚔️ <i>Будет работать только в режиме антиконфликта</i>\n"
f"Активируйте: <code>/stopconflict [минуты]</code>"
)
else:
text = f"⚠️ Конфликтное слово <code>{word}</code> уже существует"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка добавления конфликтного слова: {e}", log_type="CONFLICT")
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
@router.message(
Command(*COMMANDS.get("addconflictlemma", ["addconflictlemma"]), prefix=settings.PREFIX, ignore_case=True),
IsAdmin())
@log_action(action_name="ADD_CONFLICT_LEMMA", log_args=True)
async def add_conflict_lemma_cmd(message: Message) -> None:
"""
Добавляет конфликтную лемму.
Конфликтные леммы работают только в режиме /stopconflict.
Использование: /addconflictlemma <слово>
"""
success, result = parse_conflict_args(message.text, "addconflictlemma", need_minutes=False)
if not success:
await message.answer(result, parse_mode="HTML")
return
word = result[0].lower().strip()
manager = get_manager()
try:
added = await manager.add_banword(
word=word,
word_type=BanWordType.CONFLICT_LEMMA,
added_by=message.from_user.id,
reason="Конфликтная лемма"
)
if added:
text = (
f"✅ <b>Конфликтная лемма добавлена</b>\n\n"
f"🔤 Слово: <code>{word}</code>\n"
f"🔍 Тип: лемма (все формы слова)\n\n"
f"⚔️ <i>Будет работать только в режиме антиконфликта</i>\n"
f"Активируйте: <code>/stopconflict [минуты]</code>"
)
else:
text = f"⚠️ Конфликтная лемма <code>{word}</code> уже существует"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка добавления конфликтной леммы: {e}", log_type="CONFLICT")
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
# ================= УДАЛЕНИЕ КОНФЛИКТНЫХ СЛОВ =================
@router.message(
Command(*COMMANDS.get("remconflictword", ["remconflictword"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="REMOVE_CONFLICT_WORD", log_args=True)
async def remove_conflict_word_cmd(message: Message) -> None:
"""
Удаляет конфликтное слово-подстроку.
Использование: /remconflictword <слово>
"""
success, result = parse_conflict_args(message.text, "remconflictword", need_minutes=False)
if not success:
await message.answer(result, parse_mode="HTML")
return
word = result[0].lower().strip()
manager = get_manager()
try:
removed = await manager.remove_banword(
word=word,
word_type=BanWordType.CONFLICT_SUBSTRING
)
if removed:
text = f"🗑 <b>Конфликтное слово удалено</b>\n\n📝 Слово: <code>{word}</code>"
else:
text = f"⚠️ Конфликтное слово <code>{word}</code> не найдено"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка удаления конфликтного слова: {e}", log_type="CONFLICT")
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
@router.message(
Command(*COMMANDS.get("remconflictlemma", ["remconflictlemma"]), prefix=settings.PREFIX, ignore_case=True),
IsAdmin())
@log_action(action_name="REMOVE_CONFLICT_LEMMA", log_args=True)
async def remove_conflict_lemma_cmd(message: Message) -> None:
"""
Удаляет конфликтную лемму.
Использование: /remconflictlemma <слово>
"""
success, result = parse_conflict_args(message.text, "remconflictlemma", need_minutes=False)
if not success:
await message.answer(result, parse_mode="HTML")
return
word = result[0].lower().strip()
manager = get_manager()
try:
removed = await manager.remove_banword(
word=word,
word_type=BanWordType.CONFLICT_LEMMA
)
if removed:
text = f"🗑 <b>Конфликтная лемма удалена</b>\n\n🔤 Слово: <code>{word}</code>"
else:
text = f"⚠️ Конфликтная лемма <code>{word}</code> не найдена"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка удаления конфликтной леммы: {e}", log_type="CONFLICT")
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
# ================= УПРАВЛЕНИЕ РЕЖИМОМ АНТИКОНФЛИКТА =================
@router.message(Command(*COMMANDS.get("stopconflict", ["stopconflict"]), prefix=settings.PREFIX, ignore_case=True),
IsAdmin())
@log_action(action_name="START_CONFLICT_MODE", log_args=True)
async def start_conflict_mode_cmd(message: Message) -> None:
"""
Активирует режим антиконфликта на указанное время.
В этом режиме работают только конфликтные слова/леммы.
Обычные банворды временно отключаются.
Использование: /stopconflict <минуты>
Пример: /stopconflict 30
"""
success, result = parse_conflict_args(message.text, "stopconflict", need_minutes=True)
if not success:
await message.answer(result, parse_mode="HTML")
return
# Валидация минут
try:
minutes = int(result[0])
if minutes < 1 or minutes > 10080: # Максимум неделя
await message.answer(
"❌ Время должно быть от 1 минуты до 10080 минут (7 дней)",
parse_mode="HTML"
)
return
except ValueError:
await message.answer("❌ Неверный формат времени. Укажите число минут", parse_mode="HTML")
return
manager = get_manager()
try:
# Получаем статистику конфликтных слов
data = await manager.get_all_words_list()
conflict_words_count = len(data.get('conflict_substring', set()))
conflict_lemmas_count = len(data.get('conflict_lemma', set()))
total_conflict = conflict_words_count + conflict_lemmas_count
if total_conflict == 0:
await message.answer(
"⚠️ <b>Нет конфликтных слов</b>\n\n"
"Сначала добавьте конфликтные слова:\n"
"• <code>/addconflictword [слово]</code>\n"
"• <code>/addconflictlemma [слово]</code>",
parse_mode="HTML"
)
return
# Активируем режим
expires_at = await manager.set_conflict_mode(minutes)
time_str = format_time_str(minutes)
expires_str = format_datetime(expires_at)
text = (
f"⚔️ <b>РЕЖИМ АНТИКОНФЛИКТА АКТИВИРОВАН</b>\n\n"
f"⏱ Длительность: {time_str}\n"
f"🕐 Окончание: {expires_str}\n\n"
f"📊 Активные правила:\n"
f"├─ Конфликтные слова: <code>{conflict_words_count}</code>\n"
f"└─ Конфликтные леммы: <code>{conflict_lemmas_count}</code>\n\n"
f"⚠️ <i>Обычные банворды временно отключены</i>\n"
f"Отключить режим: /unstopconflict"
)
await message.answer(text, parse_mode="HTML")
logger.info(
f"Режим антиконфликта активирован на {minutes} мин "
f"(конфликтных правил: {total_conflict})",
log_type="CONFLICT"
)
except Exception as e:
logger.error(f"Ошибка активации режима антиконфликта: {e}", log_type="CONFLICT")
await message.answer("❌ <b>Ошибка активации режима</b>\n\nПопробуйте позже", parse_mode="HTML")
@router.message(Command(*COMMANDS.get("unstopconflict", ["unstopconflict"]), prefix=settings.PREFIX, ignore_case=True),
IsAdmin())
@log_action(action_name="STOP_CONFLICT_MODE")
async def stop_conflict_mode_cmd(message: Message) -> None:
"""
Отключает режим антиконфликта.
Использование: /unstopconflict
"""
manager = get_manager()
try:
# Проверяем, активен ли режим
is_active = await manager.is_conflict_active()
if not is_active:
await message.answer(
"⚠️ <b>Режим антиконфликта не активен</b>\n\n"
"Активируйте: <code>/stopconflict [минуты]</code>",
parse_mode="HTML"
)
return
# Отключаем режим
await manager.disable_conflict_mode()
text = (
f"✅ <b>Режим антиконфликта отключен</b>\n\n"
f"🔄 Обычные банворды снова активны\n"
f"⚔️ Конфликтные слова деактивированы"
)
await message.answer(text, parse_mode="HTML")
logger.info("Режим антиконфликта отключён", log_type="CONFLICT")
except Exception as e:
logger.error(f"Ошибка отключения режима антиконфликта: {e}", log_type="CONFLICT")
await message.answer("❌ <b>Ошибка отключения режима</b>\n\nПопробуйте позже", parse_mode="HTML")
@router.message(Command(*COMMANDS.get("conflictstatus", ["conflictstatus"]), prefix=settings.PREFIX, ignore_case=True),
IsAdmin())
@log_action(action_name="CONFLICT_STATUS")
async def conflict_status_cmd(message: Message) -> None:
"""
Показывает статус режима антиконфликта.
Использование: /conflictstatus
"""
manager = get_manager()
try:
# Проверяем активность режима
is_active = await manager.is_conflict_active()
# Получаем статистику
data = await manager.get_all_words_list()
conflict_words_count = len(data.get('conflict_substring', set()))
conflict_lemmas_count = len(data.get('conflict_lemma', set()))
total_conflict = conflict_words_count + conflict_lemmas_count
if is_active:
# Режим активен - показываем детали
conflict_until_str = await manager.repo.get_setting("conflict_until")
conflict_until = float(conflict_until_str)
expires_at = datetime.fromtimestamp(conflict_until)
now = datetime.now()
time_left_seconds = (expires_at - now).total_seconds()
time_left_minutes = int(time_left_seconds / 60)
text = (
f"⚔️ <b>РЕЖИМ АНТИКОНФЛИКТА АКТИВЕН</b>\n\n"
f"⏱ Осталось: {format_time_str(time_left_minutes)}\n"
f"🕐 Окончание: {format_datetime(expires_at)}\n\n"
f"📊 Активные правила:\n"
f"├─ Конфликтные слова: <code>{conflict_words_count}</code>\n"
f"└─ Конфликтные леммы: <code>{conflict_lemmas_count}</code>\n\n"
f"⚠️ <i>Обычные банворды отключены</i>\n"
f"Отключить: /unstopconflict"
)
else:
# Режим не активен
text = (
f"💤 <b>Режим антиконфликта НЕ активен</b>\n\n"
f"📊 Конфликтных правил в базе:\n"
f"├─ Слова: <code>{conflict_words_count}</code>\n"
f"└─ Леммы: <code>{conflict_lemmas_count}</code>\n\n"
)
if total_conflict > 0:
text += f"Активировать: <code>/stopconflict [минуты]</code>"
else:
text += (
f"⚠️ <i>Нет конфликтных слов</i>\n"
f"Добавьте:\n"
f"• <code>/addconflictword [слово]</code>\n"
f"• <code>/addconflictlemma [слово]</code>"
)
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка получения статуса режима: {e}", log_type="CONFLICT")
await message.answer("❌ <b>Ошибка получения статуса</b>", parse_mode="HTML")

View File

@@ -0,0 +1,215 @@
"""
Обработчик команды /emoji для извлечения ID премиум эмодзи
"""
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot.filters.admin import IsAdmin
from configs import settings, COMMANDS
from middleware.loggers import logger
__all__ = ("router",)
router: Router = Router(name="emoji_extractor_router")
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
def extract_custom_emojis(message: Message) -> list[dict]:
"""
Извлекает все кастомные эмодзи из сообщения.
Args:
message: Сообщение для анализа
Returns:
Список словарей с информацией об эмодзи
"""
if not message.entities and not message.caption_entities:
return []
# Определяем текст и entities
text = message.text or message.caption
entities = message.entities or message.caption_entities
if not text or not entities:
return []
custom_emojis = []
for entity in entities:
if entity.type == "custom_emoji":
# Извлекаем символ эмодзи
emoji_char = text[entity.offset:entity.offset + entity.length]
custom_emojis.append({
"char": emoji_char,
"id": entity.custom_emoji_id,
"offset": entity.offset
})
return custom_emojis
def format_emoji_html(emoji_char: str, emoji_id: str) -> str:
"""
Форматирует эмодзи в HTML-тег.
Args:
emoji_char: Символ эмодзи (fallback)
emoji_id: ID кастомного эмодзи
Returns:
HTML-строка
"""
return f'<tg-emoji emoji-id="{emoji_id}">{emoji_char}</tg-emoji>'
def escape_html(text: str) -> str:
"""Экранирует HTML символы"""
return (
text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
# ================= КОМАНДА /EMOJI =================
@router.message(
Command(*COMMANDS.get("emoji", ["emoji"]), prefix=settings.PREFIX, ignore_case=True),
IsAdmin()
)
async def emoji_extractor_cmd(message: Message) -> None:
"""
Извлекает кастомные эмодзи из сообщения.
Доступно только администраторам.
Использование: /emoji (в ответ на сообщение)
"""
# Проверяем, что команда в ответ на сообщение
if not message.reply_to_message:
await message.answer(
"❌ <b>Используйте команду в ответ на сообщение</b>\n\n"
"📝 Как использовать:\n"
"1. Ответьте на сообщение с премиум эмодзи\n"
"2. Напишите <code>/emoji</code>\n\n"
"💡 <i>Бот извлечёт все кастомные эмодзи и покажет HTML-код</i>",
parse_mode="HTML"
)
return
replied_message = message.reply_to_message
# Извлекаем кастомные эмодзи
custom_emojis = extract_custom_emojis(replied_message)
if not custom_emojis:
# Нет кастомных эмодзи
await message.answer(
"⚠️ <b>Кастомные эмодзи не найдены</b>\n\n"
"В этом сообщении нет премиум эмодзи.\n\n"
"💡 <i>Попробуйте ответить на сообщение с анимированными эмодзи</i>",
parse_mode="HTML"
)
return
# === ФОРМИРУЕМ ОТВЕТ ===
output = f"✨ <b>НАЙДЕНО ЭМОДЗИ: {len(custom_emojis)}</b>\n\n"
for idx, emoji_data in enumerate(custom_emojis, 1):
emoji_char = emoji_data["char"]
emoji_id = emoji_data["id"]
output += f"<b>{idx}.</b> Эмодзи: {emoji_char}\n"
output += f"📋 <b>ID:</b> <code>{emoji_id}</code>\n\n"
# HTML-код (экранированный для отображения)
html_code = format_emoji_html(emoji_char, emoji_id)
html_escaped = escape_html(html_code)
output += f"📝 <b>HTML-код:</b>\n"
output += f"<code>{html_escaped}</code>\n\n"
# Пример использования
output += f"🎨 <b>Превью:</b> {html_code}\n"
if idx < len(custom_emojis):
output += "\n" + "" * 30 + "\n\n"
output += "💡 <i>Скопируйте HTML-код и используйте в своих сообщениях</i>"
# Создаём клавиатуру
ikb = InlineKeyboardBuilder()
ikb.button(text="✖️ Закрыть", callback_data="emoji_close")
# Отправляем
try:
await message.answer(
text=output,
parse_mode="HTML",
reply_markup=ikb.as_markup()
)
logger.info(
f"Извлечено {len(custom_emojis)} кастомных эмодзи админом {message.from_user.id}",
log_type="EMOJI_EXTRACT"
)
except Exception as e:
logger.error(f"Ошибка отправки эмодзи: {e}", log_type="ERROR")
await message.answer(
"❌ <b>Ошибка извлечения эмодзи</b>\n\n"
"Попробуйте позже или обратитесь к разработчику.",
parse_mode="HTML"
)
# ================= ОБРАБОТЧИК КНОПКИ ЗАКРЫТИЯ =================
@router.callback_query(lambda c: c.data == "emoji_close", IsAdmin())
async def emoji_close_callback(callback) -> None:
"""Закрывает сообщение с эмодзи"""
try:
await callback.message.delete()
await callback.answer("✅ Закрыто")
except Exception as e:
logger.error(f"Ошибка удаления сообщения с эмодзи: {e}", log_type="ERROR")
await callback.answer("Не удалось удалить", show_alert=True)
# ================= ДОПОЛНИТЕЛЬНАЯ КОМАНДА /EMOJIHELP =================
@router.message(
Command(*COMMANDS.get("emojihelp", ["emojihelp"]), prefix=settings.PREFIX, ignore_case=True),
IsAdmin()
)
async def emoji_help_cmd(message: Message) -> None:
"""
Справка по работе с кастомными эмодзи.
"""
text = (
"🎨 <b>РАБОТА С КАСТОМНЫМИ ЭМОДЗИ</b>\n\n"
"📝 <b>Команда /emoji</b>\n"
"Извлекает ID премиум эмодзи из сообщения\n\n"
"🔧 <b>Как использовать:</b>\n"
"1⃣ Ответьте на сообщение с эмодзи\n"
"2⃣ Напишите <code>/emoji</code>\n"
"3⃣ Скопируйте HTML-код\n\n"
"💻 <b>Формат HTML-кода:</b>\n"
"<code>&lt;tg-emoji emoji-id=\"ID\"&gt;fallback&lt;/tg-emoji&gt;</code>\n\n"
"📌 <b>Пример использования в коде:</b>\n"
"<code>text = 'Привет &lt;tg-emoji emoji-id=\"5368324170671202286\"&gt;👍&lt;/tg-emoji&gt;'\n"
"await message.answer(text, parse_mode=\"HTML\")</code>\n\n"
"⚠️ <b>Важно:</b>\n"
"├─ Используйте <code>parse_mode=\"HTML\"</code>\n"
"├─ Пользователи без Premium видят fallback\n"
"└─ Работает только с кастомными эмодзи\n\n"
"💡 <i>Попробуйте отправить эмодзи и ответить командой /emoji</i>"
)
await message.answer(text, parse_mode="HTML")

View File

@@ -0,0 +1,221 @@
"""
Обработчик команды /id для получения информации о пользователе
"""
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery
from aiogram.utils.keyboard import InlineKeyboardBuilder
from configs import settings, COMMANDS
from middleware.loggers import logger
__all__ = ("router",)
router: Router = Router(name="user_id_router")
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
def get_close_keyboard():
"""Создаёт клавиатуру с кнопкой закрытия"""
ikb = InlineKeyboardBuilder()
ikb.button(text="✖️ Закрыть", callback_data="id_close")
return ikb.as_markup()
# ================= КОМАНДА /ID =================
@router.message(Command(*COMMANDS.get("id", ["id"]), prefix=settings.PREFIX, ignore_case=True))
async def id_cmd(message: Message) -> None:
"""
Показывает информацию о вашем Telegram аккаунте.
Доступно всем пользователям.
Использование: /id
"""
user = message.from_user
if not user:
await message.answer("Не удалось получить информацию о пользователе")
return
# === ФОРМИРУЕМ ИНФОРМАЦИЮ ===
output = "👤 <b>ИНФОРМАЦИЯ О ВАС</b>\n\n"
# Имя
full_name_parts = []
if user.first_name:
full_name_parts.append(user.first_name)
if user.last_name:
full_name_parts.append(user.last_name)
full_name = " ".join(full_name_parts) if full_name_parts else "Не указано"
output += f"📝 <b>Имя:</b> {full_name}\n"
# Username
if user.username:
output += f"🔗 <b>Username:</b> @{user.username}\n"
else:
output += f"🔗 <b>Username:</b> <i>не установлен</i>\n"
# ID
output += f"🆔 <b>ID:</b> <code>{user.id}</code>\n\n"
# Тип аккаунта
if user.is_bot:
output += f"🤖 <b>Тип:</b> Бот\n"
elif user.is_premium:
output += f"⭐️ <b>Тип:</b> Premium пользователь\n"
else:
output += f"👥 <b>Тип:</b> Обычный пользователь\n"
# Дополнительная информация
output += "\n📊 <b>Дополнительно:</b>\n"
# Язык
if user.language_code:
language_names = {
'ru': '🇷🇺 Русский',
'en': '🇬🇧 English',
'uk': '🇺🇦 Українська',
'de': '🇩🇪 Deutsch',
'es': '🇪🇸 Español',
'fr': '🇫🇷 Français',
'it': '🇮🇹 Italiano',
'pt': '🇵🇹 Português',
}
language = language_names.get(user.language_code, f"🌐 {user.language_code.upper()}")
output += f"├─ Язык: {language}\n"
# Информация о чате
if message.chat.type == "private":
output += f"├─ Чат: 💬 Личные сообщения\n"
else:
chat_title = message.chat.title or "Без названия"
chat_types = {
"group": "👥 Группа",
"supergroup": "👥 Супергруппа",
"channel": "📢 Канал"
}
chat_type = chat_types.get(message.chat.type, "💬 Чат")
output += f"├─ Чат: {chat_type}\n"
output += f"├─ Название: {chat_title}\n"
output += f"├─ Chat ID: <code>{message.chat.id}</code>\n"
# Получаем количество участников (только для групп)
try:
member_count = await message.bot.get_chat_member_count(message.chat.id)
output += f"├─ Участников: {member_count}\n"
except Exception as e:
logger.debug(f"Не удалось получить количество участников: {e}", log_type="USER_ID")
# Message ID
output += f"└─ Message ID: <code>{message.message_id}</code>\n\n"
# Подсказка
output += "💡 <i>Эту информацию видите только вы</i>"
# Клавиатура
keyboard = get_close_keyboard()
# Отправляем
try:
await message.answer(
text=output,
parse_mode="HTML",
reply_markup=keyboard
)
logger.debug(f"Команда /id от пользователя {user.id}", log_type="USER_ID")
except Exception as e:
logger.error(f"Ошибка отправки информации о пользователе: {e}", log_type="ERROR")
await message.answer("❌ Произошла ошибка при получении информации")
# ================= ОБРАБОТЧИК КНОПКИ ЗАКРЫТИЯ =================
@router.callback_query(F.data == "id_close")
async def id_close_callback(callback: CallbackQuery) -> None:
"""Закрывает (удаляет) сообщение с информацией"""
try:
await callback.message.delete()
await callback.answer("✅ Закрыто")
except Exception as e:
logger.error(f"Ошибка удаления сообщения ID: {e}", log_type="ERROR")
await callback.answer("Не удалось удалить сообщение", show_alert=True)
# ================= КОМАНДА /MYID (АЛЬТЕРНАТИВА) =================
@router.message(Command(*COMMANDS.get("myid", ["myid"]), prefix=settings.PREFIX, ignore_case=True))
async def myid_cmd(message: Message) -> None:
"""
Быстрый просмотр вашего ID.
Использование: /myid
"""
user = message.from_user
if not user:
await message.answer("Не удалось получить ID")
return
# Короткий ответ
text = f"🆔 Ваш ID: <code>{user.id}</code>"
if user.username:
text += f"\n🔗 Username: @{user.username}"
await message.answer(text, parse_mode="HTML")
# ================= КОМАНДА /CHATID =================
@router.message(Command(*COMMANDS.get("chatid", ["chatid"]), prefix=settings.PREFIX, ignore_case=True))
async def chatid_cmd(message: Message) -> None:
"""
Показывает ID текущего чата.
Использование: /chatid
"""
chat = message.chat
output = "💬 <b>ИНФОРМАЦИЯ О ЧАТЕ</b>\n\n"
# Тип чата
chat_types = {
"private": "💬 Личные сообщения",
"group": "👥 Группа",
"supergroup": "👥 Супергруппа",
"channel": "📢 Канал"
}
chat_type = chat_types.get(chat.type, "💬 Чат")
output += f"📝 <b>Тип:</b> {chat_type}\n"
if chat.title:
output += f"📌 <b>Название:</b> {chat.title}\n"
if chat.username:
output += f"🔗 <b>Username:</b> @{chat.username}\n"
output += f"🆔 <b>Chat ID:</b> <code>{chat.id}</code>\n"
# Дополнительная информация для групп
if chat.type in ["group", "supergroup"]:
try:
member_count = await message.bot.get_chat_member_count(chat.id)
output += f"👥 <b>Участников:</b> {member_count}\n"
except Exception as e:
logger.debug(f"Не удалось получить количество участников: {e}", log_type="USER_ID")
keyboard = get_close_keyboard()
await message.answer(
text=output,
parse_mode="HTML",
reply_markup=keyboard
)

View File

@@ -0,0 +1,238 @@
"""
Обработчик команды /listwords - отображение всех правил модерации
"""
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot.filters.admin import IsAdmin
from configs import settings, COMMANDS
from database import get_manager
from middleware.loggers import logger
from bot.utils.decorators import log_action
__all__ = ("router",)
CMD: str = "list"
router: Router = Router(name="listwords_cmd_router")
def get_refresh_kb(page: int = 0):
"""Клавиатура с кнопкой обновления"""
ikb = InlineKeyboardBuilder()
ikb.button(text="🔄 Обновить", callback_data=f"listwords:refresh:{page}")
ikb.button(text="📊 Статистика", callback_data="stats")
ikb.adjust(2)
return ikb.as_markup()
async def format_banwords_list(page: int = 0) -> str:
"""
Форматирует список всех банвордов с разбивкой по типам.
Args:
page: Номер страницы (для будущей пагинации)
Returns:
Отформатированная строка со всеми правилами
"""
manager = get_manager()
# Получаем все данные из БД
try:
# Используем существующий метод get_all_words_list()
data = await manager.get_all_words_list()
stats = await manager.get_stats()
# Извлекаем данные из словаря
permanent_words = list(data.get('substring', set()))
permanent_lemmas = list(data.get('lemma', set()))
permanent_parts = list(data.get('part', set()))
temp_words = list(data.get('temp_substring', set()))
temp_lemmas = list(data.get('temp_lemma', set()))
conflict_words = list(data.get('conflict_substring', set()))
conflict_lemmas = list(data.get('conflict_lemma', set()))
exceptions = list(data.get('whitelist', set()))
except Exception as e:
logger.error(f"Ошибка получения данных из БД: {e}", log_type="LISTWORDS")
return "❌ <b>Ошибка загрузки данных из базы</b>"
# === ФОРМИРУЕМ ВЫВОД ===
output = "📋 <b>СПИСОК ПРАВИЛ МОДЕРАЦИИ</b>\n\n"
# Статистика
total_count = (
len(permanent_words) + len(permanent_lemmas) + len(permanent_parts) +
len(temp_words) + len(temp_lemmas) +
len(conflict_words) + len(conflict_lemmas)
)
output += f"📊 <b>Общая статистика:</b>\n"
output += f"├─ Всего правил: <code>{total_count}</code>\n"
output += f"├─ Исключений: <code>{len(exceptions)}</code>\n"
output += f"├─ Удалений за всё время: <code>{stats.get('total_deletions', 0)}</code>\n"
output += f"└─ Администраторов: <code>{stats.get('admins', 0)}</code>\n\n"
# === ПОСТОЯННЫЕ ПРАВИЛА ===
if permanent_words or permanent_lemmas or permanent_parts:
output += "🔴 <b>ПОСТОЯННЫЕ ПРАВИЛА:</b>\n\n"
if permanent_words:
output += f"📝 <b>Подстроки</b> ({len(permanent_words)}):\n"
words_str = ', '.join([f"<code>{w}</code>" for w in sorted(permanent_words)[:20]])
if len(permanent_words) > 20:
words_str += f" ... <i>(+{len(permanent_words) - 20} ещё)</i>"
output += f"{words_str}\n\n"
if permanent_lemmas:
output += f"🔤 <b>Леммы</b> ({len(permanent_lemmas)}):\n"
lemmas_str = ', '.join([f"<code>{w}</code>" for w in sorted(permanent_lemmas)[:20]])
if len(permanent_lemmas) > 20:
lemmas_str += f" ... <i>(+{len(permanent_lemmas) - 20} ещё)</i>"
output += f"{lemmas_str}\n\n"
if permanent_parts:
output += f"🧩 <b>Части</b> ({len(permanent_parts)}):\n"
parts_str = ', '.join([f"<code>{w}</code>" for w in sorted(permanent_parts)[:20]])
if len(permanent_parts) > 20:
parts_str += f" ... <i>(+{len(permanent_parts) - 20} ещё)</i>"
output += f"{parts_str}\n\n"
# === ВРЕМЕННЫЕ ПРАВИЛА ===
if temp_words or temp_lemmas:
output += "⏱ <b>ВРЕМЕННЫЕ ПРАВИЛА:</b>\n\n"
if temp_words:
output += f"📝 <b>Временные подстроки</b> ({len(temp_words)}):\n"
# Для временных слов нужна дополнительная информация о времени истечения
# Пока просто выводим список
words_str = ', '.join([f"<code>{w}</code>" for w in sorted(temp_words)[:15]])
if len(temp_words) > 15:
words_str += f" ... <i>(+{len(temp_words) - 15} ещё)</i>"
output += f"{words_str}\n\n"
if temp_lemmas:
output += f"🔤 <b>Временные леммы</b> ({len(temp_lemmas)}):\n"
lemmas_str = ', '.join([f"<code>{w}</code>" for w in sorted(temp_lemmas)[:15]])
if len(temp_lemmas) > 15:
lemmas_str += f" ... <i>(+{len(temp_lemmas) - 15} ещё)</i>"
output += f"{lemmas_str}\n\n"
# === КОНФЛИКТНЫЕ ПРАВИЛА ===
if conflict_words or conflict_lemmas:
output += "⚔️ <b>КОНФЛИКТНЫЕ ПРАВИЛА:</b>\n"
output += "<i>(работают только в режиме /stopconflict)</i>\n\n"
if conflict_words:
output += f"📝 <b>Конфликтные слова</b> ({len(conflict_words)}):\n"
words_str = ', '.join([f"<code>{w}</code>" for w in sorted(conflict_words)[:15]])
if len(conflict_words) > 15:
words_str += f" ... <i>(+{len(conflict_words) - 15} ещё)</i>"
output += f"{words_str}\n\n"
if conflict_lemmas:
output += f"🔤 <b>Конфликтные леммы</b> ({len(conflict_lemmas)}):\n"
lemmas_str = ', '.join([f"<code>{w}</code>" for w in sorted(conflict_lemmas)[:15]])
if len(conflict_lemmas) > 15:
lemmas_str += f" ... <i>(+{len(conflict_lemmas) - 15} ещё)</i>"
output += f"{lemmas_str}\n\n"
# === ИСКЛЮЧЕНИЯ (WHITELIST) ===
if exceptions:
output += f"✅ <b>ИСКЛЮЧЕНИЯ</b> ({len(exceptions)}):\n"
exc_str = ', '.join([f"<code>{exceptions}</code>" for w in sorted(exceptions)[:15]])
if len(exceptions) > 15:
exc_str += f" ... <i>(+{len(exceptions) - 15} ещё)</i>"
output += f"{exc_str}\n\n"
# === АКТИВНЫЕ РЕЖИМЫ ===
active_modes = []
if await manager.is_silence_active():
active_modes.append("🔇 Режим тишины")
if await manager.is_conflict_active():
active_modes.append("⚔️ Режим антиконфликта")
if active_modes:
output += "🔴 <b>АКТИВНЫЕ РЕЖИМЫ:</b>\n"
for mode in active_modes:
output += f"{mode}\n"
output += "\n"
# === ПУСТОЙ СПИСОК ===
if total_count == 0:
output = (
"📋 <b>СПИСОК ПРАВИЛ МОДЕРАЦИИ</b>\n\n"
"⚠️ <i>Правила модерации не настроены</i>\n\n"
"Используйте команды добавления:\n"
"• /addword — добавить подстроку\n"
"• /addlemma — добавить лемму\n"
"• /addpart — добавить часть\n\n"
"📖 Подробнее: /start"
)
# Ограничение длины (Telegram limit 4096)
if len(output) > 4000:
output = output[:3950] + "\n\n<i>... список обрезан, слишком много правил</i>"
return output
@router.callback_query(F.data.startswith("listwords:refresh"))
@router.message(Command(*COMMANDS[CMD], prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="LISTWORDS_COMMAND")
async def listwords_cmd(update: Message | CallbackQuery) -> None:
"""
Обработчик команды /listwords.
Отображает список всех правил модерации с разбивкой по категориям.
Доступно только администраторам.
Args:
update: Message или CallbackQuery
"""
# Определяем тип update
if isinstance(update, CallbackQuery):
message = update.message
is_callback = True
# Извлекаем номер страницы из callback_data
try:
page = int(update.data.split(":")[-1])
except:
page = 0
else:
message = update
is_callback = False
page = 0
# Формируем список
try:
text = await format_banwords_list(page)
keyboard = get_refresh_kb(page)
if is_callback:
await message.edit_text(
text=text,
parse_mode="HTML",
reply_markup=keyboard
)
await update.answer("✅ Список обновлён")
else:
await message.answer(
text=text,
parse_mode="HTML",
reply_markup=keyboard
)
except Exception as e:
logger.error(f"Ошибка отправки списка банвордов: {e}", log_type="LISTWORDS")
error_text = "❌ <b>Ошибка загрузки списка</b>\n\nПопробуйте позже"
if is_callback:
await update.answer("❌ Ошибка загрузки", show_alert=True)
else:
await message.answer(error_text, parse_mode="HTML")

View File

@@ -0,0 +1,118 @@
"""
Обработчики callback-кнопок уведомлений о спаме
"""
from aiogram import Router, F
from aiogram.types import CallbackQuery
from aiogram.exceptions import TelegramBadRequest
from bot.filters.admin import IsAdmin
from database import get_manager
from middleware.loggers import logger
__all__ = ("router",)
router: Router = Router(name="spam_notifications_router")
# ================= ЗАКРЫТИЕ УВЕДОМЛЕНИЯ =================
@router.callback_query(F.data == "spam_close", IsAdmin())
async def spam_close_callback(callback: CallbackQuery) -> None:
"""
Закрывает (удаляет) уведомление о спаме.
"""
try:
await callback.message.delete()
await callback.answer("✅ Уведомление закрыто")
logger.debug(
f"Уведомление о спаме закрыто админом {callback.from_user.id}",
log_type="SPAM_NOTIFICATION"
)
except TelegramBadRequest as e:
logger.error(f"Ошибка удаления уведомления: {e}", log_type="ERROR")
await callback.answer("Не удалось удалить уведомление", show_alert=True)
# ================= БАН ПОЛЬЗОВАТЕЛЯ =================
@router.callback_query(F.data.startswith("spam_ban:"), IsAdmin())
async def spam_ban_callback(callback: CallbackQuery) -> None:
"""
Банит пользователя прямо из уведомления.
"""
try:
# Парсим данные: spam_ban:user_id:chat_id
parts = callback.data.split(":")
user_id = int(parts[1])
chat_id = int(parts[2])
# Баним пользователя
try:
await callback.bot.ban_chat_member(
chat_id=chat_id,
user_id=user_id
)
# Обновляем сообщение
updated_text = callback.message.text + f"\n\n🔨 <b>Пользователь забанен</b> (@{callback.from_user.username or callback.from_user.id})"
# Убираем кнопки
await callback.message.edit_text(
text=updated_text,
parse_mode="HTML"
)
await callback.answer("✅ Пользователь забанен", show_alert=True)
logger.info(
f"Пользователь {user_id} забанен админом {callback.from_user.id} через уведомление о спаме",
log_type="SPAM_BAN"
)
except TelegramBadRequest as e:
await callback.answer(f"❌ Ошибка бана: {str(e)}", show_alert=True)
except Exception as e:
logger.error(f"Ошибка обработки бана из уведомления: {e}", log_type="ERROR")
await callback.answer("❌ Ошибка выполнения", show_alert=True)
# ================= СТАТИСТИКА ПОЛЬЗОВАТЕЛЯ =================
@router.callback_query(F.data.startswith("spam_stats:"), IsAdmin())
async def spam_stats_callback(callback: CallbackQuery) -> None:
"""
Показывает статистику пользователя.
"""
try:
# Парсим данные: spam_stats:user_id
parts = callback.data.split(":")
user_id = int(parts[1])
manager = get_manager()
# Получаем статистику
spam_count = await manager.get_user_spam_count(user_id)
recent_spam = await manager.get_spam_stats(limit=5, user_id=user_id)
# Формируем текст
text = f"📊 <b>Статистика пользователя</b>\n\n"
text += f"🆔 ID: <code>{user_id}</code>\n"
text += f"🗑 Удалено сообщений: <code>{spam_count}</code>\n\n"
if recent_spam:
text += f"📝 <b>Последние нарушения:</b>\n"
for idx, stat in enumerate(recent_spam, 1):
matched_word = stat.matched_word or "неизвестно"
match_type = stat.match_type or "unknown"
text += f"{idx}. <code>{matched_word}</code> ({match_type})\n"
else:
text += "✅ <i>Нет нарушений</i>"
await callback.answer(text, show_alert=True)
except Exception as e:
logger.error(f"Ошибка получения статистики из уведомления: {e}", log_type="ERROR")
await callback.answer("❌ Ошибка получения статистики", show_alert=True)

View File

@@ -0,0 +1,447 @@
"""
Обработчики команды /report для пользователей
"""
from datetime import datetime
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery, User
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.exceptions import TelegramBadRequest
from bot.filters.admin import IsAdmin
from configs import settings, COMMANDS
from database import get_manager
from middleware.loggers import logger
__all__ = ("router",)
router: Router = Router(name="report_router")
# ================= НАСТРОЙКИ =================
# ID чата для отправки репортов (можно вынести в configs)
# Если None, репорты отправляются всем владельцам в ЛС
REPORT_CHAT_ID = getattr(settings, 'REPORT_CHAT_ID', None)
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
def format_user(user: User) -> str:
"""
Форматирует информацию о пользователе.
Args:
user: Объект User
Returns:
Отформатированная строка с именем и username
"""
if not user:
return "Unknown User"
# Формируем имя
name_parts = []
if user.first_name:
name_parts.append(user.first_name)
if user.last_name:
name_parts.append(user.last_name)
full_name = " ".join(name_parts) if name_parts else "No Name"
# Добавляем username если есть
if user.username:
return f"{full_name} (@{user.username})"
else:
return full_name
def format_datetime(dt: datetime) -> str:
"""Форматирует datetime"""
return dt.strftime("%d.%m.%Y %H:%M:%S")
def truncate_text(text: str, max_length: int = 200) -> str:
"""Обрезает текст до указанной длины"""
if len(text) <= max_length:
return text
return text[:max_length] + "..."
def get_report_keyboard(
chat_id: int,
message_id: int,
reported_user_id: int,
report_id: str
) -> InlineKeyboardBuilder:
"""
Создает клавиатуру для репорта.
Args:
chat_id: ID чата, где было сообщение
message_id: ID сообщения
reported_user_id: ID пользователя, на которого пожаловались
report_id: Уникальный ID репорта
"""
ikb = InlineKeyboardBuilder()
# Кнопки действий
ikb.button(
text="🚫 Забанить",
callback_data=f"report:ban:{chat_id}:{reported_user_id}:{report_id}"
)
ikb.button(
text="🗑 Удалить",
callback_data=f"report:delete:{chat_id}:{message_id}:{report_id}"
)
ikb.button(
text="✅ Закрыть",
callback_data=f"report:close:{report_id}"
)
ikb.adjust(2, 1)
return ikb
def generate_report_id() -> str:
"""Генерирует уникальный ID репорта"""
return f"{int(datetime.now().timestamp() * 1000)}"
# ================= КОМАНДА РЕПОРТА =================
@router.message(Command(*COMMANDS.get("report", ["report"]), prefix=settings.PREFIX, ignore_case=True))
async def report_cmd(message: Message) -> None:
"""
Отправляет жалобу на сообщение администраторам.
Доступно всем пользователям.
Использование:
/report — в ответ на сообщение
/report <причина> — в ответ на сообщение с указанием причины
Пример:
/report спам
/report оскорбления
"""
# Проверяем, что команда в ответ на сообщение
if not message.reply_to_message:
await message.answer(
"❌ <b>Используйте команду в ответ на сообщение</b>\n\n"
"Как использовать:\n"
"1. Ответьте на сообщение нарушителя\n"
"2. Напишите <code>/report</code> или <code>/report причина</code>\n\n"
"Пример: <code>/report спам</code>",
parse_mode="HTML"
)
return
reported_message = message.reply_to_message
reported_user = reported_message.from_user
reporter = message.from_user
# Проверка на None
if not reported_user or not reporter:
await message.answer("❌ <b>Ошибка получения данных пользователя</b>", parse_mode="HTML")
return
# Нельзя пожаловаться на самого себя
if reported_user.id == reporter.id:
await message.answer(
"⚠️ <b>Нельзя пожаловаться на самого себя</b>",
parse_mode="HTML"
)
return
# Нельзя пожаловаться на бота
if reported_user.is_bot:
await message.answer(
"⚠️ <b>Нельзя пожаловаться на бота</b>",
parse_mode="HTML"
)
return
# Нельзя пожаловаться на администратора
manager = get_manager()
is_admin = await manager.is_admin(reported_user.id) or reported_user.id in settings.OWNER_ID
if is_admin:
await message.answer(
"⚠️ <b>Нельзя пожаловаться на администратора</b>",
parse_mode="HTML"
)
return
# Извлекаем причину (опционально)
parts = message.text.split(maxsplit=1)
reason = parts[1] if len(parts) > 1 else "Не указана"
# Генерируем ID репорта
report_id = generate_report_id()
# === ФОРМИРУЕМ СООБЩЕНИЕ РЕПОРТА ===
report_text = "🚨 <b>НОВЫЙ РЕПОРТ</b>\n\n"
# Информация о жалобщике
report_text += f"👤 <b>От:</b> {format_user(reporter)} (<code>{reporter.id}</code>)\n"
# Информация о нарушителе
report_text += f"⚠️ <b>На:</b> {format_user(reported_user)} (<code>{reported_user.id}</code>)\n\n"
# Информация о чате
chat_title = message.chat.title if message.chat.title else "Личные сообщения"
report_text += f"💬 <b>Чат:</b> {chat_title}\n"
report_text += f"🆔 <b>Chat ID:</b> <code>{message.chat.id}</code>\n\n"
# Причина
report_text += f"📝 <b>Причина:</b> {reason}\n\n"
# Текст сообщения
report_text += f"📄 <b>Текст сообщения:</b>\n"
if reported_message.text:
truncated_text = truncate_text(reported_message.text, max_length=300)
report_text += f"<code>{truncated_text}</code>\n\n"
elif reported_message.caption:
truncated_caption = truncate_text(reported_message.caption, max_length=300)
report_text += f"<code>{truncated_caption}</code>\n\n"
else:
content_type = reported_message.content_type
report_text += f"<i>[{content_type}]</i>\n\n"
# Время
report_text += f"🕐 <b>Время:</b> {format_datetime(datetime.now())}\n"
report_text += f"🔗 <b>Message ID:</b> <code>{reported_message.message_id}</code>\n\n"
report_text += f"💡 <i>ID репорта: {report_id}</i>"
# Клавиатура
keyboard = get_report_keyboard(
chat_id=message.chat.id,
message_id=reported_message.message_id,
reported_user_id=reported_user.id,
report_id=report_id
)
# === ОТПРАВКА РЕПОРТА ===
try:
# Если указан админ-чат, отправляем туда
if REPORT_CHAT_ID:
await message.bot.send_message(
chat_id=REPORT_CHAT_ID,
text=report_text,
parse_mode="HTML",
reply_markup=keyboard.as_markup()
)
else:
# Отправляем всем владельцам
sent_count = 0
for owner_id in settings.OWNER_ID:
try:
await message.bot.send_message(
chat_id=owner_id,
text=report_text,
parse_mode="HTML",
reply_markup=keyboard.as_markup()
)
sent_count += 1
except Exception as e:
logger.error(f"Ошибка отправки репорта владельцу {owner_id}: {e}", log_type="REPORT")
if sent_count == 0:
raise Exception("Не удалось отправить репорт ни одному владельцу")
# Подтверждение пользователю
await message.answer(
"✅ <b>Жалоба отправлена администраторам</b>\n\n"
"Спасибо за бдительность! Администраторы рассмотрят вашу жалобу.",
parse_mode="HTML"
)
# Логирование
logger.info(
f"Репорт #{report_id}: {reporter.id}{reported_user.id} в чате {message.chat.id}",
log_type="REPORT"
)
except Exception as e:
logger.error(f"Ошибка отправки репорта: {e}", log_type="REPORT")
await message.answer(
"❌ <b>Ошибка отправки жалобы</b>\n\nПопробуйте позже или обратитесь к администратору напрямую.",
parse_mode="HTML"
)
# ================= ОБРАБОТЧИКИ КНОПОК =================
@router.callback_query(F.data.startswith("report:ban:"), IsAdmin())
async def report_ban_callback(callback: CallbackQuery) -> None:
"""Обрабатывает нажатие кнопки 'Забанить'"""
try:
# Парсим данные: report:ban:chat_id:user_id:report_id
parts = callback.data.split(":")
chat_id = int(parts[2])
user_id = int(parts[3])
report_id = parts[4]
# Баним пользователя
try:
await callback.bot.ban_chat_member(
chat_id=chat_id,
user_id=user_id
)
admin_name = format_user(callback.from_user)
# Обновляем сообщение
updated_text = callback.message.text + f"\n\n✅ <b>Пользователь забанен</b> ({admin_name})"
# Убираем кнопки
await callback.message.edit_text(
text=updated_text,
parse_mode="HTML"
)
await callback.answer("✅ Пользователь забанен", show_alert=True)
logger.info(
f"Репорт #{report_id}: пользователь {user_id} забанен админом {callback.from_user.id}",
log_type="REPORT"
)
except TelegramBadRequest as e:
await callback.answer(f"❌ Ошибка бана: {str(e)}", show_alert=True)
except Exception as e:
logger.error(f"Ошибка обработки бана из репорта: {e}", log_type="REPORT")
await callback.answer("❌ Ошибка выполнения", show_alert=True)
@router.callback_query(F.data.startswith("report:delete:"), IsAdmin())
async def report_delete_callback(callback: CallbackQuery) -> None:
"""Обрабатывает нажатие кнопки 'Удалить'"""
try:
# Парсим данные: report:delete:chat_id:message_id:report_id
parts = callback.data.split(":")
chat_id = int(parts[2])
message_id = int(parts[3])
report_id = parts[4]
# Удаляем сообщение
try:
await callback.bot.delete_message(
chat_id=chat_id,
message_id=message_id
)
admin_name = format_user(callback.from_user)
# Обновляем сообщение
updated_text = callback.message.text + f"\n\n🗑 <b>Сообщение удалено</b> ({admin_name})"
# Убираем кнопки
await callback.message.edit_text(
text=updated_text,
parse_mode="HTML"
)
await callback.answer("✅ Сообщение удалено", show_alert=True)
logger.info(
f"Репорт #{report_id}: сообщение {message_id} удалено админом {callback.from_user.id}",
log_type="REPORT"
)
except TelegramBadRequest as e:
await callback.answer(f"❌ Ошибка удаления: {str(e)}", show_alert=True)
except Exception as e:
logger.error(f"Ошибка удаления из репорта: {e}", log_type="REPORT")
await callback.answer("❌ Ошибка выполнения", show_alert=True)
@router.callback_query(F.data.startswith("report:close:"), IsAdmin())
async def report_close_callback(callback: CallbackQuery) -> None:
"""Обрабатывает нажатие кнопки 'Закрыть'"""
try:
# Парсим данные: report:close:report_id
parts = callback.data.split(":")
report_id = parts[2]
admin_name = format_user(callback.from_user)
# Обновляем сообщение
updated_text = callback.message.text + f"\n\n✅ <b>Репорт закрыт</b> ({admin_name})"
# Убираем кнопки
await callback.message.edit_text(
text=updated_text,
parse_mode="HTML"
)
await callback.answer("✅ Репорт закрыт")
logger.info(
f"Репорт #{report_id} закрыт админом {callback.from_user.id}",
log_type="REPORT"
)
except Exception as e:
logger.error(f"Ошибка закрытия репорта: {e}", log_type="REPORT")
await callback.answer("❌ Ошибка выполнения", show_alert=True)
# ================= ДОПОЛНИТЕЛЬНЫЕ ФУНКЦИИ =================
@router.message(Command(*COMMANDS.get("reporthelp", ["reporthelp"]), prefix=settings.PREFIX, ignore_case=True))
async def report_help_cmd(message: Message) -> None:
"""
Показывает справку по системе репортов.
Доступно всем пользователям.
"""
text = (
"🚨 <b>СИСТЕМА РЕПОРТОВ</b>\n\n"
"Используйте команду /report, чтобы пожаловаться на сообщение администраторам.\n\n"
"📝 <b>Как пожаловаться:</b>\n"
"1. Ответьте на сообщение нарушителя\n"
"2. Напишите <code>/report</code>\n"
"3. Можно указать причину: <code>/report спам</code>\n\n"
"✅ <b>Примеры:</b>\n"
"• <code>/report</code> — жалоба без причины\n"
"• <code>/report спам</code> — жалоба на спам\n"
"• <code>/report оскорбления</code> — жалоба на оскорбления\n\n"
"⚠️ <b>Важно:</b>\n"
"├─ Нельзя пожаловаться на себя\n"
"├─ Нельзя пожаловаться на ботов\n"
"├─ Нельзя пожаловаться на администраторов\n"
"└─ Ложные жалобы могут привести к бану\n\n"
"💡 <i>Администраторы получат уведомление и примут меры</i>"
)
await message.answer(text, parse_mode="HTML")
@router.message(Command(*COMMANDS.get("reportstats", ["reportstats"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
async def report_stats_cmd(message: Message) -> None:
"""
Показывает статистику по репортам (для админов).
TODO: Реализовать сохранение статистики в БД
"""
text = (
"📊 <b>СТАТИСТИКА РЕПОРТОВ</b>\n\n"
"⚠️ <i>Функция в разработке</i>\n\n"
"Планируется:\n"
"Всего репортов за всё время\n"
"• Топ жалобщиков\n"
"• Топ нарушителей\n"
"• Распределение по причинам\n"
"• Статистика обработки\n\n"
"💡 <i>Для реализации нужно добавить таблицу reports в БД</i>"
)
await message.answer(text, parse_mode="HTML")

View File

@@ -0,0 +1,346 @@
"""
Обработчики команд режима тишины
"""
from datetime import datetime
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import Message
from bot.filters.admin import IsAdmin
from configs import settings, COMMANDS
from database import get_manager
from middleware.loggers import logger
from bot.utils.decorators import log_action
__all__ = ("router",)
router: Router = Router(name="silence_mode_router")
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
def parse_silence_args(text: str) -> tuple[bool, str | int]:
"""
Парсит аргументы команды для режима тишины.
Args:
text: Полный текст сообщения
Returns:
(success, result): result это либо минуты (int), либо текст ошибки (str)
"""
parts = text.split(maxsplit=1)
if len(parts) < 2:
return False, "❌ Использование: <code>/silence <минуты></code>"
return True, parts[1]
def format_time_str(minutes: int) -> str:
"""Форматирует время в читабельный формат"""
if minutes < 60:
return f"{minutes} мин"
elif minutes < 1440:
hours = minutes // 60
mins = minutes % 60
return f"{hours}ч {mins}м" if mins else f"{hours}ч"
else:
days = minutes // 1440
hours = (minutes % 1440) // 60
return f"{days}д {hours}ч" if hours else f"{days}д"
def format_datetime(dt: datetime) -> str:
"""Форматирует datetime в читабельный формат"""
return dt.strftime("%d.%m.%Y %H:%M:%S")
# ================= КОМАНДЫ РЕЖИМА ТИШИНЫ =================
@router.message(Command(*COMMANDS.get("silence", ["silence"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="START_SILENCE_MODE", log_args=True)
async def start_silence_mode_cmd(message: Message) -> None:
"""
Активирует режим тишины на указанное время.
В этом режиме удаляются ВСЕ сообщения от обычных пользователей.
Администраторы могут продолжать писать.
Использование: /silence <минуты>
Примеры:
/silence 30 — на 30 минут
/silence 120 — на 2 часа
/silence 1440 — на сутки
"""
success, result = parse_silence_args(message.text)
if not success:
await message.answer(result, parse_mode="HTML")
return
# Валидация минут
try:
minutes = int(result)
if minutes < 1 or minutes > 10080: # Максимум неделя
await message.answer(
"❌ Время должно быть от 1 минуты до 10080 минут (7 дней)",
parse_mode="HTML"
)
return
except ValueError:
await message.answer("❌ Неверный формат времени. Укажите число минут", parse_mode="HTML")
return
manager = get_manager()
try:
# Проверяем, уже активен ли режим
is_already_active = await manager.is_silence_active()
# Активируем режим (перезаписывает предыдущий, если был)
expires_at = await manager.set_silence_mode(minutes)
time_str = format_time_str(minutes)
expires_str = format_datetime(expires_at)
if is_already_active:
action_text = "🔄 <b>РЕЖИМ ТИШИНЫ ОБНОВЛЁН</b>"
else:
action_text = "🔇 <b>РЕЖИМ ТИШИНЫ АКТИВИРОВАН</b>"
text = (
f"{action_text}\n\n"
f"⏱ Длительность: {time_str}\n"
f"🕐 Окончание: {expires_str}\n\n"
f"⚠️ <b>Что происходит:</b>\n"
f"├─ Все сообщения от пользователей удаляются\n"
f"├─ Администраторы могут писать\n"
f"└─ Банворды временно отключены\n\n"
f"💡 <i>Используйте для успокоения спора или флуда</i>\n"
f"Отключить досрочно: /unsilence"
)
await message.answer(text, parse_mode="HTML")
logger.info(
f"Режим тишины {'обновлён' if is_already_active else 'активирован'} на {minutes} мин "
f"пользователем {message.from_user.id}",
log_type="SILENCE"
)
except Exception as e:
logger.error(f"Ошибка активации режима тишины: {e}", log_type="SILENCE")
await message.answer("❌ <b>Ошибка активации режима</b>\n\nПопробуйте позже", parse_mode="HTML")
@router.message(Command(*COMMANDS.get("unsilence", ["unsilence"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="STOP_SILENCE_MODE")
async def stop_silence_mode_cmd(message: Message) -> None:
"""
Отключает режим тишины.
Использование: /unsilence
"""
manager = get_manager()
try:
# Проверяем, активен ли режим
is_active = await manager.is_silence_active()
if not is_active:
await message.answer(
"⚠️ <b>Режим тишины не активен</b>\n\n"
"Активируйте командой: /silence <минуты>",
parse_mode="HTML"
)
return
# Отключаем режим
await manager.disable_silence_mode()
text = (
f"✅ <b>Режим тишины отключен</b>\n\n"
f"🔊 Пользователи снова могут отправлять сообщения\n"
f"🔄 Банворды снова активны"
)
await message.answer(text, parse_mode="HTML")
logger.info(
f"Режим тишины отключён пользователем {message.from_user.id}",
log_type="SILENCE"
)
except Exception as e:
logger.error(f"Ошибка отключения режима тишины: {e}", log_type="SILENCE")
await message.answer("❌ <b>Ошибка отключения режима</b>\n\nПопробуйте позже", parse_mode="HTML")
@router.message(Command(*COMMANDS.get("silencestatus", ["silencestatus"]), prefix=settings.PREFIX, ignore_case=True),
IsAdmin())
@log_action(action_name="SILENCE_STATUS")
async def silence_status_cmd(message: Message) -> None:
"""
Показывает статус режима тишины.
Использование: /silencestatus
"""
manager = get_manager()
try:
# Проверяем активность режима
is_active = await manager.is_silence_active()
if is_active:
# Режим активен - показываем детали
silence_until_str = await manager.repo.get_setting("silence_until")
silence_until = float(silence_until_str)
expires_at = datetime.fromtimestamp(silence_until)
now = datetime.now()
time_left_seconds = (expires_at - now).total_seconds()
time_left_minutes = int(time_left_seconds / 60)
# Расчёт процента прошедшего времени (для визуализации)
# Примерно определяем начальное время
started_minutes_ago = 0 # Можно было бы сохранять в БД
text = (
f"🔇 <b>РЕЖИМ ТИШИНЫ АКТИВЕН</b>\n\n"
f"⏱ Осталось: {format_time_str(time_left_minutes)}\n"
f"🕐 Окончание: {format_datetime(expires_at)}\n\n"
f"⚠️ <b>Что происходит:</b>\n"
f"├─ Все сообщения от пользователей удаляются\n"
f"├─ Администраторы могут писать\n"
f"└─ Банворды временно отключены\n\n"
f"💡 <i>Для успокоения конфликта или флуда</i>\n"
f"Отключить: /unsilence"
)
# Добавляем визуальную шкалу прогресса
if time_left_minutes <= 60:
progress_bar = create_progress_bar(time_left_minutes, 60)
text += f"\n\n{progress_bar}"
else:
# Режим не активен
text = (
f"💤 <b>Режим тишины НЕ активен</b>\n\n"
f"🔊 Пользователи могут отправлять сообщения\n"
f"🔄 Банворды работают в обычном режиме\n\n"
f"Активировать: /silence <минуты>"
)
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка получения статуса режима тишины: {e}", log_type="SILENCE")
await message.answer("❌ <b>Ошибка получения статуса</b>", parse_mode="HTML")
def create_progress_bar(minutes_left: int, total_minutes: int, length: int = 10) -> str:
"""
Создает визуальную шкалу прогресса.
Args:
minutes_left: Сколько минут осталось
total_minutes: Всего минут
length: Длина шкалы
Returns:
Строка с визуальной шкалой
"""
if total_minutes <= 0:
filled = 0
else:
filled = int((total_minutes - minutes_left) / total_minutes * length)
filled = max(0, min(filled, length))
empty = length - filled
bar = "" * filled + "" * empty
percentage = int((total_minutes - minutes_left) / total_minutes * 100) if total_minutes > 0 else 0
return f"[{bar}] {percentage}%"
@router.message(Command(*COMMANDS.get("extend_silence", ["extend_silence"]), prefix=settings.PREFIX, ignore_case=True),
IsAdmin())
@log_action(action_name="EXTEND_SILENCE_MODE", log_args=True)
async def extend_silence_mode_cmd(message: Message) -> None:
"""
Продлевает режим тишины на указанное время.
Использование: /extend_silence <минуты>
Пример: /extend_silence 30
"""
success, result = parse_silence_args(message.text)
if not success:
# Меняем текст ошибки для extend команды
await message.answer(
"❌ Использование: <code>/extend_silence <минуты></code>",
parse_mode="HTML"
)
return
# Проверяем, активен ли режим
manager = get_manager()
is_active = await manager.is_silence_active()
if not is_active:
await message.answer(
"⚠️ <b>Режим тишины не активен</b>\n\n"
"Сначала активируйте: /silence <минуты>",
parse_mode="HTML"
)
return
try:
add_minutes = int(result)
if add_minutes < 1 or add_minutes > 1440:
await message.answer(
"❌ Время продления должно быть от 1 до 1440 минут (24 часа)",
parse_mode="HTML"
)
return
except ValueError:
await message.answer("❌ Неверный формат времени. Укажите число минут", parse_mode="HTML")
return
try:
# Получаем текущее время окончания
silence_until_str = await manager.repo.get_setting("silence_until")
current_until = float(silence_until_str)
current_expires = datetime.fromtimestamp(current_until)
# Вычисляем сколько минут осталось + добавляем новые
now = datetime.now()
current_minutes_left = int((current_expires - now).total_seconds() / 60)
new_total_minutes = current_minutes_left + add_minutes
# Устанавливаем новое время
new_expires_at = await manager.set_silence_mode(new_total_minutes)
time_str = format_time_str(add_minutes)
new_expires_str = format_datetime(new_expires_at)
text = (
f"⏱ <b>РЕЖИМ ТИШИНЫ ПРОДЛЁН</b>\n\n"
f" Добавлено: {time_str}\n"
f"🕐 Новое окончание: {new_expires_str}\n"
f"Всего осталось: {format_time_str(new_total_minutes)}\n\n"
f"Отключить: /unsilence"
)
await message.answer(text, parse_mode="HTML")
logger.info(
f"Режим тишины продлён на {add_minutes} мин (всего: {new_total_minutes} мин)",
log_type="SILENCE"
)
except Exception as e:
logger.error(f"Ошибка продления режима тишины: {e}", log_type="SILENCE")
await message.answer("❌ <b>Ошибка продления режима</b>\n\nПопробуйте позже", parse_mode="HTML")

View File

@@ -0,0 +1,168 @@
"""
Обработчик команды /start и /help для администраторов.
Показывает список доступных команд для управления банвордами.
"""
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot.filters.admin import IsAdmin
from configs import settings, COMMANDS
from middleware.loggers import logger
from bot.utils.decorators import log_action
__all__ = ("router",)
CMD: str = "start"
router: Router = Router(name="start_cmd_router")
def kb(text: str = "Создатель⬆️", url: str = "https://t.me/verdise"):
ikb = InlineKeyboardBuilder()
ikb.button(text=text, url=url)
return ikb.as_markup()
@router.callback_query(F.data.casefold() == CMD)
@router.message(Command(*COMMANDS[CMD], prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="START_COMMAND", log_args=True)
async def start_cmd(update: Message | CallbackQuery) -> None:
"""
Обработчик команды /start и /help.
Показывает справку по командам бота для администраторов.
Доступно только администраторам (суперадмин или доп. админ из БД).
Args:
update: Message или CallbackQuery
"""
print(123)
# Определяем тип update и извлекаем данные
if isinstance(update, CallbackQuery):
message = update.message
user_id = update.from_user.id
is_callback = True
else:
message = update
user_id = update.from_user.id
is_callback = False
# Проверяем, является ли пользователь суперадмином
is_super_admin = user_id in settings.OWNER_ID
# Формируем текст помощи
help_text = (
"🤖 <b>PrimoGuard - Бот-модератор</b>\n\n"
"Автоматическое удаление сообщений с запрещёнными словами.\n"
"Поддержка подстрок, лемм, временных блокировок и режимов модерации.\n\n"
)
# === Команды просмотра ===
help_text += (
"📋 <b>Просмотр:</b>\n"
"/list — список всех правил и слов\n"
"/stats — статистика по удалениям\n"
"/id — получение айди пользователя\n"
"/chatid — получение айди чата\n\n"
)
# === Постоянные банворды ===
help_text += (
" <b>Добавить банворд (постоянно):</b>\n"
"/addword <code>слово</code> — подстрока (простой поиск)\n"
"/addlemma <code>слово</code> — лемма (все формы слова)\n"
"/addpart <code>комбинация</code> — часть (поиск без пробелов)\n\n"
)
# === Временные банворды ===
help_text += (
"⏱ <b>Добавить банворд (временно):</b>\n"
"/addtempword <code>слово минуты</code> — временная подстрока\n"
"/addtemplemma <code>слово минуты</code> — временная лемма\n"
"<i>Пример: /addtempword спам 60</i>\n\n"
)
# === Исключения (whitelist) ===
help_text += (
"✅ <b>Исключения (whitelist):</b>\n"
"/addexcept <code>текст</code> — добавить исключение\n"
"/remexcept <code>текст</code> — удалить исключение\n"
"<i>Исключения не проверяются фильтром</i>\n\n"
)
# === Режимы модерации ===
help_text += (
"🔇 <b>Режим тишины:</b>\n"
"/silence <code>минуты</code> — удалять ВСЕ сообщения\n"
"/unsilence — отключить режим тишины\n\n"
)
help_text += (
"⚔️ <b>Режим антиконфликта:</b>\n"
"/addconflictword <code>слово</code> — добавить конфликтное слово\n"
"/addconflictlemma <code>слово</code> — добавить конфликтную лемму\n"
"/stopconflict <code>минуты</code> — активировать режим\n"
"/unstopconflict — отключить режим\n\n"
)
# === Удаление ===
help_text += (
" <b>Удалить:</b>\n"
"/remword <code>слово</code> — удалить подстроку\n"
"/remlemma <code>слово</code> — удалить лемму\n"
"/rempart <code>комбинация</code> — удалить часть\n"
"/remtempword <code>слово</code> — удалить временную подстроку\n"
"/remtemplemma <code>слово</code> — удалить временную лемму\n"
"/remconflictword <code>слово</code> — удалить конфликтное слово\n"
"/remconflictlemma <code>слово</code> — удалить конфликтную лемму\n\n"
)
# === Управление админами (только для суперадминов) ===
if is_super_admin:
help_text += (
"👑 <b>Управление админами (только для владельцев):</b>\n"
"/addadmin <code>ID</code> — добавить администратора\n"
"/remadmin <code>ID</code> — удалить администратора\n"
"/listadmins — список всех админов\n\n"
)
# === Типы проверок ===
help_text += (
" <b>Типы проверок:</b>\n"
"• <b>Подстрока</b> — простой поиск в тексте\n"
"• <b>Лемма</b> — все формы слова (купить→куплю, купил, купишь...)\n"
"• <b>Часть</b> — поиск без пробелов (обходит \"к у п и т ь\")\n"
"• <b>Временные</b> — автоматически удаляются через N минут\n"
"• <b>Конфликтные</b> — работают только в режиме /stopconflict\n\n"
)
help_text += (
"🔧 <b>Технологии:</b>\n"
"• Unicode-нормализация (латиница→кириллица)\n"
"• Обход через разделители (\"с п а м\"\"спам\")\n"
"• Морфологический анализ (pymorphy3)\n"
"• SQLAlchemy + SQLite с кэшированием\n\n"
"💾 Все настройки сохраняются в базе данных"
)
# Отправляем ответ
try:
if is_callback:
await message.edit_text(
text=help_text,
parse_mode="HTML",
reply_markup=kb()
)
await update.answer()
else:
await message.answer(
text=help_text,
parse_mode="HTML",
reply_markup=kb()
)
except Exception as e:
logger.error(
f"Ошибка отправки help сообщения: {e}",
log_type="ERROR"
)
if is_callback:
await update.answer("❌ Ошибка отображения справки", show_alert=True)

View File

@@ -0,0 +1,589 @@
"""
Обработчики команды статистики
"""
from datetime import datetime
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot.filters.admin import IsAdmin
from configs import settings, COMMANDS
from database import get_manager
from middleware.loggers import logger
from bot.utils.decorators import log_action
__all__ = ("router",)
router: Router = Router(name="stats_router")
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
def format_number(num: int) -> str:
"""Форматирует большие числа с разделителями"""
return f"{num:,}".replace(",", " ")
def create_text_bar(value: int, max_value: int, length: int = 10) -> str:
"""Создает текстовую полоску прогресса"""
if max_value == 0:
return "" * length
filled = int((value / max_value) * length)
filled = max(0, min(filled, length))
empty = length - filled
return "" * filled + "" * empty
def format_datetime(dt: datetime) -> str:
"""Форматирует datetime в читабельный формат"""
return dt.strftime("%d.%m.%Y %H:%M")
def format_time_remaining(minutes: int) -> str:
"""
Форматирует оставшееся время в читабельный формат.
Args:
minutes: Количество минут
Returns:
Отформатированная строка времени
"""
if minutes <= 0:
return "истёк"
elif minutes < 60:
return f"{minutes} мин"
elif minutes < 1440: # < 24 часов
hours = minutes // 60
mins = minutes % 60
if mins > 0:
return f"{hours}ч {mins}м"
return f"{hours}ч"
else: # >= 24 часов
days = minutes // 1440
hours = (minutes % 1440) // 60
if hours > 0:
return f"{days}д {hours}ч"
return f"{days}д"
def get_stats_keyboard():
"""Клавиатура для статистики"""
ikb = InlineKeyboardBuilder()
ikb.button(text="🔄 Обновить", callback_data="stats:refresh")
ikb.button(text="📊 Детали", callback_data="stats:details")
ikb.button(text="🏆 Топ-спамеры", callback_data="stats:top_spammers")
ikb.button(text="🔤 Топ-слова", callback_data="stats:top_words")
ikb.button(text="🚀 Назад", callback_data="start")
ikb.adjust(2, 2, 1)
return ikb.as_markup()
# ================= ОСНОВНАЯ СТАТИСТИКА =================
@router.callback_query(F.data == "stats:refresh")
@router.callback_query(F.data == "stats")
@router.message(Command(*COMMANDS.get("stats", ["stats"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="VIEW_STATS")
async def stats_cmd(update: Message | CallbackQuery) -> None:
"""
Показывает общую статистику работы бота.
Включает:
- Общее количество удалений
- Активные режимы
- Статистику банвордов
- Топ спамеров
Использование: /stats
"""
# Определяем тип update
if isinstance(update, CallbackQuery):
message = update.message
is_callback = True
else:
message = update
is_callback = False
manager = get_manager()
try:
# Получаем данные
stats = await manager.get_stats()
data = await manager.get_all_words_list()
top_spammers = await manager.get_top_spammers(limit=5)
# Проверяем активные режимы
is_silence = await manager.is_silence_active()
is_conflict = await manager.is_conflict_active()
# === ФОРМИРУЕМ ВЫВОД ===
output = "📊 <b>СТАТИСТИКА PRIMOGUARD</b>\n\n"
# Общая информация
total_deletions = stats.get('total_deletions', 0)
output += f"🗑 <b>Всего удалений:</b> <code>{format_number(total_deletions)}</code>\n\n"
# Активные режимы
if is_silence or is_conflict:
output += "🔴 <b>АКТИВНЫЕ РЕЖИМЫ:</b>\n\n"
if is_silence:
silence_until_str = await manager.repo.get_setting("silence_until")
silence_until = datetime.fromtimestamp(float(silence_until_str))
time_left_seconds = (silence_until - datetime.now()).total_seconds()
time_left_minutes = int(time_left_seconds / 60)
output += f"🔇 <b>Режим тишины</b>\n"
output += f"├─ ⏱ Осталось: <code>{format_time_remaining(time_left_minutes)}</code>\n"
output += f"└─ 🕐 До: {format_datetime(silence_until)}\n"
if is_conflict:
output += "\n"
if is_conflict:
conflict_until_str = await manager.repo.get_setting("conflict_until")
conflict_until = datetime.fromtimestamp(float(conflict_until_str))
time_left_seconds = (conflict_until - datetime.now()).total_seconds()
time_left_minutes = int(time_left_seconds / 60)
conflict_words_count = len(data.get('conflict_substring', set()))
conflict_lemmas_count = len(data.get('conflict_lemma', set()))
total_conflict = conflict_words_count + conflict_lemmas_count
output += f"⚔️ <b>Режим антиконфликта</b>\n"
output += f"├─ ⏱ Осталось: <code>{format_time_remaining(time_left_minutes)}</code>\n"
output += f"├─ 🕐 До: {format_datetime(conflict_until)}\n"
output += f"└─ 📊 Правил: <code>{total_conflict}</code>\n"
output += "\n"
# Статистика правил
total_rules = (
len(data.get('substring', set())) +
len(data.get('lemma', set())) +
len(data.get('part', set())) +
len(data.get('temp_substring', set())) +
len(data.get('temp_lemma', set())) +
len(data.get('conflict_substring', set())) +
len(data.get('conflict_lemma', set()))
)
output += f"📋 <b>Правила модерации:</b>\n"
output += f"├─ Всего правил: <code>{total_rules}</code>\n"
output += f"├─ Постоянные: <code>{len(data.get('substring', set())) + len(data.get('lemma', set())) + len(data.get('part', set()))}</code>\n"
output += f"├─ Временные: <code>{len(data.get('temp_substring', set())) + len(data.get('temp_lemma', set()))}</code>\n"
output += f"├─ Конфликтные: <code>{len(data.get('conflict_substring', set())) + len(data.get('conflict_lemma', set()))}</code>\n"
output += f"└─ Исключения: <code>{len(data.get('whitelist', set()))}</code>\n\n"
# Топ-5 спамеров
if top_spammers:
output += "🏆 <b>Топ-5 спамеров:</b>\n"
max_count = top_spammers[0][1] if top_spammers else 1
for idx, (user_id, count) in enumerate(top_spammers, 1):
bar = create_text_bar(count, max_count, length=8)
output += f"{idx}. <code>{user_id}</code> — {count} [{bar}]\n"
output += "\n"
else:
output += "🏆 <b>Топ-5 спамеров:</b>\n"
output += "└─ <i>Нет данных</i>\n\n"
# Администраторы
admins_count = len(settings.OWNER_ID) + len(data.get('admins', set()))
output += f"👥 <b>Администраторов:</b> <code>{admins_count}</code>\n\n"
# Подсказка
output += "💡 <i>Используйте кнопки для детальной информации</i>"
# Клавиатура
keyboard = get_stats_keyboard()
# Отправка
if is_callback:
await message.edit_text(
text=output,
parse_mode="HTML",
reply_markup=keyboard
)
await update.answer("✅ Статистика обновлена")
else:
await message.answer(
text=output,
parse_mode="HTML",
reply_markup=keyboard
)
except Exception as e:
logger.error(f"Ошибка получения статистики: {e}", log_type="STATS")
error_text = "❌ <b>Ошибка загрузки статистики</b>\n\nПопробуйте позже"
if is_callback:
await update.answer("❌ Ошибка загрузки", show_alert=True)
else:
await message.answer(error_text, parse_mode="HTML")
# ================= ДЕТАЛЬНАЯ СТАТИСТИКА =================
@router.callback_query(F.data == "stats:details")
@log_action(action_name="VIEW_DETAILED_STATS")
async def stats_details_callback(callback: CallbackQuery) -> None:
"""Показывает детальную статистику"""
manager = get_manager()
try:
stats = await manager.get_stats()
data = await manager.get_all_words_list()
output = "📊 <b>ДЕТАЛЬНАЯ СТАТИСТИКА</b>\n\n"
# Подробная статистика удалений
total_deletions = stats.get('total_deletions', 0)
output += f"🗑 <b>Удаления сообщений:</b>\n"
output += f"├─ Всего: <code>{format_number(total_deletions)}</code>\n"
output += "\n"
# Активные режимы (детально)
is_silence = await manager.is_silence_active()
is_conflict = await manager.is_conflict_active()
if is_silence or is_conflict:
output += "🔴 <b>Активные режимы:</b>\n\n"
if is_silence:
silence_until_str = await manager.repo.get_setting("silence_until")
silence_until = datetime.fromtimestamp(float(silence_until_str))
time_left_seconds = (silence_until - datetime.now()).total_seconds()
time_left_minutes = int(time_left_seconds / 60)
output += f"🔇 <b>Режим тишины:</b>\n"
output += f"├─ Статус: ✅ Активен\n"
output += f"├─ Осталось: <code>{format_time_remaining(time_left_minutes)}</code>\n"
output += f"├─ Окончание: {format_datetime(silence_until)}\n"
output += f"└─ Эффект: Удаляются ВСЕ сообщения\n\n"
if is_conflict:
conflict_until_str = await manager.repo.get_setting("conflict_until")
conflict_until = datetime.fromtimestamp(float(conflict_until_str))
time_left_seconds = (conflict_until - datetime.now()).total_seconds()
time_left_minutes = int(time_left_seconds / 60)
conflict_words_count = len(data.get('conflict_substring', set()))
conflict_lemmas_count = len(data.get('conflict_lemma', set()))
output += f"⚔️ <b>Режим антиконфликта:</b>\n"
output += f"├─ Статус: ✅ Активен\n"
output += f"├─ Осталось: <code>{format_time_remaining(time_left_minutes)}</code>\n"
output += f"├─ Окончание: {format_datetime(conflict_until)}\n"
output += f"├─ Слов: <code>{conflict_words_count}</code>\n"
output += f"├─ Лемм: <code>{conflict_lemmas_count}</code>\n"
output += f"└─ Эффект: Обычные банворды отключены\n\n"
# Детальная статистика правил
output += f"📋 <b>Правила модерации:</b>\n\n"
output += f"🔴 <b>Постоянные:</b>\n"
output += f"├─ Подстроки: <code>{len(data.get('substring', set()))}</code>\n"
output += f"├─ Леммы: <code>{len(data.get('lemma', set()))}</code>\n"
output += f"└─ Части: <code>{len(data.get('part', set()))}</code>\n\n"
output += f"⏱ <b>Временные:</b>\n"
output += f"├─ Подстроки: <code>{len(data.get('temp_substring', set()))}</code>\n"
output += f"└─ Леммы: <code>{len(data.get('temp_lemma', set()))}</code>\n\n"
output += f"⚔️ <b>Конфликтные:</b>\n"
output += f"├─ Слова: <code>{len(data.get('conflict_substring', set()))}</code>\n"
output += f"└─ Леммы: <code>{len(data.get('conflict_lemma', set()))}</code>\n\n"
output += f"✅ <b>Исключения:</b> <code>{len(data.get('whitelist', set()))}</code>\n\n"
# Информация о кэше
cache_info = stats.get('cache_active', False)
cache_updated = stats.get('cache_updated_at', None)
output += f"💾 <b>Кэш:</b>\n"
output += f"├─ Статус: {'✅ Активен' if cache_info else '❌ Неактивен'}\n"
if cache_updated and isinstance(cache_updated, str):
try:
updated_dt = datetime.fromisoformat(cache_updated)
output += f"└─ Обновлён: {format_datetime(updated_dt)}\n"
except (ValueError, TypeError):
output += f"└─ Обновлён: недавно\n"
else:
output += f"└─ Не обновлялся\n"
# Кнопка возврата
ikb = InlineKeyboardBuilder()
ikb.button(text="◀️ Назад", callback_data="stats:refresh")
await callback.message.edit_text(
text=output,
parse_mode="HTML",
reply_markup=ikb.as_markup()
)
await callback.answer()
except Exception as e:
logger.error(f"Ошибка получения детальной статистики: {e}", log_type="STATS")
await callback.answer("❌ Ошибка загрузки", show_alert=True)
# ================= ТОП СПАМЕРОВ =================
@router.callback_query(F.data == "stats:top_spammers")
@log_action(action_name="VIEW_TOP_SPAMMERS")
async def stats_top_spammers_callback(callback: CallbackQuery) -> None:
"""Показывает топ-10 спамеров"""
manager = get_manager()
try:
top_spammers = await manager.get_top_spammers(limit=10)
output = "🏆 <b>ТОП-10 СПАМЕРОВ</b>\n\n"
if top_spammers:
max_count = top_spammers[0][1] if top_spammers else 1
for idx, (user_id, count) in enumerate(top_spammers, 1):
bar = create_text_bar(count, max_count, length=10)
# Эмодзи для топ-3
if idx == 1:
medal = "🥇"
elif idx == 2:
medal = "🥈"
elif idx == 3:
medal = "🥉"
else:
medal = f"{idx}."
output += f"{medal} <code>{user_id}</code>\n"
output += f" └─ {format_number(count)} удалений [{bar}]\n\n"
# Общая статистика
total_spammers = len(top_spammers)
total_deletions = sum(count for _, count in top_spammers)
output += f"📊 <b>Статистика:</b>\n"
output += f"├─ Всего пользователей: <code>{total_spammers}</code>\n"
output += f"└─ Всего удалений: <code>{format_number(total_deletions)}</code>\n\n"
output += "💡 <i>ID можно использовать для проверки пользователя</i>"
else:
output += "└─ <i>Нет данных об удалениях</i>\n\n"
output += "💡 <i>Когда бот начнёт удалять сообщения, здесь появится статистика</i>"
# Кнопка возврата
ikb = InlineKeyboardBuilder()
ikb.button(text="◀️ Назад", callback_data="stats:refresh")
await callback.message.edit_text(
text=output,
parse_mode="HTML",
reply_markup=ikb.as_markup()
)
await callback.answer()
except Exception as e:
logger.error(f"Ошибка получения топ спамеров: {e}", log_type="STATS")
await callback.answer("❌ Ошибка загрузки", show_alert=True)
# ================= ТОП СЛОВ =================
@router.callback_query(F.data == "stats_top_words")
async def stats_top_words_callback(callback: CallbackQuery) -> None:
"""Показывает топ-10 самых частых срабатываний"""
await callback.answer()
manager = get_manager()
# Получаем топ слов
top_words = await manager.get_top_words(limit=10)
if not top_words:
text = (
"🔤 <b>ТОП-10 СРАБАТЫВАНИЙ ПО СЛОВАМ</b>\n\n"
"📭 <i>Статистика пока пуста</i>\n\n"
"Срабатывания появятся после удаления\n"
"первых спам-сообщений."
)
else:
text = "🔤 <b>ТОП-10 СРАБАТЫВАНИЙ ПО СЛОВАМ</b>\n\n"
# Эмодзи для типов
type_emoji = {
"substring": "🔤",
"lemma": "📖",
"part": "🧩",
"silence": "🔇",
"conflict_substring": "⚔️",
"conflict_lemma": "⚔️"
}
for i, word_data in enumerate(top_words, 1):
word = word_data['word']
count = word_data['count']
word_type = word_data['type']
emoji = type_emoji.get(word_type, "")
# Медали для топ-3
medal = ""
if i == 1:
medal = "🥇 "
elif i == 2:
medal = "🥈 "
elif i == 3:
medal = "🥉 "
text += f"{medal}<b>{i}.</b> {emoji} <code>{word}</code> — {count} раз\n"
# Общая статистика
total = await manager.get_total_spam_count()
text += f"\n📊 <b>Всего удалено:</b> {total} сообщений"
# Кнопка назад
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="◀️ Назад", callback_data="show_stats")]
])
try:
await callback.message.edit_text(
text=text,
reply_markup=keyboard,
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка показа топ-слов: {e}", log_type="ERROR")
await callback.answer("❌ Ошибка загрузки статистики", show_alert=True)
# ================= СТАТИСТИКА ПОЛЬЗОВАТЕЛЯ =================
@router.message(Command(*COMMANDS.get("userstats", ["userstats"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="VIEW_USER_STATS", log_args=True)
async def user_stats_cmd(message: Message) -> None:
"""
Показывает статистику конкретного пользователя.
Использование: /userstats <ID>
Пример: /userstats 123456789
"""
parts = message.text.split(maxsplit=1)
if len(parts) < 2:
await message.answer(
"❌ Использование: <code>/userstats [ID]</code>\n\n"
"Пример: <code>/userstats 123456789</code>",
parse_mode="HTML"
)
return
try:
user_id = int(parts[1].strip())
except ValueError:
await message.answer("❌ ID должен быть числом", parse_mode="HTML")
return
manager = get_manager()
try:
# Получаем статистику пользователя
user_spam_count = await manager.get_user_spam_count(user_id)
user_spam_stats = await manager.get_spam_stats(limit=10, user_id=user_id)
output = f"👤 <b>СТАТИСТИКА ПОЛЬЗОВАТЕЛЯ</b>\n\n"
output += f"🆔 ID: <code>{user_id}</code>\n\n"
if user_spam_count > 0:
output += f"🗑 <b>Удалено сообщений:</b> <code>{format_number(user_spam_count)}</code>\n\n"
if user_spam_stats:
output += f"📝 <b>Последние удаления:</b>\n"
for stat in user_spam_stats[:5]:
deleted_at = stat.deleted_at
matched_word = stat.matched_word or "неизвестно"
match_type = stat.match_type or "unknown"
output += f"├─ {format_datetime(deleted_at)}\n"
output += f"│ └─ Слово: <code>{matched_word}</code> ({match_type})\n"
output += "\n"
else:
output += "✅ <i>Нет нарушений</i>\n\n"
output += "Этот пользователь не нарушал правила чата"
await message.answer(output, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка получения статистики пользователя: {e}", log_type="STATS")
await message.answer("❌ <b>Ошибка загрузки статистики</b>", parse_mode="HTML")
# ================= СБРОС СТАТИСТИКИ =================
@router.message(Command(*COMMANDS.get("resetstats", ["resetstats"]), prefix=settings.PREFIX, ignore_case=True),
IsAdmin())
@log_action(action_name="RESET_STATS")
async def reset_stats_cmd(message: Message) -> None:
"""
Сбрасывает всю статистику удалений.
⚠️ ВНИМАНИЕ: Это действие необратимо!
Использование: /resetstats confirm
"""
parts = message.text.split(maxsplit=1)
if len(parts) < 2 or parts[1].lower() != "confirm":
await message.answer(
"⚠️ <b>ВНИМАНИЕ!</b>\n\n"
"Эта команда удалит ВСЮ статистику удалений:\n"
"• Счётчики удалений пользователей\n"
"• Историю удалённых сообщений\n"
"• Топ спамеров\n\n"
"Правила модерации НЕ будут удалены.\n\n"
"Для подтверждения используйте:\n"
"<code>/resetstats confirm</code>",
parse_mode="HTML"
)
return
manager = get_manager()
try:
# Сбрасываем статистику
deleted_count = await manager.reset_spam_stats()
if deleted_count > 0:
await message.answer(
f"✅ <b>Статистика сброшена</b>\n\n"
f"Удалено записей: {deleted_count}\n\n"
f"Новые данные начнут собираться\n"
f"с этого момента.",
parse_mode="HTML"
)
logger.warning(
f"Статистика сброшена пользователем {message.from_user.id}: "
f"удалено {deleted_count} записей",
log_type="STATS"
)
else:
await message.answer(
" <b>Статистика уже пуста</b>",
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка сброса статистики: {e}", log_type="STATS")
await message.answer("❌ <b>Ошибка сброса статистики</b>", parse_mode="HTML")

View File

@@ -0,0 +1,546 @@
"""
Обработчики команд добавления и удаления банвордов
"""
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message
from bot.filters.admin import IsAdmin
from configs import settings, COMMANDS
from database import get_manager
from database.models import BanWordType
from middleware.loggers import logger
from bot.utils.decorators import log_action
__all__ = ("router",)
router: Router = Router(name="manage_words_router")
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
def parse_args(text: str, command: str, min_args: int = 1, max_args: int = 2) -> tuple[bool, str | list]:
"""
Парсит аргументы команды.
Args:
text: Полный текст сообщения
command: Название команды
min_args: Минимальное количество аргументов
max_args: Максимальное количество аргументов
Returns:
(success, result): result это либо список аргументов, либо текст ошибки
"""
# Убираем команду из текста
parts = text.split(maxsplit=max_args)
if len(parts) < min_args + 1:
return False, f"❌ Использование: <code>/{command} {'<слово>' if min_args == 1 else '<слово> <минуты>'}</code>"
args = parts[1:]
# Валидация длины слова
if args and len(args[0]) < 2:
return False, "❌ Слово должно содержать минимум 2 символа"
if args and len(args[0]) > 100:
return False, "❌ Слово слишком длинное (максимум 100 символов)"
return True, args
def format_success_message(action: str, word: str, word_type: str, extra: str = "") -> str:
"""Форматирует сообщение об успехе"""
emoji_map = {
'добавлена': '',
'добавлен': '',
'добавлено': '',
'удалена': '🗑',
'удален': '🗑',
'удалено': '🗑'
}
emoji = emoji_map.get(action, '')
message = f"{emoji} <b>{word_type.capitalize()}</b> <code>{word}</code> {action}"
if extra:
message += f"\n{extra}"
return message
# ================= КОМАНДЫ ДОБАВЛЕНИЯ =================
@router.message(Command(*COMMANDS.get("addword", ["addword"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="ADD_WORD", log_args=True)
async def add_word_cmd(message: Message) -> None:
"""
Добавляет банворд-подстроку (постоянно).
Использование: /addword <слово>
"""
success, result = parse_args(message.text, "addword", min_args=1, max_args=1)
if not success:
await message.answer(result, parse_mode="HTML")
return
word = result[0].lower().strip()
manager = get_manager()
try:
added = await manager.add_banword(
word=word,
word_type=BanWordType.SUBSTRING,
added_by=message.from_user.id,
reason=f"Добавлено через команду"
)
if added:
text = format_success_message(
"добавлена",
word,
"подстрока",
"🔍 Тип проверки: простой поиск в тексте"
)
else:
text = f"⚠️ Подстрока <code>{word}</code> уже существует"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка добавления банворда: {e}", log_type="CMD")
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
@router.message(Command(*COMMANDS.get("addlemma", ["addlemma"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="ADD_LEMMA", log_args=True)
async def add_lemma_cmd(message: Message) -> None:
"""
Добавляет банворд-лемму (постоянно).
Использование: /addlemma <слово>
"""
success, result = parse_args(message.text, "addlemma", min_args=1, max_args=1)
if not success:
await message.answer(result, parse_mode="HTML")
return
word = result[0].lower().strip()
manager = get_manager()
try:
added = await manager.add_banword(
word=word,
word_type=BanWordType.LEMMA,
added_by=message.from_user.id,
reason=f"Добавлено через команду"
)
if added:
text = format_success_message(
"добавлена",
word,
"лемма",
"🔤 Тип проверки: все формы слова (купить→куплю, купил, купишь...)"
)
else:
text = f"⚠️ Лемма <code>{word}</code> уже существует"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка добавления леммы: {e}", log_type="CMD")
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
@router.message(Command(*COMMANDS.get("addpart", ["addpart"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="ADD_PART", log_args=True)
async def add_part_cmd(message: Message) -> None:
"""
Добавляет банворд-часть (постоянно).
Использование: /addpart <комбинация>
"""
success, result = parse_args(message.text, "addpart", min_args=1, max_args=1)
if not success:
await message.answer(result, parse_mode="HTML")
return
word = result[0].lower().strip()
manager = get_manager()
try:
added = await manager.add_banword(
word=word,
word_type=BanWordType.PART,
added_by=message.from_user.id,
reason=f"Добавлено через команду"
)
if added:
text = format_success_message(
"добавлена",
word,
"часть",
"🧩 Тип проверки: поиск без пробелов (обходит \"к у п и т ь\")"
)
else:
text = f"⚠️ Часть <code>{word}</code> уже существует"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка добавления части: {e}", log_type="CMD")
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
@router.message(Command(*COMMANDS.get("addtempword", ["addtempword"]), prefix=settings.PREFIX, ignore_case=True),
IsAdmin())
@log_action(action_name="ADD_TEMP_WORD", log_args=True)
async def add_temp_word_cmd(message: Message) -> None:
"""
Добавляет временную банворд-подстроку.
Использование: /addtempword <слово> <минуты>
"""
success, result = parse_args(message.text, "addtempword", min_args=2, max_args=2)
if not success:
await message.answer(result, parse_mode="HTML")
return
word = result[0].lower().strip()
# Валидация минут
try:
minutes = int(result[1])
if minutes < 1 or minutes > 10080: # Максимум неделя
await message.answer("❌ Время должно быть от 1 минуты до 10080 минут (7 дней)", parse_mode="HTML")
return
except ValueError:
await message.answer("❌ Неверный формат времени. Укажите число минут", parse_mode="HTML")
return
manager = get_manager()
try:
added = await manager.add_temp_banword(
word=word,
word_type=BanWordType.SUBSTRING,
minutes=minutes,
added_by=message.from_user.id
)
if added:
# Форматируем время
if minutes < 60:
time_str = f"{minutes} мин"
elif minutes < 1440:
hours = minutes // 60
mins = minutes % 60
time_str = f"{hours}ч {mins}м" if mins else f"{hours}ч"
else:
days = minutes // 1440
hours = (minutes % 1440) // 60
time_str = f"{days}д {hours}ч" if hours else f"{days}д"
text = format_success_message(
"добавлена",
word,
"временная подстрока",
f"⏱ Автоматически удалится через {time_str}"
)
else:
text = f"⚠️ Временная подстрока <code>{word}</code> уже существует"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка добавления временного банворда: {e}", log_type="CMD")
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
@router.message(Command(*COMMANDS.get("addtemplemma", ["addtemplemma"]), prefix=settings.PREFIX, ignore_case=True),
IsAdmin())
@log_action(action_name="ADD_TEMP_LEMMA", log_args=True)
async def add_temp_lemma_cmd(message: Message) -> None:
"""
Добавляет временную банворд-лемму.
Использование: /addtemplemma <слово> <минуты>
"""
success, result = parse_args(message.text, "addtemplemma", min_args=2, max_args=2)
if not success:
await message.answer(result, parse_mode="HTML")
return
word = result[0].lower().strip()
try:
minutes = int(result[1])
if minutes < 1 or minutes > 10080:
await message.answer("❌ Время должно быть от 1 минуты до 10080 минут (7 дней)", parse_mode="HTML")
return
except ValueError:
await message.answer("❌ Неверный формат времени. Укажите число минут", parse_mode="HTML")
return
manager = get_manager()
try:
added = await manager.add_temp_banword(
word=word,
word_type=BanWordType.LEMMA,
minutes=minutes,
added_by=message.from_user.id
)
if added:
if minutes < 60:
time_str = f"{minutes} мин"
elif minutes < 1440:
hours = minutes // 60
mins = minutes % 60
time_str = f"{hours}ч {mins}м" if mins else f"{hours}ч"
else:
days = minutes // 1440
hours = (minutes % 1440) // 60
time_str = f"{days}д {hours}ч" if hours else f"{days}д"
text = format_success_message(
"добавлена",
word,
"временная лемма",
f"⏱ Автоматически удалится через {time_str}"
)
else:
text = f"⚠️ Временная лемма <code>{word}</code> уже существует"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка добавления временной леммы: {e}", log_type="CMD")
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
@router.message(Command(*COMMANDS.get("addexcept", ["addexcept"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="ADD_EXCEPTION", log_args=True)
async def add_exception_cmd(message: Message) -> None:
"""
Добавляет исключение в whitelist.
Использование: /addexcept <текст>
"""
success, result = parse_args(message.text, "addexcept", min_args=1, max_args=1)
if not success:
await message.answer(result, parse_mode="HTML")
return
word = result[0].lower().strip()
manager = get_manager()
try:
added = await manager.add_whitelist(
word=word,
added_by=message.from_user.id,
reason="Добавлено через команду"
)
if added:
text = format_success_message(
"добавлено",
word,
"исключение",
"✅ Сообщения с этим текстом не будут проверяться"
)
else:
text = f"⚠️ Исключение <code>{word}</code> уже существует"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка добавления исключения: {e}", log_type="CMD")
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
# ================= КОМАНДЫ УДАЛЕНИЯ =================
@router.message(Command(*COMMANDS.get("remword", ["remword"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="REMOVE_WORD", log_args=True)
async def remove_word_cmd(message: Message) -> None:
"""
Удаляет банворд-подстроку.
Использование: /remword <слово>
"""
success, result = parse_args(message.text, "remword", min_args=1, max_args=1)
if not success:
await message.answer(result, parse_mode="HTML")
return
word = result[0].lower().strip()
manager = get_manager()
try:
removed = await manager.remove_banword(word=word, word_type=BanWordType.SUBSTRING)
if removed:
text = format_success_message("удалена", word, "подстрока")
else:
text = f"⚠️ Подстрока <code>{word}</code> не найдена"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка удаления банворда: {e}", log_type="CMD")
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
@router.message(Command(*COMMANDS.get("remlemma", ["remlemma"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="REMOVE_LEMMA", log_args=True)
async def remove_lemma_cmd(message: Message) -> None:
"""Удаляет банворд-лемму"""
success, result = parse_args(message.text, "remlemma", min_args=1, max_args=1)
if not success:
await message.answer(result, parse_mode="HTML")
return
word = result[0].lower().strip()
manager = get_manager()
try:
removed = await manager.remove_banword(word=word, word_type=BanWordType.LEMMA)
if removed:
text = format_success_message("удалена", word, "лемма")
else:
text = f"⚠️ Лемма <code>{word}</code> не найдена"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка удаления леммы: {e}", log_type="CMD")
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
@router.message(Command(*COMMANDS.get("rempart", ["rempart"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="REMOVE_PART", log_args=True)
async def remove_part_cmd(message: Message) -> None:
"""Удаляет банворд-часть"""
success, result = parse_args(message.text, "rempart", min_args=1, max_args=1)
if not success:
await message.answer(result, parse_mode="HTML")
return
word = result[0].lower().strip()
manager = get_manager()
try:
removed = await manager.remove_banword(word=word, word_type=BanWordType.PART)
if removed:
text = format_success_message("удалена", word, "часть")
else:
text = f"⚠️ Часть <code>{word}</code> не найдена"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка удаления части: {e}", log_type="CMD")
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
@router.message(Command(*COMMANDS.get("remtempword", ["remtempword"]), prefix=settings.PREFIX, ignore_case=True),
IsAdmin())
@log_action(action_name="REMOVE_TEMP_WORD", log_args=True)
async def remove_temp_word_cmd(message: Message) -> None:
"""Удаляет временную подстроку"""
success, result = parse_args(message.text, "remtempword", min_args=1, max_args=1)
if not success:
await message.answer(result, parse_mode="HTML")
return
word = result[0].lower().strip()
manager = get_manager()
try:
removed = await manager.remove_temp_banword(word=word, word_type=BanWordType.SUBSTRING)
if removed:
text = format_success_message("удалена", word, "временная подстрока")
else:
text = f"⚠️ Временная подстрока <code>{word}</code> не найдена"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка удаления временного банворда: {e}", log_type="CMD")
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
@router.message(Command(*COMMANDS.get("remtemplemma", ["remtemplemma"]), prefix=settings.PREFIX, ignore_case=True),
IsAdmin())
@log_action(action_name="REMOVE_TEMP_LEMMA", log_args=True)
async def remove_temp_lemma_cmd(message: Message) -> None:
"""Удаляет временную лемму"""
success, result = parse_args(message.text, "remtemplemma", min_args=1, max_args=1)
if not success:
await message.answer(result, parse_mode="HTML")
return
word = result[0].lower().strip()
manager = get_manager()
try:
removed = await manager.remove_temp_banword(word=word, word_type=BanWordType.LEMMA)
if removed:
text = format_success_message("удалена", word, "временная лемма")
else:
text = f"⚠️ Временная лемма <code>{word}</code> не найдена"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка удаления временной леммы: {e}", log_type="CMD")
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
@router.message(Command(*COMMANDS.get("remexcept", ["remexcept"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="REMOVE_EXCEPTION", log_args=True)
async def remove_exception_cmd(message: Message) -> None:
"""Удаляет исключение из whitelist"""
success, result = parse_args(message.text, "remexcept", min_args=1, max_args=1)
if not success:
await message.answer(result, parse_mode="HTML")
return
word = result[0].lower().strip()
manager = get_manager()
try:
removed = await manager.remove_whitelist(word=word)
if removed:
text = format_success_message("удалено", word, "исключение")
else:
text = f"⚠️ Исключение <code>{word}</code> не найдено"
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка удаления исключения: {e}", log_type="CMD")
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")

View File

@@ -0,0 +1,15 @@
from aiogram import Router
from .default_msg import router as default_message_router
from .ping_test import router as ping_test_message_router
# Настройка экспорта и роутера
router: Router = Router(name=__name__)
# Подготовка роутера команд
# router.include_routers(
# ping_test_message_router,
# )
# Подключение стандартного роутера
router.include_router(default_message_router)

View File

@@ -0,0 +1,11 @@
from aiogram import Router
from aiogram.types import Message
# Настройки экспорта и роутера
router: Router = Router(name=__name__)
@router.message()
async def default_msg(message: Message) -> None:
"""Обработчик всех необработанных сообщений."""
return

View File

@@ -0,0 +1,32 @@
from aiogram import Router
from aiogram.types import Message
router: Router = Router(name=__name__)
# Словарь с ответами по ключам
RESPONSE_DICT: dict[str, str] = {
"пинг": "Понг! 🏓",
"понг": "Пинг!",
"бот": "На месте! 🤖",
}
@router.message()
async def auto_response_handler(message: Message) -> None:
"""Обработчик автоматических ответов по ключевым словам."""
if not message.text:
return
text_lower: str = message.text.casefold().strip()
# Поиск точного совпадения
if text_lower in RESPONSE_DICT:
response: str = RESPONSE_DICT[text_lower]
await message.answer(response)
return
# Поиск частичного совпадения (если хотите расширенную функциональность)
for key, response in RESPONSE_DICT.items():
if key in text_lower and len(key) > 3: # Только для ключей длиннее 3 символов
await message.answer(response)
return

View File

@@ -0,0 +1,2 @@
from .inline import *
from .reply import *

17
bot/keyboards/inline.py Normal file
View File

@@ -0,0 +1,17 @@
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
def decision_keyboard(thread_id: int, kind: str) -> InlineKeyboardMarkup:
"""
Получение клавиатуры Принятия\Отклонить.
:param thread_id: Айди действия.
:param kind: Вид для клавиатуры.
:return: Инлайн-клавиатуру (Принять, Отклонить).
"""
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
ikb.row(
InlineKeyboardButton(text="✅ Принять", callback_data=f"{kind}:accept:{thread_id}"),
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"{kind}:reject:{thread_id}")
)
return ikb.as_markup()

View File

@@ -0,0 +1 @@
from .decision import *

View File

@@ -0,0 +1,18 @@
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
def decision_keyboard(thread_id: int, kind: str) -> InlineKeyboardMarkup:
"""
Получение клавиатуры Принятия\Отклонить.
:param thread_id: Айди действия.
:param kind: Вид для клавиатуры.
:return: Инлайн-клавиатуру (Принять, Отклонить).
"""
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
ikb.row(
InlineKeyboardButton(text="✅ Принять", callback_data=f"{kind}:accept:{thread_id}"),
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"{kind}:reject:{thread_id}")
)
return ikb.as_markup()

0
bot/keyboards/reply.py Normal file
View File

View File

137
bot/middlewares/__init__.py Normal file
View File

@@ -0,0 +1,137 @@
"""
Middleware для бота PrimoGuardBot.
Порядок выполнения middleware важен:
1. TimingMiddleware - замер времени выполнения
2. LoggingMiddleware - логирование всех событий
3. BanCheckMiddleware - проверка статуса бана (блокирует забаненных)
4. ErrorHandlingMiddleware - обработка ошибок (последний)
Message-level middleware:
1. RateLimitMiddleware/AntiSpamMiddleware - защита от флуда
2. SubscriptionMiddleware - проверка подписки на каналы
3. ReferralMiddleware - обработка реферальных ссылок
"""
from aiogram import Dispatcher, Bot
from configs import settings
from middleware.loggers import logger
from .error_mdw import ErrorHandlingMiddleware
from .logging_mdw import LoggingMiddleware
from .referal_mdw import ReferralMiddleware
from .spam_mdw import AntiSpamMiddleware, spam_stats
from .sub_mdw import SubscriptionMiddleware
from .time_mdw import TimingMiddleware
from .banwords_mdw import BanWordsMiddleware
__all__ = (
# Middleware классы
"TimingMiddleware",
"LoggingMiddleware",
"ErrorHandlingMiddleware",
"AntiSpamMiddleware",
"SubscriptionMiddleware",
"ReferralMiddleware",
"BanWordsMiddleware",
# Статистика
"spam_stats",
# Утилиты
"setup_middlewares",
)
def setup_middlewares(
dp: Dispatcher,
bot: Bot,
admin_ids: list[int] = settings.ADMIN_ID+settings.OWNER_ID,
channel_ids: list[int | str] | None = None,
enable_spam_check: bool = False,
enable_subscription_check: bool = False,
) -> dict:
"""
Регистрирует все middleware в диспетчере.
Args:
dp: Диспетчер aiogram
bot: Экземпляр бота
admin_ids: ID администраторов (для защиты и уведомлений)
channel_ids: ID каналов для проверки подписки
enable_spam_check: Включить антиспам
enable_subscription_check: Включить проверку подписки
Returns:
dict: Словарь с экземплярами middleware для доступа к методам
"""
channel_ids = channel_ids or []
# === UPDATE LEVEL MIDDLEWARE (для всех событий) ===
middlewares_updates = []
instances = {}
# 1. Timing - замер времени (первый!)
timing_mdw = TimingMiddleware()
middlewares_updates.append(timing_mdw)
instances['timing'] = timing_mdw
# 2. Logging - логирование всех событий
loggings_mdw = LoggingMiddleware()
middlewares_updates.append(loggings_mdw)
instances['logging'] = loggings_mdw
# 3. ErrorHandling - обработка ошибок (последний!)
errors_mdw = ErrorHandlingMiddleware(admin_ids=admin_ids)
middlewares_updates.append(errors_mdw)
instances['error'] = errors_mdw
# === MESSAGE LEVEL MIDDLEWARE (только для сообщений) ===
middlewares_msg = []
# 1. AntiSpam - защита от флуда (опционально)
if enable_spam_check:
spams_mdw = AntiSpamMiddleware()
middlewares_msg.append(spams_mdw)
instances['spam'] = spams_mdw
# 2. Subscription - проверка подписки на каналы (опционально)
if enable_subscription_check and channel_ids:
subs_mdw = SubscriptionMiddleware(bot=bot, channels=channel_ids)
middlewares_msg.append(subs_mdw)
instances['subscription'] = subs_mdw
dp.message.middleware(BanWordsMiddleware())
# 3. Referral - обработка реферальных ссылок
referral_mdw = ReferralMiddleware()
middlewares_msg.append(referral_mdw)
instances['referral'] = referral_mdw
# === РЕГИСТРАЦИЯ MIDDLEWARE ===
# Регистрируем update-level middleware
for middleware in middlewares_updates:
dp.update.middleware(middleware)
# Регистрируем message-level middleware
for middleware in middlewares_msg:
dp.message.middleware(middleware)
# Логируем успешную регистрацию
enabled_features = []
if enable_spam_check:
enabled_features.append("AntiSpam")
if enable_subscription_check:
enabled_features.append("Subscription")
logger.info(
text=(
f"Middleware зарегистрированы: "
f"Update={len(middlewares_updates)}, "
f"Message={len(middlewares_msg)}, "
f"Функции=[{', '.join(enabled_features) if enabled_features else 'базовые'}]"
),
log_type="MIDDLEWARE_SETUP"
)
return instances

View File

@@ -0,0 +1,337 @@
"""
Middleware для проверки сообщений на запрещённые слова (банворды).
Pipeline проверки:
1. Пропускаем админов и служебные сообщения
2. Проверяем whitelist (исключения)
3. Проверяем режим silence (удаляем всё)
4. Проверяем режим conflict (конфликтные слова)
5. Проверяем постоянные банворды (substring, lemma, part)
6. Проверяем временные банворды
7. Если найдено - удаляем, логируем, уведомляем админов
"""
from typing import Callable, Dict, Any, Awaitable, Optional
import re
from aiogram import BaseMiddleware
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.exceptions import TelegramBadRequest
from configs import settings
from database import get_manager, BanWordType
from bot.special import process_text, extract_words, get_lemma
from middleware.loggers import logger
__all__ = ("BanWordsMiddleware",)
class BanWordsMiddleware(BaseMiddleware):
"""
Middleware для фильтрации сообщений с банвордами.
Проверяет каждое текстовое сообщение на наличие запрещённых слов,
удаляет спам и уведомляет администраторов.
"""
def __init__(self):
"""Инициализирует middleware"""
super().__init__()
self.manager = get_manager()
async def __call__(
self,
handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],
event: Message,
data: Dict[str, Any]
) -> Any:
"""
Обрабатывает входящие сообщения.
Args:
handler: Следующий обработчик в цепочке
event: Сообщение от пользователя
data: Данные из диспетчера
Returns:
Any: Результат обработчика или None (если сообщение удалено)
"""
# Пропускаем не-текстовые сообщения
if not event.text and not event.caption:
return await handler(event, data)
# Получаем текст (из text или caption)
message_text = event.text or event.caption
# Пропускаем команды (начинаются с /)
if message_text.startswith('/'):
return await handler(event, data)
# Проверяем, является ли пользователь админом
user_id = event.from_user.id
is_super_admin = user_id in settings.OWNER_ID
is_admin = is_super_admin or self.manager.is_admin_cached(user_id)
# Админы пропускаются
if is_admin:
return await handler(event, data)
# Проверяем сообщение на банворды
spam_result = await self._check_message(message_text)
if spam_result:
# Найден спам - удаляем и уведомляем
await self._handle_spam(event, spam_result)
return None # Не продолжаем обработку
# Сообщение чистое - пропускаем дальше
return await handler(event, data)
@staticmethod
def _normalize_for_part_check(text: str) -> str:
"""
Нормализует текст для проверки частей слов.
Удаляет ВСЕ символы кроме букв и цифр, приводит к нижнему регистру.
Args:
text: Исходный текст
Returns:
str: Нормализованный текст (только буквы и цифры, нижний регистр)
Examples:
"@Astrixkeepbot" -> "astrixkeepbot"
"hello@world.com" -> "helloworldcom"
"test_123-456" -> "test123456"
"""
# Оставляем только буквы и цифры
return re.sub(r'[^a-zA-Zа-яА-ЯёЁ0-9]', '', text.lower())
async def _check_message(self, text: str) -> Optional[Dict[str, str]]:
"""
Проверяет сообщение на наличие банвордов.
Args:
text: Текст сообщения
Returns:
Optional[Dict]: {"word": "найденное_слово", "type": "тип_проверки"} или None
"""
# Нормализуем текст для проверки
text_lower = text.lower()
text_processed = process_text(text_lower)
# === 1. WHITELIST (исключения) ===
if self.manager.is_whitelisted(text_processed):
logger.debug(
f"Сообщение содержит whitelist слово: '{text_processed[:50]}'",
log_type="BANWORDS"
)
return None
# === 2. SILENCE MODE (удаляем всё) ===
if await self.manager.is_silence_active():
return {
"word": "[режим тишины]",
"type": "silence"
}
# === 3. CONFLICT MODE (конфликтные слова) ===
if await self.manager.is_conflict_active():
# Проверяем конфликтные подстроки
conflict_substring = self.manager.get_banwords_cached(
BanWordType.CONFLICT_SUBSTRING
)
for word in conflict_substring:
if word in text_processed:
return {"word": word, "type": "conflict_substring"}
# Проверяем конфликтные леммы
conflict_lemma = self.manager.get_banwords_cached(
BanWordType.CONFLICT_LEMMA
)
words_in_text = extract_words(text_processed)
for word_text in words_in_text:
lemma = get_lemma(word_text)
if lemma in conflict_lemma:
return {"word": lemma, "type": "conflict_lemma"}
# === 4. SUBSTRING (подстроки) ===
substring_words = self.manager.get_banwords_cached(BanWordType.SUBSTRING)
for word in substring_words:
if word in text_processed:
return {"word": word, "type": "substring"}
# === 5. PART (части слов без пробелов и спецсимволов) ===
part_words = self.manager.get_banwords_cached(BanWordType.PART)
if part_words:
# Специальная нормализация для PART: удаляем ВСЁ кроме букв и цифр
text_normalized = self._normalize_for_part_check(text)
logger.debug(
f"Проверка PART: исходный='{text[:50]}', нормализованный='{text_normalized[:50]}'",
log_type="BANWORDS"
)
for part in part_words:
# Нормализуем само запрещенное слово тоже
part_normalized = self._normalize_for_part_check(part)
if part_normalized in text_normalized:
logger.info(
f"Найдена запрещенная часть: '{part}' (нормализовано: '{part_normalized}') "
f"в тексте '{text_normalized[:100]}'",
log_type="BANWORDS"
)
return {"word": part, "type": "part"}
# === 6. LEMMA (нормальные формы слов) ===
lemma_words = self.manager.get_banwords_cached(BanWordType.LEMMA)
if lemma_words:
words_in_text = extract_words(text_processed)
for word_text in words_in_text:
lemma = get_lemma(word_text)
if lemma in lemma_words:
return {"word": lemma, "type": "lemma"}
# Банворды не найдены
return None
async def _handle_spam(
self,
message: Message,
spam_result: Dict[str, str]
) -> None:
"""
Обрабатывает найденный спам: удаляет, логирует, уведомляет.
Args:
message: Сообщение со спамом
spam_result: Результат проверки (слово + тип)
"""
user = message.from_user
matched_word = spam_result["word"]
match_type = spam_result["type"]
# Получаем текст сообщения
message_text = message.text or message.caption or "[нет текста]"
# === 1. УДАЛЯЕМ СООБЩЕНИЕ ===
try:
await message.delete()
logger.info(
f"Удалено сообщение от @{user.username or user.id} "
f"(слово: '{matched_word}', тип: {match_type})",
log_type="BANWORDS",
message=message
)
except TelegramBadRequest as e:
logger.error(
f"Не удалось удалить сообщение: {e}",
log_type="ERROR",
message=message
)
return
# === 2. ЛОГИРУЕМ В БД ===
await self.manager.log_spam(
user_id=user.id,
username=user.username or f"id{user.id}",
chat_id=message.chat.id,
message_text=message_text,
matched_word=matched_word,
match_type=match_type
)
# === 3. УВЕДОМЛЯЕМ АДМИНОВ ===
await self._notify_admins(message, matched_word, match_type, message_text)
async def _notify_admins(
self,
message: Message,
matched_word: str,
match_type: str,
message_text: str
) -> None:
"""
Отправляет уведомление в админский чат с кнопками.
Args:
message: Удалённое сообщение
matched_word: Слово, по которому сработал фильтр
match_type: Тип проверки
message_text: Текст сообщения
"""
user = message.from_user
username = f"@{user.username}" if user.username else f"ID: {user.id}"
# Получаем количество предыдущих нарушений
spam_count = await self.manager.get_user_spam_count(user.id)
# Формируем текст уведомления
notification_text = (
f"🚫 <b>Удалено сообщение</b>\n\n"
f"👤 <b>Пользователь:</b> {username}\n"
f"🆔 <b>ID:</b> <code>{user.id}</code>\n"
f"📊 <b>Нарушений:</b> {spam_count}\n\n"
f"🔍 <b>Триггер:</b> <code>{matched_word}</code>\n"
f"📝 <b>Тип:</b> {self._get_type_emoji(match_type)} {match_type}\n\n"
f"💬 <b>Текст:</b>\n"
f"<code>{self._escape_html(message_text[:500])}</code>"
)
# Создаём клавиатуру с действиями
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="🔨 Забанить",
callback_data=f"spam_ban:{user.id}:{message.chat.id}"
),
InlineKeyboardButton(
text="✅ Закрыть",
callback_data="spam_close"
)
],
[
InlineKeyboardButton(
text="📊 Статистика",
callback_data=f"spam_stats:{user.id}"
)
]
])
# Отправляем уведомление
try:
bot = message.bot
await bot.send_message(
chat_id=settings.ADMIN_CHAT_ID,
text=notification_text,
reply_markup=keyboard,
parse_mode="HTML"
)
except Exception as e:
logger.error(
f"Ошибка отправки уведомления админам: {e}",
log_type="ERROR"
)
@staticmethod
def _get_type_emoji(match_type: str) -> str:
"""Возвращает эмодзи для типа проверки"""
emoji_map = {
"substring": "🔤",
"lemma": "📖",
"part": "🧩",
"silence": "🔇",
"conflict_substring": "⚔️",
"conflict_lemma": "⚔️"
}
return emoji_map.get(match_type, "")
@staticmethod
def _escape_html(text: str) -> str:
"""Экранирует HTML символы для безопасного отображения"""
return (
text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)

View File

@@ -0,0 +1,674 @@
"""
Middleware для глобальной обработки ошибок
"""
from typing import Callable, Awaitable, Any, Dict, Optional, List, Set
from datetime import datetime
from collections import defaultdict
from enum import Enum
import traceback
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, Message, CallbackQuery, Update
from aiogram.exceptions import (
TelegramBadRequest,
TelegramForbiddenError,
TelegramNotFound,
TelegramUnauthorizedError,
TelegramRetryAfter,
TelegramAPIError
)
from middleware.loggers import logger
from bot.utils import (
username,
format_content_info,
get_content_type,
safe_answer_callback,
format_duration,
format_timestamp
)
from bot.templates import msg
__all__ = ('ErrorHandlingMiddleware', 'ErrorCategory')
class ErrorCategory(str, Enum):
"""Категории ошибок"""
TELEGRAM_API = "telegram_api" # Ошибки Telegram API
RATE_LIMIT = "rate_limit" # Rate limiting
PERMISSION = "permission" # Права доступа
VALIDATION = "validation" # Валидация данных
DATABASE = "database" # Ошибки БД
HANDLER = "handler" # Ошибки в хендлерах
UNKNOWN = "unknown" # Неизвестные ошибки
class ErrorStats:
"""Статистика ошибок"""
def __init__(self):
# Счетчики по категориям
self.by_category: Dict[ErrorCategory, int] = defaultdict(int)
# Счетчики по типам исключений
self.by_exception: Dict[str, int] = defaultdict(int)
# Последние ошибки (последние 10)
self.recent_errors: List[Dict[str, Any]] = []
self.max_recent = 10
# Общая статистика
self.total_errors: int = 0
self.start_time: datetime = datetime.now()
def add_error(
self,
exception: Exception,
category: ErrorCategory,
user_id: Optional[int] = None,
details: Optional[Dict] = None
):
"""Добавляет ошибку в статистику"""
self.total_errors += 1
self.by_category[category] += 1
self.by_exception[type(exception).__name__] += 1
# Добавляем в последние ошибки
error_info = {
'timestamp': datetime.now(),
'exception': type(exception).__name__,
'message': str(exception),
'category': category,
'user_id': user_id,
'details': details or {}
}
self.recent_errors.append(error_info)
if len(self.recent_errors) > self.max_recent:
self.recent_errors.pop(0)
def get_summary(self) -> Dict[str, Any]:
"""Возвращает сводку по статистике"""
uptime = datetime.now() - self.start_time
return {
'total_errors': self.total_errors,
'uptime': format_duration(int(uptime.total_seconds())),
'by_category': dict(self.by_category),
'by_exception': dict(self.by_exception),
'recent_errors': self.recent_errors
}
class ErrorHandlingMiddleware(BaseMiddleware):
"""
Middleware для глобальной обработки ошибок.
Features:
- Категоризация ошибок
- Уведомление администраторов
- Статистика ошибок
- Rate limiting уведомлений
- Retry механизм для некоторых ошибок
- Детальное логирование
- Graceful degradation
"""
def __init__(
self,
admin_ids: List[int],
notify_admins: bool = True,
notify_users: bool = True,
log_errors: bool = True,
notify_rate_limit: int = 60 # Не чаще раза в минуту для одного типа ошибки
):
"""
Args:
admin_ids: Список ID администраторов
notify_admins: Уведомлять администраторов
notify_users: Уведомлять пользователей
log_errors: Логировать ошибки
notify_rate_limit: Минимальный интервал между уведомлениями (секунды)
"""
super().__init__()
self.admin_ids = admin_ids
self.notify_admins = notify_admins
self.notify_users = notify_users
self.log_errors = log_errors
self.notify_rate_limit = notify_rate_limit
# Статистика
self.stats = ErrorStats()
# Rate limiting для уведомлений
# {error_type: last_notification_time}
self._last_notifications: Dict[str, datetime] = {}
# Игнорируемые ошибки (для которых не нужно уведомлять)
self.ignored_errors: Set[type] = {
TelegramRetryAfter, # Rate limit Telegram
}
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
"""
Обрабатывает ошибки в хендлерах.
Args:
handler: Следующий обработчик
event: Входящее событие
data: Контекстные данные
Returns:
Результат выполнения обработчика или None при ошибке
"""
try:
# Выполняем хендлер
return await handler(event, data)
except Exception as e:
# Обрабатываем ошибку
await self._handle_error(e, event, data)
return None
async def _handle_error(
self,
exception: Exception,
event: TelegramObject,
data: Dict[str, Any]
):
"""
Централизованная обработка ошибки.
Args:
exception: Исключение
event: Событие
data: Контекстные данные
"""
# Определяем категорию ошибки
category = self._categorize_error(exception)
# Извлекаем информацию о событии
event_info = self._extract_event_info(event)
# Добавляем в статистику
self.stats.add_error(
exception=exception,
category=category,
user_id=event_info.get('user_id'),
details=event_info
)
# Логируем ошибку
if self.log_errors:
await self._log_error(exception, category, event_info)
# Уведомляем администраторов
if self.notify_admins and not self._is_ignored(exception):
await self._notify_admins_about_error(exception, category, event_info, event)
# Уведомляем пользователя
if self.notify_users:
await self._notify_user_about_error(exception, category, event)
@staticmethod
def _categorize_error(exception: Exception) -> ErrorCategory:
"""
Определяет категорию ошибки.
Args:
exception: Исключение
Returns:
Категория ошибки
"""
# Ошибки Telegram API
if isinstance(exception, TelegramRetryAfter):
return ErrorCategory.RATE_LIMIT
if isinstance(exception, (TelegramForbiddenError, TelegramUnauthorizedError)):
return ErrorCategory.PERMISSION
if isinstance(exception, (TelegramBadRequest, TelegramNotFound)):
return ErrorCategory.TELEGRAM_API
if isinstance(exception, TelegramAPIError):
return ErrorCategory.TELEGRAM_API
# Ошибки валидации
if isinstance(exception, (ValueError, TypeError, AttributeError)):
return ErrorCategory.VALIDATION
# Ошибки БД (примеры, замени на свои)
# if isinstance(exception, (DatabaseError, OperationalError)):
# return ErrorCategory.DATABASE
# Остальные ошибки
return ErrorCategory.HANDLER
@staticmethod
def _extract_event_info(event: TelegramObject) -> Dict[str, Any]:
"""
Извлекает информацию о событии.
Args:
event: Объект события
Returns:
Словарь с информацией
"""
info: Dict[str, Any] = {
'event_type': type(event).__name__,
'timestamp': datetime.now(),
'user_str': '@System',
'user_id': None,
'chat_id': None,
'chat_type': None,
'message_id': None,
'content_type': None,
'content_info': None,
'text': None
}
# Обработка разных типов событий
message = None
if isinstance(event, Message):
message = event
elif isinstance(event, CallbackQuery):
message = event.message
info['callback_data'] = event.data
elif isinstance(event, Update):
message = (
event.message or
event.edited_message or
event.channel_post or
event.edited_channel_post
)
if event.callback_query:
info['callback_data'] = event.callback_query.data
# Извлекаем информацию из сообщения
if message:
# Пользователь
if message.from_user:
info['user_str'] = username(message)
info['user_id'] = message.from_user.id
# Чат
info['chat_id'] = message.chat.id
info['chat_type'] = message.chat.type
info['message_id'] = message.message_id
# Контент
info['content_type'] = get_content_type(message)
info['content_info'] = format_content_info(message, include_text=False)
# Текст
if message.text:
text = message.text
info['text'] = text if len(text) <= 100 else text[:100] + "..."
elif message.caption:
caption = message.caption
info['caption'] = caption if len(caption) <= 100 else caption[:100] + "..."
return info
@staticmethod
async def _log_error(
exception: Exception,
category: ErrorCategory,
event_info: Dict[str, Any]
):
"""
Логирует ошибку.
Args:
exception: Исключение
category: Категория ошибки
event_info: Информация о событии
"""
# Формируем сообщение для лога
error_type = type(exception).__name__
error_msg = str(exception)
# Получаем traceback
tb = ''.join(traceback.format_exception(
type(exception),
exception,
exception.__traceback__
))
# Базовое сообщение
log_msg = (
f"🚨 Ошибка в хендлере\n"
f"├─ Тип: {error_type}\n"
f"├─ Категория: {category.value}\n"
f"├─ Сообщение: {error_msg}\n"
f"├─ Событие: {event_info['event_type']}\n"
)
if event_info.get('text'):
log_msg += f"├─ Текст: {event_info['text']}\n"
if event_info.get('callback_data'):
log_msg += f"├─ Callback: {event_info['callback_data']}\n"
if event_info.get('content_info'):
log_msg += f"└─ Контент: {event_info['content_info']}"
# Логируем с полным traceback
logger.error(
text=log_msg,
log_type=f"ERROR_{category.value.upper()}",
user=event_info['user_str'],
)
# Дополнительно логируем traceback отдельно для детального анализа
logger.debug(
text=f"Полный traceback:\n{tb}",
log_type=f"ERROR_{category.value.upper()}_TRACEBACK",
user=event_info['user_str']
)
async def _notify_admins_about_error(
self,
exception: Exception,
category: ErrorCategory,
event_info: Dict[str, Any],
event: TelegramObject
):
"""
Уведомляет администраторов об ошибке.
Args:
exception: Исключение
category: Категория ошибки
event_info: Информация о событии
event: Объект события
"""
# Проверяем rate limit
error_key = type(exception).__name__
if not self._should_notify(error_key):
logger.debug(
f"Пропуск уведомления админов о {error_key} (rate limit)",
log_type="ADMIN_NOTIFY_SKIP"
)
return
# Обновляем время последнего уведомления
self._last_notifications[error_key] = datetime.now()
# Получаем bot
bot = event.bot if hasattr(event, 'bot') else None
if not bot:
return
# Формируем сообщение
error_type = type(exception).__name__
error_msg = str(exception)
# Определяем emoji для категории
category_emoji = self._get_category_emoji(category)
notification = (
f"{category_emoji} <b>Ошибка в боте</b>\n\n"
f"📊 <b>Информация:</b>\n"
f"├─ Тип: <code>{error_type}</code>\n"
f"├─ Категория: <code>{category.value}</code>\n"
f"├─ Время: {format_timestamp(datetime.now())}\n"
)
# Добавляем информацию о пользователе
if event_info.get('user_str') and event_info['user_str'] != '@System':
notification += f"└─ Пользователь: {event_info['user_str']}\n\n"
else:
notification += "\n"
# Добавляем сообщение ошибки
if len(error_msg) <= 200:
notification += f"💬 <b>Сообщение:</b>\n<code>{error_msg}</code>\n\n"
else:
notification += f"💬 <b>Сообщение:</b>\n<code>{error_msg[:200]}...</code>\n\n"
# Добавляем контекст события
notification += f"📋 <b>Контекст:</b>\n"
if event_info.get('text'):
notification += f"├─ Текст: <code>{event_info['text']}</code>\n"
if event_info.get('callback_data'):
notification += f"├─ Callback: <code>{event_info['callback_data']}</code>\n"
if event_info.get('content_info'):
notification += f"├─ Контент: {event_info['content_info']}\n"
if event_info.get('chat_type'):
notification += f"└─ Тип чата: <code>{event_info['chat_type']}</code>\n"
# Добавляем статистику
stats = self.stats.get_summary()
notification += (
f"\n📊 <b>Статистика:</b>\n"
f"└─ Всего ошибок: {stats['total_errors']}"
)
# Отправляем администраторам
for admin_id in self.admin_ids:
try:
await bot.send_message(
chat_id=admin_id,
text=notification,
parse_mode="HTML"
)
logger.debug(
f"Администратор {admin_id} уведомлен об ошибке",
log_type="ADMIN_NOTIFIED"
)
except Exception as e:
logger.error(
f"Не удалось уведомить админа {admin_id}: {e}",
log_type="ADMIN_NOTIFY_ERROR"
)
@staticmethod
async def _notify_user_about_error(
exception: Exception,
category: ErrorCategory,
event: TelegramObject
):
"""
Уведомляет пользователя об ошибке.
Args:
exception: Исключение
category: Категория ошибки
event: Объект события
"""
# Формируем сообщение в зависимости от категории
error_messages = {
ErrorCategory.TELEGRAM_API: (
"⚠️ Произошла техническая ошибка.\n"
"Попробуйте повторить действие."
),
ErrorCategory.RATE_LIMIT: (
"⏳ Слишком много запросов.\n"
"Пожалуйста, подождите немного."
),
ErrorCategory.PERMISSION: (
"🔒 Недостаточно прав для выполнения действия."
),
ErrorCategory.VALIDATION: (
"❌ Некорректные данные.\n"
"Проверьте правильность ввода."
),
ErrorCategory.DATABASE: (
"💾 Ошибка базы данных.\n"
"Попробуйте позже."
),
ErrorCategory.HANDLER: (
"⚠️ Произошла непредвиденная ошибка.\n"
"Разработчики уже уведомлены."
),
ErrorCategory.UNKNOWN: (
"⚠️ Произошла ошибка.\n"
"Попробуйте повторить позже."
)
}
error_text = error_messages.get(
category,
error_messages[ErrorCategory.UNKNOWN]
)
error_text += "\n\nПопробуйте нажать /start или обратитесь к администратору."
try:
# Отправляем сообщение
if isinstance(event, Message):
await msg(event, text=error_text)
elif isinstance(event, CallbackQuery):
await safe_answer_callback(event, error_text[:200], show_alert=True)
# Также отправляем в чат если сообщение доступно
if event.message:
try:
await msg(event.message, text=error_text)
except:
pass
elif isinstance(event, Update):
if event.message:
await msg(event.message, text=error_text)
elif event.callback_query:
await safe_answer_callback(
event.callback_query,
error_text[:200],
show_alert=True
)
logger.debug(
"Пользователь уведомлен об ошибке",
log_type="USER_ERROR_NOTIFIED"
)
except Exception as e:
logger.warning(
f"Не удалось уведомить пользователя об ошибке: {e}",
log_type="USER_NOTIFY_ERROR"
)
def _should_notify(self, error_key: str) -> bool:
"""
Проверяет, нужно ли отправлять уведомление (rate limiting).
Args:
error_key: Ключ ошибки
Returns:
True если можно отправить уведомление
"""
if error_key not in self._last_notifications:
return True
last_time = self._last_notifications[error_key]
time_passed = (datetime.now() - last_time).total_seconds()
return time_passed >= self.notify_rate_limit
def _is_ignored(self, exception: Exception) -> bool:
"""
Проверяет, игнорируется ли ошибка.
Args:
exception: Исключение
Returns:
True если ошибка игнорируется
"""
return type(exception) in self.ignored_errors
@staticmethod
def _get_category_emoji(category: ErrorCategory) -> str:
"""Возвращает emoji для категории ошибки"""
emoji_map = {
ErrorCategory.TELEGRAM_API: "🔌",
ErrorCategory.RATE_LIMIT: "",
ErrorCategory.PERMISSION: "🔒",
ErrorCategory.VALIDATION: "",
ErrorCategory.DATABASE: "💾",
ErrorCategory.HANDLER: "🚨",
ErrorCategory.UNKNOWN: "⚠️"
}
return emoji_map.get(category, "⚠️")
def get_stats(self) -> Dict[str, Any]:
"""Возвращает статистику ошибок"""
return self.stats.get_summary()
def reset_stats(self):
"""Сбрасывает статистику"""
self.stats = ErrorStats()
def add_ignored_error(self, error_type: type):
"""Добавляет тип ошибки в игнорируемые"""
self.ignored_errors.add(error_type)
def remove_ignored_error(self, error_type: type):
"""Удаляет тип ошибки из игнорируемых"""
self.ignored_errors.discard(error_type)
# ================= УТИЛИТЫ =================
def format_error_stats(stats: Dict[str, Any]) -> str:
"""
Форматирует статистику ошибок.
Args:
stats: Словарь со статистикой
Returns:
Отформатированная строка
Example:
>> stats = middleware.get_stats()
>> print(format_error_stats(stats))
"""
text = (
f"🚨 <b>Статистика ошибок</b>\n\n"
f"📊 <b>Общая информация:</b>\n"
f"├─ Всего ошибок: {stats['total_errors']}\n"
f"└─ Время работы: {stats['uptime']}\n\n"
)
# По категориям
if stats['by_category']:
text += f"📁 <b>По категориям:</b>\n"
for category, count in stats['by_category'].items():
text += f"├─ {category}: {count}\n"
text += "\n"
# По типам исключений
if stats['by_exception']:
text += f"🔧 <b>По типам (топ-5):</b>\n"
sorted_exceptions = sorted(
stats['by_exception'].items(),
key=lambda x: x[1],
reverse=True
)[:5]
for exc_type, count in sorted_exceptions:
text += f"├─ {exc_type}: {count}\n"
return text

View File

@@ -0,0 +1,350 @@
"""
Middleware для логирования всех событий бота
"""
from typing import Callable, Awaitable, Any, Dict, Optional, Tuple
from datetime import datetime
from aiogram import BaseMiddleware
from aiogram.types import (
TelegramObject,
Update,
Message,
CallbackQuery,
InlineQuery,
ChatMemberUpdated
)
from middleware.loggers import logger
from ..utils import (
username,
get_content_type,
is_command,
parse_command,
is_group_chat
)
__all__ = ('LoggingMiddleware',)
class LoggingMiddleware(BaseMiddleware):
"""
Middleware для детального логирования всех событий бота.
Типы логов:
- CMD: Команды бота
- MSG: Текстовые сообщения
- MEDIA: Медиа сообщения
- CBD: Callback queries
- INLINE: Inline queries
- MEMBER: Изменения участников чата
"""
def __init__(self, project_prefix: str = "PRIMO"):
super().__init__()
self.project_prefix = project_prefix
# Статистика
self.stats = {
'total': 0,
'commands': 0,
'messages': 0,
'callbacks': 0,
'errors': 0
}
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
"""Обрабатывает входящее событие"""
self.stats['total'] += 1
start_time = datetime.now()
# Анализируем событие
log_info = self._analyze_event(event)
if not log_info:
return await handler(event, data)
log_type, log_text, user_str = log_info
# Добавляем префикс проекта
prefixed_log_type = f"{self.project_prefix}-{log_type}"
# Логируем получение события
logger.info(text=log_text, log_type=prefixed_log_type, user=user_str)
try:
# Выполняем обработчик
result = await handler(event, data)
# Вычисляем время обработки
processing_time = (datetime.now() - start_time).total_seconds()
# Логируем успешное выполнение для команд
if log_type == "CMD":
self.stats['commands'] += 1
logger.debug(
text=f"✅ Команда обработана за {processing_time:.3f}s",
log_type=prefixed_log_type,
user=user_str
)
return result
except Exception as e:
self.stats['errors'] += 1
logger.error(
text=f"❌ Ошибка обработки: {str(e)}",
log_type=prefixed_log_type,
user=user_str,
)
raise
def _analyze_event(self, event: TelegramObject) -> Optional[Tuple[str, str, str]]:
"""
Анализирует событие и извлекает информацию для логирования.
Returns:
Tuple: (тип_лога, текст_лога, пользователь) или None
"""
if isinstance(event, Update):
return self._analyze_update(event)
elif isinstance(event, Message):
return self._analyze_message(event)
elif isinstance(event, CallbackQuery):
return self._analyze_callback(event)
elif isinstance(event, InlineQuery):
return self._analyze_inline_query(event)
elif isinstance(event, ChatMemberUpdated):
return self._analyze_member_update(event)
return None
def _analyze_update(self, update: Update) -> Optional[Tuple[str, str, str]]:
"""Анализирует Update объект"""
if update.message:
return self._analyze_message(update.message)
elif update.edited_message:
result = self._analyze_message(update.edited_message)
if result:
log_type, log_text, user_str = result
log_text = f"✏️ [РЕДАКТИРОВАНО] {log_text}"
return log_type, log_text, user_str
elif update.channel_post:
return self._analyze_message(update.channel_post, is_channel=True)
elif update.edited_channel_post:
result = self._analyze_message(update.edited_channel_post, is_channel=True)
if result:
log_type, log_text, user_str = result
log_text = f"✏️ [РЕДАКТИРОВАНО] {log_text}"
return log_type, log_text, user_str
elif update.callback_query:
return self._analyze_callback(update.callback_query)
elif update.inline_query:
return self._analyze_inline_query(update.inline_query)
elif update.my_chat_member:
return self._analyze_member_update(update.my_chat_member)
elif update.chat_member:
return self._analyze_member_update(update.chat_member)
return None
def _analyze_message(self, message: Message, is_channel: bool = False) -> Tuple[str, str, str]:
"""Анализирует сообщение"""
user_str = username(message)
# Формируем префикс с информацией о чате
chat_info = ""
if is_group_chat(message):
chat_info = f"[{message.chat.type.upper()} {message.chat.id}] "
elif is_channel:
chat_info = f"[CHANNEL {message.chat.id}] "
else:
chat_info = f"[PM {message.chat.id}] "
# Проверяем команду
if message.text and is_command(message.text):
self.stats['messages'] += 1
parsed = parse_command(message.text)
if parsed:
log_text = f"{chat_info}📝 Команда: /{parsed.command}"
if parsed.args:
args_str = ' '.join(parsed.args[:3])
if len(parsed.args) > 3:
args_str += f" ... (+{len(parsed.args) - 3})"
log_text += f" | Аргументы: {args_str}"
if parsed.flags:
flags_str = ', '.join(f"--{k}" for k in list(parsed.flags.keys())[:3])
if len(parsed.flags) > 3:
flags_str += f" ... (+{len(parsed.flags) - 3})"
log_text += f" | Флаги: {flags_str}"
return "CMD", log_text, user_str
# Обычное сообщение
self.stats['messages'] += 1
content_type = get_content_type(message, russian=True)
content_emoji = self._get_content_emoji(message)
# Текстовое сообщение
if message.text:
text_preview = message.text
if len(text_preview) > 100:
text_preview = text_preview[:100] + "..."
log_text = f"{chat_info}{content_emoji} Сообщение ({len(message.text)} симв.): {text_preview!r}"
# Медиа с caption
elif message.caption:
caption_preview = message.caption
if len(caption_preview) > 50:
caption_preview = caption_preview[:50] + "..."
log_text = f"{chat_info}{content_emoji} {content_type}"
# Добавляем детали медиа
media_details = self._get_media_details_str(message)
if media_details:
log_text += f" {media_details}"
log_text += f" | Описание: {caption_preview!r}"
# Медиа без caption
else:
log_text = f"{chat_info}{content_emoji} {content_type}"
media_details = self._get_media_details_str(message)
if media_details:
log_text += f" {media_details}"
# Определяем тип лога
log_type = "MEDIA" if message.content_type != "text" else "MSG"
# Добавляем префикс канала
if is_channel:
log_text = f"📢 {log_text}"
return log_type, log_text, user_str
def _analyze_callback(self, callback: CallbackQuery) -> Tuple[str, str, str]:
"""Анализирует callback query"""
self.stats['callbacks'] += 1
user_str = f"@{callback.from_user.username}" if callback.from_user.username else f"id{callback.from_user.id}"
callback_data = callback.data or "None"
if len(callback_data) > 50:
callback_data = callback_data[:50] + "..."
chat_info = f"[MSG {callback.message.message_id}] " if callback.message else ""
log_text = f"{chat_info}🔘 Callback: {callback_data!r}"
return "CBD", log_text, user_str
@staticmethod
def _analyze_inline_query(inline_query: InlineQuery) -> Tuple[str, str, str]:
"""Анализирует inline query"""
user_str = f"@{inline_query.from_user.username}" if inline_query.from_user.username else f"id{inline_query.from_user.id}"
query = inline_query.query or ""
if len(query) > 50:
query = query[:50] + "..."
log_text = f"🔍 Inline запрос: {query!r}"
return "INLINE", log_text, user_str
@staticmethod
def _analyze_member_update(update: ChatMemberUpdated) -> Tuple[str, str, str]:
"""Анализирует изменения участников"""
user_str = f"@{update.from_user.username}" if update.from_user.username else f"id{update.from_user.id}"
old_status = update.old_chat_member.status
new_status = update.new_chat_member.status
chat_info = f"[{update.chat.type.upper()} {update.chat.id}] "
log_text = f"{chat_info}👥 Изменение статуса: {old_status}{new_status}"
return "MEMBER", log_text, user_str
@staticmethod
def _get_content_emoji(message: Message) -> str:
"""Возвращает emoji для типа контента"""
emoji_map = {
'text': '💬',
'photo': '📷',
'video': '🎥',
'animation': '🎞️',
'audio': '🎵',
'voice': '🎤',
'video_note': '🎬',
'document': '📄',
'sticker': '🎨',
'location': '📍',
'contact': '👤',
'poll': '📊',
'dice': '🎲'
}
return emoji_map.get(message.content_type, '📎')
@staticmethod
def _get_media_details_str(message: Message) -> Optional[str]:
"""Возвращает строку с деталями медиа файла"""
from ..utils import get_media_info
try:
media_info = get_media_info(message)
details = []
# Размер файла
if 'file_size_mb' in media_info:
details.append(f"{media_info['file_size_mb']} MB")
elif 'file_size_kb' in media_info:
details.append(f"{media_info['file_size_kb']} KB")
# Длительность
if 'duration_formatted' in media_info:
details.append(media_info['duration_formatted'])
# Разрешение
if 'width' in media_info and 'height' in media_info:
details.append(f"{media_info['width']}x{media_info['height']}")
return f"({', '.join(details)})" if details else None
except:
return None
def get_stats(self) -> Dict[str, int]:
"""Возвращает статистику middleware"""
return self.stats.copy()
def reset_stats(self):
"""Сбрасывает статистику"""
self.stats = {
'total': 0,
'commands': 0,
'messages': 0,
'callbacks': 0,
'errors': 0
}
def format_log_stats(stats: Dict[str, int]) -> str:
"""Форматирует статистику для вывода"""
return (
f"📊 Статистика логирования:\n"
f"├─ 📨 Всего событий: {stats['total']}\n"
f"├─ 📝 Команд: {stats['commands']}\n"
f"├─ 💬 Сообщений: {stats['messages']}\n"
f"├─ 🔘 Callbacks: {stats['callbacks']}\n"
f"└─ ❌ Ошибок: {stats['errors']}"
)

View File

@@ -0,0 +1,544 @@
"""
Middleware для обработки реферальных ссылок и deep links
"""
from typing import Callable, Awaitable, Any, Dict, Optional
from dataclasses import dataclass, field
from datetime import datetime
from collections import defaultdict
import re
from aiogram import BaseMiddleware
from aiogram.filters.command import CommandObject
from aiogram.types import TelegramObject, Message, User
from middleware.loggers import logger
__all__ = (
'ReferralMiddleware',
'DeepLinkData',
'referral_stats',
'ReferralType'
)
class ReferralType:
"""Типы реферальных ссылок"""
REFERRAL = 'ref' # Обычная реферальная ссылка
PROMO = 'promo' # Промокод
UTM = 'utm' # UTM метки
INVITE = 'invite' # Инвайт-ссылка
DEEPLINK = 'deeplink' # Произвольный deep link
CUSTOM = 'custom' # Кастомный тип
@dataclass
class DeepLinkData:
"""
Данные deep link.
Attributes:
raw: Исходная строка (все после /start)
type: Тип ссылки (ref, promo, utm, и т.д.)
params: Распарсенные параметры
user_id: ID пользователя, перешедшего по ссылке
username: Username пользователя
timestamp: Время перехода
is_valid: Валидна ли ссылка
"""
raw: str
type: str = ReferralType.DEEPLINK
params: Dict[str, Any] = field(default_factory=dict)
user_id: Optional[int] = None
username: Optional[str] = None
timestamp: datetime = field(default_factory=datetime.now)
is_valid: bool = True
def get(self, key: str, default: Any = None) -> Any:
"""Получает параметр по ключу"""
return self.params.get(key, default)
def __getitem__(self, key: str) -> Any:
"""Позволяет использовать data['key']"""
return self.params[key]
def __contains__(self, key: str) -> bool:
"""Позволяет использовать 'key' in data"""
return key in self.params
class ReferralStatistics:
"""
Статистика реферальных переходов.
"""
def __init__(self):
# Счетчики переходов по типам: {type: count}
self.clicks_by_type: Dict[str, int] = defaultdict(int)
# Переходы по кодам: {ref_code: count}
self.clicks_by_code: Dict[str, int] = defaultdict(int)
# История переходов: [(timestamp, user_id, ref_code, type), ...]
self.history: list[tuple[datetime, int, str, str]] = []
# Уникальные пользователи: {ref_code: set(user_ids)}
self.unique_users: Dict[str, set[int]] = defaultdict(set)
def record(self, deep_link: DeepLinkData) -> None:
"""Записывает переход"""
# Счетчик по типу
self.clicks_by_type[deep_link.type] += 1
# Счетчик по коду (если есть реферальный код)
ref_code = deep_link.get('ref_code') or deep_link.get('code') or deep_link.raw
if ref_code:
self.clicks_by_code[ref_code] += 1
# Уникальные пользователи
if deep_link.user_id:
self.unique_users[ref_code].add(deep_link.user_id)
# История
if deep_link.user_id:
self.history.append((
deep_link.timestamp,
deep_link.user_id,
ref_code,
deep_link.type
))
def get_stats(self, ref_code: Optional[str] = None) -> Dict[str, Any]:
"""
Возвращает статистику.
Args:
ref_code: Код для фильтрации (если None, возвращает общую статистику)
"""
if ref_code:
return {
'ref_code': ref_code,
'total_clicks': self.clicks_by_code.get(ref_code, 0),
'unique_users': len(self.unique_users.get(ref_code, set()))
}
return {
'total_clicks': sum(self.clicks_by_type.values()),
'clicks_by_type': dict(self.clicks_by_type),
'top_codes': self.get_top_codes(10),
'total_unique_users': sum(len(users) for users in self.unique_users.values())
}
def get_top_codes(self, limit: int = 10) -> list[tuple[str, int]]:
"""Возвращает топ реферальных кодов"""
sorted_codes = sorted(
self.clicks_by_code.items(),
key=lambda x: x[1],
reverse=True
)
return sorted_codes[:limit]
# Глобальная статистика
referral_stats = ReferralStatistics()
class ReferralMiddleware(BaseMiddleware):
"""
Middleware для обработки реферальных ссылок и deep links.
Возможности:
- Парсинг различных форматов deep links
- Автоматическое определение типа ссылки
- Валидация параметров
- Сбор статистики
- Интеграция с базой данных через callback
- Поддержка сложных параметров (ref_123_promo_abc)
Поддерживаемые форматы:
- /start ref123 → {'ref_code': 'ref123'}
- /start promo_SUMMER2024 → {'type': 'promo', 'code': 'SUMMER2024'}
- /start ref_123_bonus_50 → {'ref_code': '123', 'bonus': '50'}
- /start utm_source_telegram → {'utm_source': 'telegram'}
Attributes:
on_referral: Callback функция для сохранения в БД
validator: Функция валидации кодов
parse_complex: Парсить ли сложные параметры
collect_stats: Собирать ли статистику
Example:
```python
from middleware.referral import ReferralMiddleware, DeepLinkData
async def save_referral(deep_link: DeepLinkData):
# Сохранение в БД
await db.save_referral(
user_id=deep_link.user_id,
ref_code=deep_link.get('ref_code'),
timestamp=deep_link.timestamp
)
# Регистрация middleware
referral_mdw = ReferralMiddleware(
on_referral=save_referral,
parse_complex=True,
collect_stats=True
)
dp.message.middleware(referral_mdw)
# В хендлере
@router.message(CommandStart())
async def start(message: Message, deep_link: Optional[DeepLinkData] = None):
if deep_link:
ref_code = deep_link.get('ref_code')
await message.answer(f"Привет! Вы пришли по ссылке: {ref_code}")
else:
await message.answer("Привет!")
```
"""
# Паттерны для парсинга
PATTERNS = {
# ref_123 или ref123
ReferralType.REFERRAL: re.compile(r'^ref[_-]?(\w+)$', re.IGNORECASE),
# promo_SUMMER2024
ReferralType.PROMO: re.compile(r'^promo[_-]?(\w+)$', re.IGNORECASE),
# invite_abc123
ReferralType.INVITE: re.compile(r'^invite[_-]?(\w+)$', re.IGNORECASE),
# utm_source_telegram_campaign_ads
ReferralType.UTM: re.compile(r'^utm[_-]', re.IGNORECASE),
}
def __init__(
self,
on_referral: Optional[Callable[[DeepLinkData], Awaitable[None]]] = None,
validator: Optional[Callable[[str], bool]] = None,
parse_complex: bool = True,
collect_stats: bool = True,
max_length: int = 64
):
"""
Инициализация middleware.
Args:
on_referral: Callback для обработки реферала (сохранение в БД)
validator: Функция валидации кода (должна вернуть True если валиден)
parse_complex: Парсить ли сложные параметры (ref_123_bonus_50)
collect_stats: Собирать ли статистику
max_length: Максимальная длина deep link
"""
super().__init__()
self.on_referral = on_referral
self.validator = validator
self.parse_complex = parse_complex
self.collect_stats = collect_stats
self.max_length = max_length
def _parse_simple(self, args: str) -> tuple[str, Dict[str, Any]]:
"""
Парсит простые форматы deep links.
Args:
args: Аргументы команды /start
Returns:
tuple: (тип, параметры)
"""
# Проверка по паттернам
for link_type, pattern in self.PATTERNS.items():
match = pattern.match(args)
if match:
if link_type == ReferralType.REFERRAL:
return link_type, {'ref_code': match.group(1)}
elif link_type == ReferralType.PROMO:
return link_type, {'code': match.group(1), 'promo_code': match.group(1)}
elif link_type == ReferralType.INVITE:
return link_type, {'invite_code': match.group(1)}
elif link_type == ReferralType.UTM:
# Парсим UTM параметры
return link_type, self._parse_utm(args)
# Если не совпало ни с одним паттерном - просто код
return ReferralType.DEEPLINK, {'code': args}
def _parse_utm(self, args: str) -> Dict[str, Any]:
"""
Парсит UTM параметры: utm_source_telegram_campaign_ads
Args:
args: Строка с UTM параметрами
Returns:
Dict с UTM параметрами
"""
params = {}
# Удаляем префикс utm_
if args.lower().startswith('utm_'):
args = args[4:]
# Разбиваем по _ и парсим пары ключ-значение
parts = args.split('_')
i = 0
while i < len(parts) - 1:
key = f"utm_{parts[i]}"
value = parts[i + 1]
params[key] = value
i += 2
return params
def _parse_complex(self, args: str) -> tuple[str, Dict[str, Any]]:
"""
Парсит сложные форматы: ref_123_bonus_50_promo_SUMMER
Args:
args: Аргументы команды
Returns:
tuple: (тип, параметры)
"""
params = {}
parts = args.split('_')
# Определяем тип по первому элементу
link_type = ReferralType.DEEPLINK
if parts[0].lower() in ['ref', 'referral']:
link_type = ReferralType.REFERRAL
if len(parts) > 1:
params['ref_code'] = parts[1]
parts = parts[2:] # Пропускаем первые 2 элемента
elif parts[0].lower() == 'promo':
link_type = ReferralType.PROMO
if len(parts) > 1:
params['promo_code'] = parts[1]
parts = parts[2:]
elif parts[0].lower() == 'invite':
link_type = ReferralType.INVITE
if len(parts) > 1:
params['invite_code'] = parts[1]
parts = parts[2:]
# Парсим остальные параметры как пары ключ-значение
i = 0
while i < len(parts) - 1:
key = parts[i]
value = parts[i + 1]
# Пытаемся преобразовать в число
try:
value = int(value)
except ValueError:
try:
value = float(value)
except ValueError:
pass # Оставляем строкой
params[key] = value
i += 2
return link_type, params
def _validate_deep_link(self, args: str) -> bool:
"""
Валидирует deep link.
Args:
args: Строка для валидации
Returns:
bool: True если валиден
"""
# Проверка длины
if len(args) > self.max_length:
logger.warning(
f"Deep link слишком длинный: {len(args)} > {self.max_length}",
log_type='REFERRAL'
)
return False
# Проверка на запрещенные символы (только буквы, цифры, _ и -)
if not re.match(r'^[a-zA-Z0-9_-]+$', args):
logger.warning(
f"Deep link содержит недопустимые символы: {args}",
log_type='REFERRAL'
)
return False
# Кастомная валидация
if self.validator:
return self.validator(args)
return True
def _parse_deep_link(self, args: str, user: User) -> DeepLinkData:
"""
Парсит deep link и создает объект DeepLinkData.
Args:
args: Аргументы команды /start
user: Пользователь, перешедший по ссылке
Returns:
DeepLinkData: Распарсенные данные
"""
# Валидация
is_valid = self._validate_deep_link(args)
# Парсинг
if self.parse_complex and '_' in args:
link_type, params = self._parse_complex(args)
else:
link_type, params = self._parse_simple(args)
# Создаем объект
deep_link = DeepLinkData(
raw=args,
type=link_type,
params=params,
user_id=user.id,
username=user.username,
is_valid=is_valid
)
return deep_link
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
"""
Перехватывает команды /start с аргументами.
Args:
handler: Функция хендлера
event: Объект события
data: Дополнительные данные
Returns:
Результат хендлера
"""
# Обрабатываем только сообщения
if not isinstance(event, Message):
return await handler(event, data)
# Извлекаем команду
command: Optional[CommandObject] = data.get('command')
# Проверяем, что это /start с аргументами
if not command or command.command.lower() != 'start' or not command.args:
return await handler(event, data)
user = event.from_user
args = command.args
# Парсим deep link
deep_link = self._parse_deep_link(args, user)
# Логирование
if deep_link.is_valid:
logger.info(
f"Deep link: type={deep_link.type}, params={deep_link.params}",
log_type='REFERRAL',
user=f"@{user.username}" if user.username else f"id{user.id}"
)
else:
logger.warning(
f"Невалидный deep link: {args}",
log_type='REFERRAL',
user=f"@{user.username}" if user.username else f"id{user.id}"
)
# Собираем статистику
if self.collect_stats and deep_link.is_valid:
referral_stats.record(deep_link)
# Вызываем callback для сохранения в БД
if self.on_referral and deep_link.is_valid:
try:
await self.on_referral(deep_link)
except Exception as e:
logger.error(
f"Ошибка в on_referral callback: {e}",
log_type='REFERRAL'
)
# Добавляем deep_link в data для хендлера
data['deep_link'] = deep_link
data['ref_code'] = deep_link.get('ref_code') # Для обратной совместимости
# Выполняем хендлер
return await handler(event, data)
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
def create_deep_link(bot_username: str, **params) -> str:
"""
Создает deep link для бота.
Args:
bot_username: Username бота (без @)
**params: Параметры для ссылки
Returns:
str: Готовая ссылка
Example:
>>> create_deep_link('mybot', ref_code='123', bonus='50')
'https://t.me/mybot?start=ref_123_bonus_50'
"""
# Формируем строку параметров
parts = []
for key, value in params.items():
parts.append(str(key))
parts.append(str(value))
param_string = '_'.join(parts)
return f"https://t.me/{bot_username}?start={param_string}"
def create_referral_link(bot_username: str, ref_code: str) -> str:
"""
Создает простую реферальную ссылку.
Args:
bot_username: Username бота
ref_code: Реферальный код
Returns:
str: Реферальная ссылка
Example:
>>> create_referral_link('mybot', '123')
'https://t.me/mybot?start=ref_123'
"""
return f"https://t.me/{bot_username}?start=ref_{ref_code}"
def create_promo_link(bot_username: str, promo_code: str) -> str:
"""
Создает ссылку с промокодом.
Args:
bot_username: Username бота
promo_code: Промокод
Returns:
str: Ссылка с промокодом
Example:
>>> create_promo_link('mybot', 'SUMMER2024')
'https://t.me/mybot?start=promo_SUMMER2024'
"""
return f"https://t.me/{bot_username}?start=promo_{promo_code}"

575
bot/middlewares/spam_mdw.py Normal file
View File

@@ -0,0 +1,575 @@
"""
Умный middleware для защиты от спама с адаптивными лимитами
"""
from time import time
from typing import Callable, Awaitable, Any, Dict, Optional
from dataclasses import dataclass, field
from datetime import datetime
from collections import Counter
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, Message, CallbackQuery
from middleware.loggers import logger
from configs import settings
__all__ = ('AntiSpamMiddleware', 'spam_stats')
@dataclass
class MessageContext:
"""Контекст сообщения для умной детекции"""
text: Optional[str] = None
is_forward: bool = False
is_reply: bool = False
is_command: bool = False
media_type: Optional[str] = None
callback_data: Optional[str] = None
@dataclass
class UserSpamStats:
"""
Расширенная статистика спама для пользователя.
"""
user_id: int
request_times: list[float] = field(default_factory=list)
message_contexts: list[MessageContext] = field(default_factory=list)
warnings: int = 0
blocked_until: Optional[float] = None
total_requests: int = 0
total_blocks: int = 0
first_seen: Optional[float] = None
last_seen: Optional[float] = None
reputation: float = 1.0 # Репутация пользователя (0.5 - 2.0)
def is_blocked(self, current_time: float) -> bool:
"""Проверяет, заблокирован ли пользователь"""
if self.blocked_until is None:
return False
if current_time < self.blocked_until:
return True
# Разблокировка
self.blocked_until = None
self.warnings = max(0, self.warnings - 1) # Снижаем предупреждения, но не сбрасываем полностью
return False
def get_remaining_block_time(self, current_time: float) -> float:
"""Возвращает оставшееся время блокировки"""
if self.blocked_until is None or current_time >= self.blocked_until:
return 0.0
return self.blocked_until - current_time
def clean_old_requests(self, current_time: float, time_window: float) -> None:
"""Удаляет старые запросы за пределами временного окна"""
cutoff_time = current_time - time_window
# Удаляем старые запросы
new_times = []
new_contexts = []
for req_time, context in zip(self.request_times, self.message_contexts):
if req_time > cutoff_time:
new_times.append(req_time)
new_contexts.append(context)
self.request_times = new_times
self.message_contexts = new_contexts
def add_request(self, current_time: float, context: MessageContext) -> None:
"""Добавляет новый запрос с контекстом"""
self.request_times.append(current_time)
self.message_contexts.append(context)
self.total_requests += 1
self.last_seen = current_time
if self.first_seen is None:
self.first_seen = current_time
def add_warning(self) -> None:
"""Добавляет предупреждение и снижает репутацию"""
self.warnings += 1
self.reputation = max(0.5, self.reputation - 0.1)
def improve_reputation(self) -> None:
"""Улучшает репутацию за хорошее поведение"""
self.reputation = min(2.0, self.reputation + 0.05)
def block(self, current_time: float, duration: float) -> None:
"""Блокирует пользователя"""
self.blocked_until = current_time + duration
self.total_blocks += 1
self.reputation = max(0.5, self.reputation - 0.3)
def detect_spam_patterns(self) -> Dict[str, Any]:
"""
Умная детекция спама на основе паттернов.
Returns:
Dict с результатами анализа
"""
if len(self.message_contexts) < 3:
return {'is_spam': False, 'reason': None, 'severity': 0.0}
recent_contexts = self.message_contexts[-10:] # Последние 10 сообщений
# 1. Проверка идентичных текстовых сообщений
texts = [ctx.text for ctx in recent_contexts if ctx.text and not ctx.is_command]
if texts:
text_counts = Counter(texts)
most_common_text, count = text_counts.most_common(1)[0]
if count >= 5: # 5 одинаковых сообщений подряд
return {
'is_spam': True,
'reason': 'identical_messages',
'severity': 1.0,
'details': f"Повторяющееся сообщение: '{most_common_text[:50]}...'"
}
# 2. Проверка спама callback кнопок
callbacks = [ctx.callback_data for ctx in recent_contexts if ctx.callback_data]
if callbacks:
callback_counts = Counter(callbacks)
most_common_callback, count = callback_counts.most_common(1)[0]
if count >= 8: # 8 нажатий одной кнопки
return {
'is_spam': True,
'reason': 'callback_spam',
'severity': 0.8,
'details': f"Спам кнопки: {most_common_callback}"
}
# 3. Проверка флуда медиа
media_types = [ctx.media_type for ctx in recent_contexts if ctx.media_type]
if len(media_types) >= 7: # 7+ медиафайлов подряд
return {
'is_spam': True,
'reason': 'media_flood',
'severity': 0.6,
'details': f"Флуд медиа: {len(media_types)} файлов"
}
return {'is_spam': False, 'reason': None, 'severity': 0.0}
class SpamStatistics:
"""Глобальная статистика по спаму"""
def __init__(self):
self.users: Dict[int, UserSpamStats] = {}
self.total_blocked_requests: int = 0
self.total_warnings_issued: int = 0
def get_user(self, user_id: int) -> UserSpamStats:
"""Получает или создает статистику пользователя"""
if user_id not in self.users:
self.users[user_id] = UserSpamStats(user_id=user_id)
return self.users[user_id]
def get_top_spammers(self, limit: int = 10) -> list[tuple[int, int]]:
"""Возвращает топ спамеров"""
sorted_users = sorted(
self.users.items(),
key=lambda x: x[1].total_blocks,
reverse=True
)
return [(uid, stats.total_blocks) for uid, stats in sorted_users[:limit]]
def get_stats_summary(self) -> Dict[str, Any]:
"""Возвращает общую статистику"""
return {
'total_users': len(self.users),
'total_blocked_requests': self.total_blocked_requests,
'total_warnings': self.total_warnings_issued,
'active_blocks': sum(
1 for stats in self.users.values()
if stats.blocked_until and stats.blocked_until > time()
)
}
def cleanup(self, max_age: float = 86400.0) -> int:
"""Удаляет старую статистику (24 часа по умолчанию)"""
current_time = time()
cutoff_time = current_time - max_age
users_to_delete = [
uid for uid, stats in self.users.items()
if stats.last_seen and stats.last_seen < cutoff_time
and not stats.is_blocked(current_time)
]
for uid in users_to_delete:
del self.users[uid]
return len(users_to_delete)
# Глобальная статистика
spam_stats = SpamStatistics()
class AntiSpamMiddleware(BaseMiddleware):
"""
Умный антиспам с адаптивными лимитами.
Особенности:
- Различает типы активности (текст, форварды, команды, callback)
- Адаптивные лимиты в зависимости от типа сообщения
- Система репутации пользователей
- Умная детекция спам-паттернов
- Мягкое отношение к пересылкам и ответам
"""
def __init__(
self,
# Базовые лимиты
rate_limit_text: int = 8, # Текстовых сообщений за окно
rate_limit_forward: int = 20, # Пересылок за окно
rate_limit_callback: int = 10, # Нажатий кнопок за окно
rate_limit_media: int = 10, # Медиа за окно
time_window: float = 10.0, # Временное окно (секунды)
# Предупреждения и блокировки
warning_limit: int = 3,
block_duration: float = 120.0, # 2 минуты базовая блокировка
max_block_duration: float = 3600.0, # 1 час максимум
# Опции
whitelist_admins: bool = True,
progressive_blocking: bool = True,
enable_smart_detection: bool = True,
enable_reputation: bool = True,
log_all: bool = False
):
super().__init__()
self.rate_limit_text = rate_limit_text
self.rate_limit_forward = rate_limit_forward
self.rate_limit_callback = rate_limit_callback
self.rate_limit_media = rate_limit_media
self.time_window = time_window
self.warning_limit = warning_limit
self.block_duration = block_duration
self.max_block_duration = max_block_duration
self.whitelist_admins = whitelist_admins
self.progressive_blocking = progressive_blocking
self.enable_smart_detection = enable_smart_detection
self.enable_reputation = enable_reputation
self.log_all = log_all
def _extract_context(self, event: TelegramObject) -> MessageContext:
"""Извлекает контекст из события"""
context = MessageContext()
if isinstance(event, Message):
context.text = event.text or event.caption
context.is_forward = event.forward_date is not None
context.is_reply = event.reply_to_message is not None
context.is_command = bool(context.text and context.text.startswith('/'))
# Определяем тип медиа
if event.photo:
context.media_type = 'photo'
elif event.video:
context.media_type = 'video'
elif event.document:
context.media_type = 'document'
elif event.audio:
context.media_type = 'audio'
elif event.voice:
context.media_type = 'voice'
elif event.sticker:
context.media_type = 'sticker'
elif isinstance(event, CallbackQuery):
context.callback_data = event.data
return context
def _get_effective_rate_limit(self, user_stats: UserSpamStats, context: MessageContext) -> int:
"""Вычисляет эффективный лимит с учётом типа и репутации"""
# Базовый лимит по типу
if context.is_command:
return 999 # Команды не ограничиваем
elif context.callback_data:
base_limit = self.rate_limit_callback
elif context.is_forward:
base_limit = self.rate_limit_forward
elif context.media_type:
base_limit = self.rate_limit_media
else:
base_limit = self.rate_limit_text
# Применяем репутацию
if self.enable_reputation:
base_limit = int(base_limit * user_stats.reputation)
return max(3, base_limit) # Минимум 3 сообщения
def _calculate_block_duration(self, warnings: int) -> float:
"""Вычисляет длительность блокировки"""
if not self.progressive_blocking:
return self.block_duration
multiplier = 2 ** (warnings // self.warning_limit)
duration = self.block_duration * multiplier
return min(duration, self.max_block_duration)
@staticmethod
def _format_duration(seconds: float) -> str:
"""Форматирует длительность"""
if seconds < 60:
return f"{int(seconds)} сек"
elif seconds < 3600:
return f"{int(seconds / 60)} мин"
else:
return f"{int(seconds / 3600)} час"
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Optional[Any]:
"""Основная логика проверки"""
# Пропускаем не-сообщения и не-callback
if not isinstance(event, (Message, CallbackQuery)):
return await handler(event, data)
user_id = event.from_user.id if event.from_user else None
if user_id is None:
return await handler(event, data)
user_str = f"@{event.from_user.username}" if event.from_user.username else f"id{user_id}"
# Whitelist для администраторов
if self.whitelist_admins and user_id in (settings.OWNER_ID + settings.ADMIN_ID):
if self.log_all:
logger.debug(f"Администратор {user_str} пропущен", log_type='ANTI_SPAM')
return await handler(event, data)
current_time = time()
user_stats = spam_stats.get_user(user_id)
# Проверка блокировки
if user_stats.is_blocked(current_time):
remaining = user_stats.get_remaining_block_time(current_time)
spam_stats.total_blocked_requests += 1
logger.warning(
f"Запрос от заблокированного пользователя (осталось {self._format_duration(remaining)})",
log_type='ANTI_SPAM',
user=user_str
)
block_message = (
f"🚫 <b>Вы заблокированы за спам!</b>\n\n"
f"⏳ Оставшееся время: <b>{self._format_duration(remaining)}</b>\n"
f"⚠️ Предупреждений: <b>{user_stats.warnings}</b>"
)
if isinstance(event, Message):
await event.answer(block_message, parse_mode="HTML")
elif isinstance(event, CallbackQuery):
await event.answer(
f"🚫 Заблокирован на {self._format_duration(remaining)}",
show_alert=True
)
return None
# Извлекаем контекст сообщения
context = self._extract_context(event)
# Очищаем старые запросы
user_stats.clean_old_requests(current_time, self.time_window)
# Умная детекция спам-паттернов
if self.enable_smart_detection:
spam_analysis = user_stats.detect_spam_patterns()
if spam_analysis['is_spam']:
user_stats.add_warning()
spam_stats.total_warnings_issued += 1
logger.warning(
f"Обнаружен спам-паттерн: {spam_analysis['reason']} - {spam_analysis['details']}",
log_type='ANTI_SPAM',
user=user_str
)
# Немедленная блокировка при явном спаме
if spam_analysis['severity'] >= 0.9:
block_duration = self._calculate_block_duration(user_stats.warnings)
user_stats.block(current_time, block_duration)
logger.error(
f"Пользователь заблокирован за спам: {spam_analysis['reason']}",
log_type='ANTI_SPAM',
user=user_str
)
block_message = (
f"🚫 <b>Вы заблокированы за спам!</b>\n\n"
f"⏳ Длительность: <b>{self._format_duration(block_duration)}</b>\n"
f"⚠️ Причина: {spam_analysis['details']}"
)
if isinstance(event, Message):
await event.answer(block_message, parse_mode="HTML")
elif isinstance(event, CallbackQuery):
await event.answer(
f"🚫 Блокировка: {spam_analysis['reason']}",
show_alert=True
)
return None
# Получаем эффективный лимит
effective_limit = self._get_effective_rate_limit(user_stats, context)
# Подсчитываем релевантные запросы
relevant_requests = 0
for req_context in user_stats.message_contexts:
if context.is_forward and req_context.is_forward:
relevant_requests += 1
elif context.callback_data and req_context.callback_data:
relevant_requests += 1
elif context.media_type and req_context.media_type:
relevant_requests += 1
elif not (req_context.is_forward or req_context.callback_data or req_context.media_type or req_context.is_command):
relevant_requests += 1
if self.log_all:
logger.debug(
f"Rate limit: {relevant_requests}/{effective_limit} (тип: {context.media_type or 'text'}, репутация: {user_stats.reputation:.2f})",
log_type='ANTI_SPAM',
user=user_str
)
# Проверка лимита
if relevant_requests >= effective_limit:
user_stats.add_warning()
spam_stats.total_warnings_issued += 1
logger.warning(
f"Превышен rate limit ({relevant_requests}/{effective_limit}). "
f"Предупреждение {user_stats.warnings}/{self.warning_limit}",
log_type='ANTI_SPAM',
user=user_str
)
# Блокировка при достижении лимита предупреждений
if user_stats.warnings >= self.warning_limit:
block_duration = self._calculate_block_duration(user_stats.warnings)
user_stats.block(current_time, block_duration)
logger.error(
f"Пользователь заблокирован на {self._format_duration(block_duration)}. "
f"Всего блокировок: {user_stats.total_blocks}",
log_type='ANTI_SPAM',
user=user_str
)
block_message = (
f"🚫 <b>Вы заблокированы за спам!</b>\n\n"
f"⏳ Длительность: <b>{self._format_duration(block_duration)}</b>\n"
f"⚠️ Причина: Превышение лимита запросов\n"
f"📊 Это блокировка #{user_stats.total_blocks}"
)
if isinstance(event, Message):
await event.answer(block_message, parse_mode="HTML")
elif isinstance(event, CallbackQuery):
await event.answer(
f"🚫 Блокировка на {self._format_duration(block_duration)}",
show_alert=True
)
return None
# Предупреждение
warning_message = (
f"⚠️ <b>Предупреждение #{user_stats.warnings}</b>\n\n"
f"Вы отправляете запросы слишком часто!\n"
f"Лимит: {effective_limit} запросов за {self._format_duration(self.time_window)}\n\n"
f"При {self.warning_limit} предупреждениях последует блокировка."
)
if isinstance(event, Message):
await event.answer(warning_message, parse_mode="HTML")
elif isinstance(event, CallbackQuery):
await event.answer(
f"⚠️ Предупреждение {user_stats.warnings}/{self.warning_limit}",
show_alert=True
)
return None
# Добавляем текущий запрос
user_stats.add_request(current_time, context)
# Улучшаем репутацию за нормальное поведение
if self.enable_reputation and user_stats.total_requests % 10 == 0:
user_stats.improve_reputation()
if self.log_all:
logger.debug(
f"Запрос разрешен. Всего: {user_stats.total_requests}, репутация: {user_stats.reputation:.2f}",
log_type='ANTI_SPAM',
user=user_str
)
return await handler(event, data)
# ================= УПРАВЛЕНИЕ =================
async def reset_spam_warnings(user_id: int) -> bool:
"""Сбрасывает предупреждения пользователя"""
if user_id in spam_stats.users:
spam_stats.users[user_id].warnings = 0
spam_stats.users[user_id].blocked_until = None
logger.info(f"Предупреждения сброшены для id{user_id}", log_type='ANTI_SPAM')
return True
return False
async def unblock_user(user_id: int) -> bool:
"""Разблокирует пользователя"""
if user_id in spam_stats.users:
stats = spam_stats.users[user_id]
if stats.blocked_until:
stats.blocked_until = None
stats.warnings = 0
logger.info(f"Пользователь id{user_id} разблокирован вручную", log_type='ANTI_SPAM')
return True
return False
async def get_user_spam_info(user_id: int) -> Optional[Dict[str, Any]]:
"""Получает информацию о спам-статистике пользователя"""
if user_id not in spam_stats.users:
return None
stats = spam_stats.users[user_id]
current_time = time()
return {
'user_id': user_id,
'warnings': stats.warnings,
'reputation': stats.reputation,
'is_blocked': stats.is_blocked(current_time),
'blocked_until': datetime.fromtimestamp(stats.blocked_until) if stats.blocked_until else None,
'remaining_block_time': stats.get_remaining_block_time(current_time),
'total_requests': stats.total_requests,
'total_blocks': stats.total_blocks,
'first_seen': datetime.fromtimestamp(stats.first_seen) if stats.first_seen else None,
'last_seen': datetime.fromtimestamp(stats.last_seen) if stats.last_seen else None
}

553
bot/middlewares/sub_mdw.py Normal file
View File

@@ -0,0 +1,553 @@
"""
Middleware для проверки подписки пользователей на каналы
"""
from time import time
from typing import Callable, Awaitable, Any, Dict, Optional, Union
from dataclasses import dataclass
from aiogram import BaseMiddleware, Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.types import TelegramObject, Message, CallbackQuery, InlineKeyboardButton, Chat
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.enums import ChatMemberStatus
from middleware.loggers import logger
from configs import settings
__all__ = ('SubscriptionMiddleware', 'ChannelConfig')
@dataclass
class ChannelConfig:
"""
Конфигурация канала для проверки подписки.
Attributes:
id: ID или username канала
name: Название канала (для отображения)
invite_link: Пригласительная ссылка
required: Обязательная ли подписка
"""
id: Union[str, int]
name: Optional[str] = None
invite_link: Optional[str] = None
required: bool = True
class SubscriptionCache:
"""
Кэш для проверок подписки.
Уменьшает количество запросов к Telegram API.
"""
def __init__(self, ttl: float = 300.0):
"""
Args:
ttl: Время жизни кэша в секундах (по умолчанию 5 минут)
"""
self.ttl = ttl
# Структура: {(user_id, channel_id): (is_subscribed, timestamp)}
self._cache: Dict[tuple[int, Union[str, int]], tuple[bool, float]] = {}
def get(self, user_id: int, channel_id: Union[str, int]) -> Optional[bool]:
"""
Получает значение из кэша.
Args:
user_id: ID пользователя
channel_id: ID канала
Returns:
bool или None: True/False если в кэше и актуально, иначе None
"""
key = (user_id, channel_id)
if key in self._cache:
is_subscribed, timestamp = self._cache[key]
# Проверяем актуальность
if time() - timestamp < self.ttl:
return is_subscribed
else:
# Удаляем устаревшую запись
del self._cache[key]
return None
def set(self, user_id: int, channel_id: Union[str, int], is_subscribed: bool) -> None:
"""
Сохраняет значение в кэш.
Args:
user_id: ID пользователя
channel_id: ID канала
is_subscribed: Статус подписки
"""
key = (user_id, channel_id)
self._cache[key] = (is_subscribed, time())
def invalidate(self, user_id: Optional[int] = None, channel_id: Optional[Union[str, int]] = None) -> None:
"""
Инвалидирует кэш.
Args:
user_id: ID пользователя (если None, инвалидирует все)
channel_id: ID канала (если None, инвалидирует все для пользователя)
"""
if user_id is None and channel_id is None:
# Полная очистка
self._cache.clear()
elif user_id is not None and channel_id is None:
# Удаляем все записи пользователя
keys_to_delete = [key for key in self._cache if key[0] == user_id]
for key in keys_to_delete:
del self._cache[key]
elif user_id is not None and channel_id is not None:
# Удаляем конкретную запись
key = (user_id, channel_id)
if key in self._cache:
del self._cache[key]
def cleanup(self) -> int:
"""
Удаляет устаревшие записи.
Returns:
int: Количество удаленных записей
"""
current_time = time()
keys_to_delete = [
key for key, (_, timestamp) in self._cache.items()
if current_time - timestamp >= self.ttl
]
for key in keys_to_delete:
del self._cache[key]
return len(keys_to_delete)
class SubscriptionMiddleware(BaseMiddleware):
"""
Middleware для проверки подписки пользователя на каналы.
Возможности:
- Проверка подписки на один или несколько каналов
- Кэширование результатов проверки
- Whitelist для администраторов
- Автоматическое получение ссылок на каналы
- Гибкая настройка обязательных/необязательных каналов
- Красивое сообщение с кнопками подписки
Attributes:
bot: Экземпляр бота
channels: Список конфигураций каналов
cache_ttl: Время жизни кэша в секундах
whitelist_admins: Пропускать ли администраторов бота
show_buttons: Показывать ли кнопки для подписки
Example:
```python
from middleware.subscription import SubscriptionMiddleware, ChannelConfig
channels = [
ChannelConfig(
id="@my_channel",
name="Основной канал",
invite_link="https://t.me/my_channel"
),
ChannelConfig(
id=-1001234567890,
name="Закрытый канал",
required=True
)
]
dp.message.middleware(SubscriptionMiddleware(bot, channels))
dp.callback_query.middleware(SubscriptionMiddleware(bot, channels))
```
"""
def __init__(
self,
bot: Bot,
channels: list[Union[ChannelConfig, str, int]],
cache_ttl: float = 300.0,
whitelist_admins: bool = True,
show_buttons: bool = True,
auto_fetch_links: bool = True
):
"""
Инициализация middleware.
Args:
bot: Экземпляр бота
channels: Список каналов (ChannelConfig, ID или username)
cache_ttl: Время жизни кэша в секундах
whitelist_admins: Пропускать администраторов бота
show_buttons: Показывать кнопки подписки
auto_fetch_links: Автоматически получать ссылки на каналы
"""
super().__init__()
self.bot = bot
self.cache = SubscriptionCache(ttl=cache_ttl)
self.whitelist_admins = whitelist_admins
self.show_buttons = show_buttons
self.auto_fetch_links = auto_fetch_links
# Преобразуем channels в ChannelConfig
self.channels: list[ChannelConfig] = []
for channel in channels:
if isinstance(channel, ChannelConfig):
self.channels.append(channel)
else:
# Простой ID/username -> ChannelConfig
self.channels.append(ChannelConfig(id=channel))
# Кэш информации о каналах
self._channel_info_cache: Dict[Union[str, int], Optional[Chat]] = {}
async def _get_channel_info(self, channel_id: Union[str, int]) -> Optional[Chat]:
"""
Получает информацию о канале.
Args:
channel_id: ID или username канала
Returns:
Chat или None: Информация о канале
"""
if channel_id in self._channel_info_cache:
return self._channel_info_cache[channel_id]
try:
chat = await self.bot.get_chat(channel_id)
self._channel_info_cache[channel_id] = chat
return chat
except (TelegramBadRequest, TelegramForbiddenError) as e:
logger.error(
f"Не удалось получить информацию о канале {channel_id}: {e}",
log_type='SUBSCRIPTION'
)
self._channel_info_cache[channel_id] = None
return None
async def _check_subscription(
self,
user_id: int,
channel_config: ChannelConfig
) -> bool:
"""
Проверяет подписку пользователя на канал.
Args:
user_id: ID пользователя
channel_config: Конфигурация канала
Returns:
bool: True если подписан
"""
channel_id = channel_config.id
# Проверяем кэш
cached = self.cache.get(user_id, channel_id)
if cached is not None:
logger.debug(
f"Использован кэш для проверки подписки на {channel_id}: {cached}",
log_type='SUBSCRIPTION'
)
return cached
# Выполняем проверку
try:
member = await self.bot.get_chat_member(
chat_id=channel_id,
user_id=user_id
)
is_subscribed = member.status in (
ChatMemberStatus.MEMBER,
ChatMemberStatus.ADMINISTRATOR,
ChatMemberStatus.CREATOR
)
# Сохраняем в кэш
self.cache.set(user_id, channel_id, is_subscribed)
logger.debug(
f"Проверка подписки user={user_id} на канал={channel_id}: "
f"{member.status.value} ({'' if is_subscribed else ''})",
log_type='SUBSCRIPTION'
)
return is_subscribed
except TelegramBadRequest as e:
logger.warning(
f"Канал {channel_id} недоступен или неверный: {e}",
log_type='SUBSCRIPTION'
)
# В случае ошибки считаем что не подписан
self.cache.set(user_id, channel_id, False)
return False
except TelegramForbiddenError as e:
logger.error(
f"Бот не имеет доступа к каналу {channel_id}: {e}",
log_type='SUBSCRIPTION'
)
self.cache.set(user_id, channel_id, False)
return False
async def _build_subscription_message(
self,
not_subscribed: list[ChannelConfig]
) -> tuple[str, InlineKeyboardBuilder]:
"""
Создает сообщение и клавиатуру для подписки.
Args:
not_subscribed: Список каналов без подписки
Returns:
tuple: (текст_сообщения, клавиатура)
"""
# Текст сообщения
text = "📢 <b>Для использования бота необходимо подписаться на каналы:</b>\n\n"
# Клавиатура
keyboard = InlineKeyboardBuilder()
for i, channel_config in enumerate(not_subscribed, 1):
# Получаем информацию о канале
channel_info = await self._get_channel_info(channel_config.id)
# Определяем название канала
if channel_config.name:
channel_name = channel_config.name
elif channel_info:
channel_name = channel_info.title
else:
channel_name = f"Канал {i}"
# Добавляем в текст
text += f"{i}. {channel_name}\n"
# Определяем ссылку
invite_link = channel_config.invite_link
if not invite_link and self.auto_fetch_links and channel_info:
# Пытаемся получить ссылку
if channel_info.username:
invite_link = f"https://t.me/{channel_info.username}"
elif channel_info.invite_link:
invite_link = channel_info.invite_link
# Добавляем кнопку если есть ссылка
if invite_link and self.show_buttons:
keyboard.row(
InlineKeyboardButton(
text=f"📌 {channel_name}",
url=invite_link
)
)
text += "\n✅ После подписки нажмите кнопку ниже для проверки."
# Кнопка проверки подписки
keyboard.row(
InlineKeyboardButton(
text="✅ Я подписался",
callback_data="check_subscription"
)
)
return text, keyboard
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Optional[Any]:
"""
Проверяет подписку перед выполнением хендлера.
Args:
handler: Функция хендлера
event: Объект события
data: Дополнительные данные
Returns:
Результат хендлера или None если не подписан
"""
# Пропускаем не-сообщения и не-callback
if not isinstance(event, (Message, CallbackQuery)):
return await handler(event, data)
# Извлекаем user_id
user_id = event.from_user.id if event.from_user else None
if user_id is None:
return await handler(event, data)
user_str = f"@{event.from_user.username}" if event.from_user.username else f"id{user_id}"
# Whitelist для администраторов
if self.whitelist_admins and user_id in settings.super_admin_ids:
logger.debug(
f"Администратор {user_str} пропущен без проверки подписки",
log_type='SUBSCRIPTION'
)
return await handler(event, data)
# Проверяем подписку на все каналы
not_subscribed: list[ChannelConfig] = []
for channel_config in self.channels:
# Пропускаем необязательные каналы
if not channel_config.required:
continue
is_subscribed = await self._check_subscription(user_id, channel_config)
if not is_subscribed:
not_subscribed.append(channel_config)
# Если есть каналы без подписки
if not_subscribed:
logger.info(
f"Пользователь не подписан на {len(not_subscribed)} каналов",
log_type='SUBSCRIPTION',
user=user_str
)
# Создаем сообщение
text, keyboard = await self._build_subscription_message(not_subscribed)
# Отправляем сообщение
if isinstance(event, Message):
await event.answer(
text,
reply_markup=keyboard.as_markup(),
parse_mode="HTML"
)
elif isinstance(event, CallbackQuery):
# Для callback отправляем в чат или редактируем
if event.message:
try:
await event.message.edit_text(
text,
reply_markup=keyboard.as_markup(),
parse_mode="HTML"
)
except:
await event.message.answer(
text,
reply_markup=keyboard.as_markup(),
parse_mode="HTML"
)
await event.answer(
"⚠️ Требуется подписка на каналы",
show_alert=True
)
return None
# Все подписки в порядке
logger.debug(
f"Проверка подписки пройдена",
log_type='SUBSCRIPTION',
user=user_str
)
return await handler(event, data)
def invalidate_cache(
self,
user_id: Optional[int] = None,
channel_id: Optional[Union[str, int]] = None
) -> None:
"""
Публичный метод для инвалидации кэша.
Используется при обработке callback "check_subscription".
Args:
user_id: ID пользователя
channel_id: ID канала
"""
self.cache.invalidate(user_id, channel_id)
# ================= HANDLER ДЛЯ ПРОВЕРКИ ПОДПИСКИ =================
async def handle_check_subscription(
callback: CallbackQuery,
subscription_middleware: SubscriptionMiddleware
):
"""
Обработчик callback для повторной проверки подписки.
Example:
```python
from filters.callback import CallbackStartsWith
from middleware.subscription import handle_check_subscription, subscription_middleware
@router.callback_query(CallbackStartsWith("check_subscription"))
async def check_sub(callback: CallbackQuery):
await handle_check_subscription(callback, subscription_middleware)
```
"""
user_id = callback.from_user.id
# Инвалидируем кэш для пользователя
subscription_middleware.invalidate_cache(user_id=user_id)
await callback.answer("🔄 Проверяю подписку...", show_alert=False)
# Перепроверяем подписку
not_subscribed = []
for channel_config in subscription_middleware.channels:
if not channel_config.required:
continue
is_subscribed = await subscription_middleware._check_subscription(
user_id,
channel_config
)
if not is_subscribed:
not_subscribed.append(channel_config)
if not_subscribed:
# Все еще не подписан
text, keyboard = await subscription_middleware._build_subscription_message(not_subscribed)
await callback.message.edit_text(
text,
reply_markup=keyboard.as_markup(),
parse_mode="HTML"
)
await callback.answer(
f"❌ Вы еще не подписаны на {len(not_subscribed)} каналов",
show_alert=True
)
else:
# Подписка подтверждена
await callback.message.delete()
await callback.message.answer(
"✅ <b>Подписка подтверждена!</b>\n\n"
"Теперь вы можете пользоваться ботом. Используйте /start",
parse_mode="HTML"
)
logger.info(
f"Подписка успешно подтверждена",
log_type='SUBSCRIPTION',
user=f"@{callback.from_user.username}" if callback.from_user.username else f"id{user_id}"
)

311
bot/middlewares/time_mdw.py Normal file
View File

@@ -0,0 +1,311 @@
"""
Middleware для измерения времени выполнения хендлеров
"""
from time import time
from typing import Callable, Awaitable, Any, Dict, Optional
from dataclasses import dataclass
from collections import defaultdict
from datetime import datetime
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, Message, CallbackQuery, Update, User
from middleware.loggers import logger
__all__ = ('TimingMiddleware', 'TimingStats')
@dataclass
class HandlerMetrics:
"""Метрики одного хендлера"""
total_calls: int = 0
total_time: float = 0.0
min_time: float = float('inf')
max_time: float = 0.0
last_call: Optional[datetime] = None
@property
def avg_time(self) -> float:
"""Среднее время выполнения"""
return self.total_time / self.total_calls if self.total_calls > 0 else 0.0
def update(self, execution_time: float) -> None:
"""Обновляет метрики"""
self.total_calls += 1
self.total_time += execution_time
self.min_time = min(self.min_time, execution_time)
self.max_time = max(self.max_time, execution_time)
self.last_call = datetime.now()
class TimingStats:
"""
Глобальная статистика времени выполнения хендлеров.
Хранит метрики для каждого хендлера и предоставляет методы для анализа.
"""
def __init__(self):
self.metrics: Dict[str, HandlerMetrics] = defaultdict(HandlerMetrics)
def record(self, handler_name: str, execution_time: float) -> None:
"""
Записывает время выполнения хендлера.
Args:
handler_name: Имя хендлера
execution_time: Время выполнения в секундах
"""
self.metrics[handler_name].update(execution_time)
def get_stats(self, handler_name: Optional[str] = None) -> Dict[str, Any]:
"""
Возвращает статистику по хендлеру или всем хендлерам.
Args:
handler_name: Имя конкретного хендлера (если None, возвращает все)
Returns:
Dict с метриками
"""
if handler_name:
metrics = self.metrics.get(handler_name)
if not metrics:
return {}
return {
'handler': handler_name,
'total_calls': metrics.total_calls,
'avg_time': f"{metrics.avg_time:.3f}s",
'min_time': f"{metrics.min_time:.3f}s",
'max_time': f"{metrics.max_time:.3f}s",
'last_call': metrics.last_call.strftime('%Y-%m-%d %H:%M:%S') if metrics.last_call else None
}
# Возвращаем статистику по всем хендлерам
return {
name: {
'total_calls': m.total_calls,
'avg_time': f"{m.avg_time:.3f}s",
'min_time': f"{m.min_time:.3f}s",
'max_time': f"{m.max_time:.3f}s"
}
for name, m in sorted(
self.metrics.items(),
key=lambda x: x[1].avg_time,
reverse=True
)
}
def get_slowest(self, limit: int = 10) -> list[tuple[str, float]]:
"""
Возвращает список самых медленных хендлеров.
Args:
limit: Количество хендлеров в результате
Returns:
List кортежей (имя_хендлера, среднееремя)
"""
sorted_handlers = sorted(
self.metrics.items(),
key=lambda x: x[1].avg_time,
reverse=True
)
return [(name, m.avg_time) for name, m in sorted_handlers[:limit]]
def reset(self, handler_name: Optional[str] = None) -> None:
"""
Сбрасывает статистику.
Args:
handler_name: Имя хендлера для сброса (если None, сбрасывает все)
"""
if handler_name:
if handler_name in self.metrics:
del self.metrics[handler_name]
else:
self.metrics.clear()
# Глобальный экземпляр статистики
timing_stats = TimingStats()
class TimingMiddleware(BaseMiddleware):
"""
Middleware для измерения времени выполнения хендлеров.
Возможности:
- Измерение времени выполнения каждого хендлера
- Автоматическая классификация (быстрый/средний/медленный)
- Сбор статистики
- Логирование медленных хендлеров
- Предупреждения о критически медленных запросах
Attributes:
slow_threshold: Порог медленного хендлера (сек)
warning_threshold: Порог критически медленного хендлера (сек)
log_all: Логировать все хендлеры (даже быстрые)
collect_stats: Собирать статистику
Example:
```python
from middleware.timing import TimingMiddleware, timing_stats
# Регистрация middleware
dp.message.middleware(TimingMiddleware(slow_threshold=0.5))
# Получение статистики
stats = timing_stats.get_slowest(5)
for handler, avg_time in stats:
print(f"{handler}: {avg_time:.3f}s")
```
"""
def __init__(
self,
slow_threshold: float = 1.0,
warning_threshold: float = 3.0,
log_all: bool = False,
collect_stats: bool = True
):
"""
Инициализация middleware.
Args:
slow_threshold: Порог медленного хендлера в секундах
warning_threshold: Порог критически медленного хендлера
log_all: Логировать все хендлеры (иначе только медленные)
collect_stats: Собирать статистику выполнения
"""
super().__init__()
self.slow_threshold = slow_threshold
self.warning_threshold = warning_threshold
self.log_all = log_all
self.collect_stats = collect_stats
@staticmethod
def _extract_user_info(event: TelegramObject) -> str:
"""
Извлекает информацию о пользователе из события.
Args:
event: Объект события
Returns:
str: Форматированная строка с информацией о пользователе
"""
user: Optional[User] = None
# Прямое извлечение из Message/CallbackQuery
if isinstance(event, (Message, CallbackQuery)):
user = getattr(event, 'from_user', None)
# Извлечение из Update
elif isinstance(event, Update):
for attr in ['message', 'edited_message', 'callback_query',
'channel_post', 'edited_channel_post', 'inline_query',
'chosen_inline_result', 'my_chat_member', 'chat_member']:
obj = getattr(event, attr, None)
if obj and hasattr(obj, 'from_user'):
user = obj.from_user
break
if user:
return f"@{user.username}" if user.username else f"id{user.id}"
return "@System"
@staticmethod
def _get_handler_name(handler: Callable) -> str:
"""
Получает имя хендлера для логирования.
Args:
handler: Функция хендлера
Returns:
str: Имя хендлера
"""
# Пытаемся получить полное имя с модулем
if hasattr(handler, '__module__') and hasattr(handler, '__name__'):
return f"{handler.__module__}.{handler.__name__}"
elif hasattr(handler, '__name__'):
return handler.__name__
else:
return str(handler)
def _classify_speed(self, execution_time: float) -> tuple[str, str]:
"""
Классифицирует скорость выполнения.
Args:
execution_time: Время выполнения в секундах
Returns:
tuple: (уровень_лога, тип_лога)
"""
if execution_time >= self.warning_threshold:
return 'ERROR', 'CRITICAL_SLOW'
elif execution_time >= self.slow_threshold:
return 'WARNING', 'SLOW_HANDLER'
elif execution_time >= self.slow_threshold / 2:
return 'INFO', 'MEDIUM_HANDLER'
else:
return 'DEBUG', 'FAST_HANDLER'
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
"""
Основной метод middleware.
Args:
handler: Функция хендлера
event: Объект события
data: Дополнительные данные
Returns:
Результат выполнения хендлера
"""
start_time = time()
handler_name = self._get_handler_name(handler)
user_str = self._extract_user_info(event)
# Выполняем хендлер
try:
result = await handler(event, data)
return result
finally:
# Измеряем время
execution_time = time() - start_time
# Собираем статистику
if self.collect_stats:
timing_stats.record(handler_name, execution_time)
# Классифицируем скорость
log_level, log_type = self._classify_speed(execution_time)
# Логируем результат
if self.log_all or execution_time >= self.slow_threshold / 2:
# Формируем сообщение
if execution_time >= self.warning_threshold:
message = f"⚠️ КРИТИЧЕСКИ медленный хендлер '{handler_name}': {execution_time:.3f}с"
elif execution_time >= self.slow_threshold:
message = f"🐌 Медленный хендлер '{handler_name}': {execution_time:.3f}с"
else:
message = f"⏱️ Хендлер '{handler_name}': {execution_time:.3f}с"
# Логируем
logger.log_entry(
level=log_level,
text=message,
log_type=log_type,
user=user_str
)

1
bot/special/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .text_processing import *

View File

@@ -0,0 +1,290 @@
"""
Утилиты для обработки и нормализации текста.
Используется для обнаружения спама и обхода фильтров.
Pipeline обработки текста:
1. unicode_to_ascii() - замена Unicode-символов
2. normalize_text() - латиница → кириллица, удаление диакритики
3. clean_separators() - удаление разделителей ("г е й""гей")
4. get_lemma() - получение нормальной формы слова
"""
import re
import unicodedata
from typing import Set, List
from pymorphy3 import MorphAnalyzer
from configs.mapping import UNICODE_MAP, LATIN_TO_CYRILLIC, CYRILLIC_NORMALIZE
__all__ = (
"unicode_to_ascii",
"normalize_text",
"clean_separators",
"process_text",
"get_lemma",
"get_inflected_forms",
"morph",
"extract_words"
)
# Глобальный экземпляр морфоанализатора (инициализируется один раз)
morph = MorphAnalyzer()
def unicode_to_ascii(text: str) -> str:
"""
Преобразует Unicode-символы в ASCII/кириллические аналоги.
Args:
text: Текст с Unicode-символами
Returns:
str: Текст с нормализованными символами
Examples:
>> unicode_to_ascii("")
"привет"
>> unicode_to_ascii("κупиτь")
"купить"
>> unicode_to_ascii("𝐡𝐞𝐥𝐥𝐨")
"нелло"
"""
return ''.join(UNICODE_MAP.get(char, char) for char in text)
def normalize_text(text: str) -> str:
"""
Нормализует текст для обхода фильтров:
1. Удаляет диакритические знаки (é → e, ė → e)
2. Заменяет латинские буквы на кириллические
3. Заменяет похожие кириллические буквы (укр/бел) на русские
Args:
text: Исходный текст
Returns:
str: Нормализованный текст
Examples:
>> normalize_text("prívét")
"привет"
>> normalize_text("hеllo") # h - кириллическая
"нелло"
>> normalize_text("Київ") # і → и
"Киев"
"""
# Шаг 1: Удаляем диакритические знаки (акценты)
# NFD разбивает символ на базовый + диакритику
text = unicodedata.normalize('NFD', text)
# Mn = Mark, Nonspacing (диакритические знаки)
text = ''.join(char for char in text if unicodedata.category(char) != 'Mn')
# Возвращаем в NFC (композитная форма)
text = unicodedata.normalize('NFC', text)
# Шаг 2: Заменяем латинские → кириллица и нормализуем кириллицу
result: List[str] = []
for char in text:
# Сначала латиница → кириллица
if char in LATIN_TO_CYRILLIC:
result.append(LATIN_TO_CYRILLIC[char])
# Потом нормализуем кириллицу (укр/бел → рус)
elif char in CYRILLIC_NORMALIZE:
result.append(CYRILLIC_NORMALIZE[char])
else:
result.append(char)
return ''.join(result)
def clean_separators(text: str) -> str:
"""
Удаляет разделители между буквами для обнаружения обхода через пробелы/символы.
Args:
text: Исходный текст
Returns:
str: Текст без разделителей между буквами
Examples:
>> clean_separators("г е й")
"гей"
>> clean_separators("г.е")
"гей"
>> clean_separators("г*е")
"гей"
>> clean_separators("к у п и т ь")
"купить"
>> clean_separators("нормальный текст тут")
"нормальный текст тут"
"""
# Удаляем все НЕ буквенно-цифровые символы, кроме пробелов
cleaned: str = re.sub(r'[^\w\s]', '', text, flags=re.UNICODE)
# Убираем множественные пробелы
cleaned = re.sub(r'\s+', ' ', cleaned)
# Убираем пробелы между отдельными буквами
# "г е й" → "гей", но "нормальный текст" остаётся
words = cleaned.split()
result: List[str] = []
temp_chars: List[str] = []
for word in words:
if len(word) == 1:
# Одиночный символ - копим
temp_chars.append(word)
else:
# Полное слово - сначала сбрасываем накопленные символы
if temp_chars:
result.append(''.join(temp_chars))
temp_chars = []
result.append(word)
# Не забываем остаток
if temp_chars:
result.append(''.join(temp_chars))
return ' '.join(result)
def process_text(text: str, remove_spaces: bool = False) -> str:
"""
Полный пайплайн обработки текста для спам-фильтра.
Args:
text: Исходный текст
remove_spaces: Удалить все пробелы (для проверки part-слов)
Returns:
str: Обработанный текст в нижнем регистре
Examples:
>> process_text("Κупи*τь сейчас!")
"купить сейчас"
>> process_text("г е й", remove_spaces=True)
"гей"
"""
# Приводим к нижнему регистру
text = text.casefold()
# Шаг 1: Unicode → ASCII/кириллица
text = unicode_to_ascii(text)
# Шаг 2: Нормализация (латиница → кириллица, диакритика)
text = normalize_text(text)
# Шаг 3: Удаление разделителей
text = clean_separators(text)
# Опционально: удаляем все пробелы (для part-проверки)
if remove_spaces:
text = re.sub(r'\s+', '', text)
return text
def get_lemma(word: str) -> str:
"""
Получает нормальную форму слова (лемму).
Args:
word: Слово для анализа
Returns:
str: Лемма (нормальная форма)
Examples:
>> get_lemma("купил")
"купить"
>> get_lemma("карты")
"карта"
>> get_lemma("хочется")
"хотеться"
"""
try:
parsed = morph.parse(word)[0]
return parsed.normal_form
except (IndexError, Exception):
return word
def get_inflected_forms(base_word: str, limit: int = 50) -> Set[str]:
"""
Получает все словоформы слова через морфологический анализ.
Args:
base_word: Исходное слово
limit: Максимальное количество форм (для экономии памяти)
Returns:
Set[str]: Набор всех словоформ (падежи, числа и т.д.)
Examples:
>> get_inflected_forms("купить")
{'купить', 'куплю', 'купишь', 'купит', ...}
>> get_inflected_forms("карта")
{'карта', 'карты', 'карте', 'карту', ...}
"""
try:
parsed = morph.parse(base_word)[0]
forms: Set[str] = set()
for form in parsed.lexeme:
if len(forms) >= limit:
break
forms.add(form.normal_form)
forms.add(form.word)
return forms
except Exception:
return {base_word}
def extract_words(text: str) -> List[str]:
"""
Извлекает слова из текста (только буквы).
Args:
text: Текст для обработки
Returns:
List[str]: Список слов
Examples:
>> extract_words("Привет, как дела?")
['Привет', 'как', 'дела']
"""
return re.findall(r'\b\w+\b', text, flags=re.UNICODE)
def calculate_similarity(text1: str, text2: str) -> float:
"""
Вычисляет схожесть двух текстов (простая метрика).
Args:
text1: Первый текст
text2: Второй текст
Returns:
float: Коэффициент схожести (0.0 - 1.0)
Examples:
>> calculate_similarity("привет", "привет")
1.0
>> calculate_similarity("купить", "продать")
0.0
"""
processed1 = process_text(text1)
processed2 = process_text(text2)
if processed1 == processed2:
return 1.0
# Levenshtein distance (простой вариант)
len1, len2 = len(processed1), len(processed2)
if len1 == 0 or len2 == 0:
return 0.0
# Считаем совпадающие символы
matches = sum(1 for a, b in zip(processed1, processed2) if a == b)
return matches / max(len1, len2)

0
bot/states/__init__.py Normal file
View File

View File

@@ -0,0 +1 @@
from .message_callback import *

View File

@@ -0,0 +1,818 @@
"""
Универсальные шаблоны для отправки сообщений
"""
from typing import Union, Optional, List, Dict, Callable
from pathlib import Path
from contextlib import suppress
from aiogram import Bot
from aiogram.fsm.context import FSMContext
from aiogram.types import (
Message,
CallbackQuery,
InlineKeyboardMarkup,
ReplyKeyboardMarkup,
ReplyKeyboardRemove,
FSInputFile,
InputMediaPhoto,
InputMediaVideo,
InputMediaAudio,
InputMediaDocument,
BufferedInputFile
)
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.enums import ParseMode, ChatAction
from middleware.loggers import logger
from ..utils.state_utils import safe_answer_callback
from ..utils.auto_delete import auto_delete_manager
__all__ = (
'msg',
'msg_photo',
'msg_video',
'msg_document',
'msg_audio',
'msg_voice',
'msg_media_group',
'edit_msg',
'delete_msg',
'forward_msg',
'send_action',
'markups',
'MessageTemplate',
'batch_send'
)
class MessageTemplate:
"""
Класс для хранения шаблонов сообщений.
Example:
```python
# Создание шаблона
welcome = MessageTemplate(
text="👋 Привет, {name}! Добро пожаловать в {chat}",
parse_mode=ParseMode.HTML
)
# Использование
await welcome.send(
message,
name=user.first_name,
chat=chat.title
)
```
"""
def __init__(
self,
text: str,
parse_mode: Optional[str] = ParseMode.HTML,
disable_web_page_preview: bool = False,
markup: Optional[Union[InlineKeyboardBuilder, InlineKeyboardMarkup]] = None
):
self.text = text
self.parse_mode = parse_mode
self.disable_web_page_preview = disable_web_page_preview
self.markup = markup
def format(self, **kwargs) -> str:
"""Форматирует текст с подстановкой переменных"""
return self.text.format(**kwargs)
async def send(
self,
target: Union[Message, CallbackQuery, int],
bot: Optional[Bot] = None,
**format_kwargs
) -> Optional[Message]:
"""
Отправляет сообщение по шаблону.
Args:
target: Куда отправить (Message, CallbackQuery или chat_id)
bot: Экземпляр бота (если target это chat_id)
**format_kwargs: Переменные для форматирования
"""
text = self.format(**format_kwargs)
if isinstance(target, int):
# Отправка по chat_id
if not bot:
raise ValueError("Bot instance required for chat_id")
return await bot.send_message(
chat_id=target,
text=text,
parse_mode=self.parse_mode,
disable_web_page_preview=self.disable_web_page_preview,
reply_markup=markups(self.markup)
)
else:
# Отправка через Message/CallbackQuery
return await msg(
target,
text=text,
parse_mode=self.parse_mode,
disable_web_page_preview=self.disable_web_page_preview,
markup=self.markup
)
# ================= MARKUP UTILS =================
def markups(
markup: Union[
InlineKeyboardBuilder,
ReplyKeyboardBuilder,
InlineKeyboardMarkup,
ReplyKeyboardMarkup,
ReplyKeyboardRemove,
None
] = None,
resize_keyboard: bool = True,
one_time_keyboard: bool = False
) -> Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove]]:
"""
Конвертирует builder в готовый markup.
Args:
markup: Builder или готовая клавиатура
resize_keyboard: Автоматический размер (для ReplyKeyboard)
one_time_keyboard: Скрыть после нажатия (для ReplyKeyboard)
Returns:
Готовый markup или None
Example:
>> builder = InlineKeyboardBuilder()
>> builder.button(text="Test", callback_data="test")
>> keyboard = markups(builder)
"""
if markup is None:
return None
if isinstance(markup, InlineKeyboardBuilder):
return markup.as_markup()
if isinstance(markup, ReplyKeyboardBuilder):
return markup.as_markup(
resize_keyboard=resize_keyboard,
one_time_keyboard=one_time_keyboard
)
if isinstance(markup, (InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove)):
return markup
return None
# ================= TEXT MESSAGES =================
async def msg(
update: Union[Message, CallbackQuery],
text: str,
state: Optional[FSMContext] = None,
markup: Union[InlineKeyboardBuilder, InlineKeyboardMarkup, None] = None,
parse_mode: Optional[str] = ParseMode.HTML,
disable_web_page_preview: bool = False,
answer_callback: bool = True,
state_clear: bool = False,
edit_if_possible: bool = True,
delete_previous: bool = False,
auto_delete: Optional[int] = None,
disable_notification: bool = False,
protect_content: bool = False,
show_typing: bool = False,
log: bool = False
) -> Optional[Message]:
"""
Универсальная отправка/редактирование текстового сообщения.
Args:
update: Message или CallbackQuery
text: Текст сообщения
state: FSM контекст
markup: Клавиатура
parse_mode: Режим парсинга (HTML, Markdown, None)
disable_web_page_preview: Отключить предпросмотр ссылок
answer_callback: Ответить на callback
state_clear: Очистить состояние
edit_if_possible: Попытаться отредактировать (для callback)
delete_previous: Удалить предыдущее сообщение перед отправкой
auto_delete: Автоудаление через N секунд
disable_notification: Без звука
protect_content: Защита от пересылки
show_typing: Показать "печатает"
log: Логировать отправку
Returns:
Отправленное сообщение
Example:
>> # Простая отправка
>> await msg(message, "Привет!")
>> # С клавиатурой и автоудалением
>> builder = InlineKeyboardBuilder()
>> builder.button(text="OK", callback_data="ok")
>> await msg(
... callback,
... "Сообщение удалится через 10 секунд",
... markup=builder,
... auto_delete=10
... )
"""
# Получаем message объект
message = update.message if isinstance(update, CallbackQuery) else update
if not message:
logger.warning("Невозможно получить message объект", log_type='MESSAGE')
return None
# Показываем typing если нужно
if show_typing:
await send_action(message, ChatAction.TYPING)
# Удаляем предыдущее сообщение если нужно
if delete_previous:
with suppress(TelegramBadRequest, TelegramForbiddenError):
await message.delete()
keyboard = markups(markup)
try:
# Попытка редактирования (для callback)
if edit_if_possible and isinstance(update, CallbackQuery):
sent_message = await message.edit_text(
text=text,
reply_markup=keyboard,
parse_mode=parse_mode,
disable_web_page_preview=disable_web_page_preview
)
if log:
logger.debug(
f"Сообщение отредактировано: {message.message_id}",
log_type='MESSAGE'
)
else:
raise TelegramBadRequest
except (TelegramBadRequest, TelegramForbiddenError):
# Отправка нового сообщения
try:
sent_message = await message.answer(
text=text,
reply_markup=keyboard,
parse_mode=parse_mode,
disable_web_page_preview=disable_web_page_preview,
disable_notification=disable_notification,
protect_content=protect_content
)
if log:
logger.debug(
f"Сообщение отправлено: {sent_message.message_id}",
log_type='MESSAGE'
)
except Exception as e:
logger.error(f"Ошибка отправки сообщения: {e}", log_type='MESSAGE')
return None
# Отвечаем на callback
if answer_callback and isinstance(update, CallbackQuery):
await safe_answer_callback(update)
# Очищаем состояние
if state_clear and state:
await state.clear()
# Планируем автоудаление
if auto_delete and sent_message:
await auto_delete_manager.schedule(
bot=message.bot,
chat_id=sent_message.chat.id,
message_id=sent_message.message_id,
delay=auto_delete,
reason="template_auto_delete"
)
return sent_message
# ================= MEDIA MESSAGES =================
async def msg_photo(
update: Union[Message, CallbackQuery],
photo: Union[str, Path, FSInputFile, BufferedInputFile],
caption: Optional[str] = None,
state: Optional[FSMContext] = None,
markup: Union[InlineKeyboardBuilder, InlineKeyboardMarkup, None] = None,
parse_mode: Optional[str] = ParseMode.HTML,
answer_callback: bool = True,
state_clear: bool = False,
edit_if_possible: bool = True,
auto_delete: Optional[int] = None,
has_spoiler: bool = False,
log: bool = False
) -> Optional[Message]:
"""
Универсальная отправка/редактирование фото.
Args:
update: Message или CallbackQuery
photo: Путь к файлу, FSInputFile или BufferedInputFile
caption: Подпись к фото
state: FSM контекст
markup: Клавиатура
parse_mode: Режим парсинга
answer_callback: Ответить на callback
state_clear: Очистить состояние
edit_if_possible: Попытаться отредактировать
auto_delete: Автоудаление через N секунд
has_spoiler: Спойлер
log: Логировать
Returns:
Отправленное сообщение
Example:
>> await msg_photo(
... message,
... photo="assets/welcome.jpg",
... caption="Добро пожаловать!",
... auto_delete=30
... )
"""
message = update.message if isinstance(update, CallbackQuery) else update
if not message:
return None
# Конвертируем путь в FSInputFile
if isinstance(photo, (str, Path)):
photo = FSInputFile(photo)
keyboard = markups(markup)
try:
# Попытка редактирования медиа
if edit_if_possible and isinstance(update, CallbackQuery):
media = InputMediaPhoto(
media=photo,
caption=caption,
parse_mode=parse_mode,
has_spoiler=has_spoiler
)
await message.edit_media(
media=media,
reply_markup=keyboard
)
sent_message = message
if log:
logger.debug("Фото отредактировано", log_type='MESSAGE')
else:
raise TelegramBadRequest
except (TelegramBadRequest, TelegramForbiddenError):
# Отправка нового фото
try:
sent_message = await message.answer_photo(
photo=photo,
caption=caption,
reply_markup=keyboard,
parse_mode=parse_mode,
has_spoiler=has_spoiler
)
if log:
logger.debug("Фото отправлено", log_type='MESSAGE')
except Exception as e:
logger.error(f"Ошибка отправки фото: {e}", log_type='MESSAGE')
return None
if answer_callback and isinstance(update, CallbackQuery):
await safe_answer_callback(update)
if state_clear and state:
await state.clear()
if auto_delete and sent_message:
await auto_delete_manager.schedule(
bot=message.bot,
chat_id=sent_message.chat.id,
message_id=sent_message.message_id,
delay=auto_delete
)
return sent_message
async def msg_video(
update: Union[Message, CallbackQuery],
video: Union[str, Path, FSInputFile, BufferedInputFile],
caption: Optional[str] = None,
**kwargs
) -> Optional[Message]:
"""
Отправка видео.
Поддерживает те же параметры что и msg_photo.
"""
message = update.message if isinstance(update, CallbackQuery) else update
if not message:
return None
if isinstance(video, (str, Path)):
video = FSInputFile(video)
try:
sent = await message.answer_video(
video=video,
caption=caption,
parse_mode=kwargs.get('parse_mode', ParseMode.HTML),
reply_markup=markups(kwargs.get('markup'))
)
if kwargs.get('answer_callback') and isinstance(update, CallbackQuery):
await safe_answer_callback(update)
if kwargs.get('state_clear') and kwargs.get('state'):
await kwargs['state'].clear()
if kwargs.get('auto_delete'):
await auto_delete_manager.schedule(
bot=message.bot,
chat_id=sent.chat.id,
message_id=sent.message_id,
delay=kwargs['auto_delete']
)
return sent
except Exception as e:
logger.error(f"Ошибка отправки видео: {e}", log_type='MESSAGE')
return None
async def msg_document(
update: Union[Message, CallbackQuery],
document: Union[str, Path, FSInputFile, BufferedInputFile],
caption: Optional[str] = None,
filename: Optional[str] = None,
**kwargs
) -> Optional[Message]:
"""
Отправка документа.
Args:
filename: Имя файла для отображения
:param filename:
:param caption:
:param document:
:param update:
"""
message = update.message if isinstance(update, CallbackQuery) else update
if not message:
return None
if isinstance(document, (str, Path)):
document = FSInputFile(document, filename=filename)
try:
sent = await message.answer_document(
document=document,
caption=caption,
parse_mode=kwargs.get('parse_mode', ParseMode.HTML),
reply_markup=markups(kwargs.get('markup'))
)
if kwargs.get('answer_callback') and isinstance(update, CallbackQuery):
await safe_answer_callback(update)
if kwargs.get('state_clear') and kwargs.get('state'):
await kwargs['state'].clear()
if kwargs.get('auto_delete'):
await auto_delete_manager.schedule(
bot=message.bot,
chat_id=sent.chat.id,
message_id=sent.message_id,
delay=kwargs['auto_delete']
)
return sent
except Exception as e:
logger.error(f"Ошибка отправки документа: {e}", log_type='MESSAGE')
return None
async def msg_audio(
update: Union[Message, CallbackQuery],
audio: Union[str, Path, FSInputFile, BufferedInputFile],
caption: Optional[str] = None,
**kwargs
) -> Optional[Message]:
"""Отправка аудио"""
message = update.message if isinstance(update, CallbackQuery) else update
if not message:
return None
if isinstance(audio, (str, Path)):
audio = FSInputFile(audio)
try:
return await message.answer_audio(
audio=audio,
caption=caption,
parse_mode=kwargs.get('parse_mode', ParseMode.HTML),
reply_markup=markups(kwargs.get('markup'))
)
except Exception as e:
logger.error(f"Ошибка отправки аудио: {e}", log_type='MESSAGE')
return None
async def msg_voice(
update: Union[Message, CallbackQuery],
voice: Union[str, Path, FSInputFile, BufferedInputFile],
caption: Optional[str] = None,
**kwargs
) -> Optional[Message]:
"""Отправка голосового сообщения"""
message = update.message if isinstance(update, CallbackQuery) else update
if not message:
return None
if isinstance(voice, (str, Path)):
voice = FSInputFile(voice)
try:
return await message.answer_voice(
voice=voice,
caption=caption,
parse_mode=kwargs.get('parse_mode', ParseMode.HTML),
reply_markup=markups(kwargs.get('markup'))
)
except Exception as e:
logger.error(f"Ошибка отправки голосового: {e}", log_type='MESSAGE')
return None
async def msg_media_group(
message: Message,
media: List[Union[InputMediaPhoto, InputMediaVideo, InputMediaAudio, InputMediaDocument]],
caption: Optional[str] = None
) -> Optional[List[Message]]:
"""
Отправка media group (альбом).
Args:
message: Объект сообщения
media: Список медиа
caption: Подпись (будет добавлена к первому элементу)
Returns:
Список отправленных сообщений
Example:
>> media = [
... InputMediaPhoto(media=FSInputFile("photo1.jpg")),
... InputMediaPhoto(media=FSInputFile("photo2.jpg")),
... InputMediaVideo(media=FSInputFile("video.mp4"))
... ]
>> await msg_media_group(message, media, caption="Альбом")
"""
if not media:
return None
# Добавляем подпись к первому элементу
if caption and media:
media[0].caption = caption
try:
return await message.answer_media_group(media=media)
except Exception as e:
logger.error(f"Ошибка отправки media group: {e}", log_type='MESSAGE')
return None
# ================= MESSAGE ACTIONS =================
async def edit_msg(
message: Message,
text: Optional[str] = None,
caption: Optional[str] = None,
markup: Optional[InlineKeyboardMarkup] = None,
parse_mode: Optional[str] = ParseMode.HTML
) -> bool:
"""
Безопасное редактирование сообщения.
Returns:
bool: True если успешно отредактировано
"""
try:
if text:
await message.edit_text(
text=text,
reply_markup=markup,
parse_mode=parse_mode
)
elif caption:
await message.edit_caption(
caption=caption,
reply_markup=markup,
parse_mode=parse_mode
)
else:
await message.edit_reply_markup(reply_markup=markup)
return True
except (TelegramBadRequest, TelegramForbiddenError) as e:
logger.debug(f"Не удалось отредактировать сообщение: {e}", log_type='MESSAGE')
return False
async def delete_msg(
message: Message,
delay: Optional[int] = None
) -> bool:
"""
Безопасное удаление сообщения.
Args:
message: Сообщение для удаления
delay: Задержка перед удалением (секунды)
Returns:
bool: True если успешно удалено
"""
if delay:
await auto_delete_manager.schedule(
bot=message.bot,
chat_id=message.chat.id,
message_id=message.message_id,
delay=delay
)
return True
try:
await message.delete()
return True
except (TelegramBadRequest, TelegramForbiddenError):
return False
async def forward_msg(
message: Message,
to_chat_id: int,
disable_notification: bool = False,
protect_content: bool = False
) -> Optional[Message]:
"""
Пересылка сообщения.
Args:
message: Исходное сообщение
to_chat_id: ID чата куда переслать
disable_notification: Без звука
protect_content: Защита от пересылки
Returns:
Пересланное сообщение
"""
try:
return await message.forward(
chat_id=to_chat_id,
disable_notification=disable_notification,
protect_content=protect_content
)
except Exception as e:
logger.error(f"Ошибка пересылки сообщения: {e}", log_type='MESSAGE')
return None
async def send_action(
message: Message,
action: ChatAction = ChatAction.TYPING
) -> bool:
"""
Отправка chat action (печатает, загружает фото и т.д.).
Args:
message: Объект сообщения
action: Тип действия
Returns:
bool: True если успешно
Example:
>> await send_action(message, ChatAction.TYPING)
>> await send_action(message, ChatAction.UPLOAD_PHOTO)
"""
try:
await message.bot.send_chat_action(
chat_id=message.chat.id,
action=action
)
return True
except:
return False
# ================= BATCH SENDING =================
async def batch_send(
bot: Bot,
chat_ids: List[int],
text: str,
markup: Optional[InlineKeyboardMarkup] = None,
parse_mode: Optional[str] = ParseMode.HTML,
disable_notification: bool = False,
on_success: Optional[Callable] = None,
on_error: Optional[Callable] = None
) -> Dict[str, int]:
"""
Массовая рассылка сообщений.
Args:
bot: Экземпляр бота
chat_ids: Список ID чатов
text: Текст сообщения
markup: Клавиатура
parse_mode: Режим парсинга
disable_notification: Без звука
on_success: Callback при успехе (chat_id)
on_error: Callback при ошибке (chat_id, error)
Returns:
Dict со статистикой: {'success': N, 'failed': N}
Example:
>> stats = await batch_send(
... bot,
... [123, 456, 789],
... "Важное объявление!"
... )
>> print(f"Отправлено: {stats['success']}")
"""
import asyncio
success_count = 0
failed_count = 0
for chat_id in chat_ids:
try:
await bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=markup,
parse_mode=parse_mode,
disable_notification=disable_notification
)
success_count += 1
if on_success:
await on_success(chat_id)
except Exception as e:
failed_count += 1
if on_error:
await on_error(chat_id, e)
logger.warning(
f"Не удалось отправить сообщение в чат {chat_id}: {e}",
log_type='BATCH'
)
# Небольшая задержка чтобы избежать rate limit
await asyncio.sleep(0.05)
logger.info(
f"Рассылка завершена: успешно={success_count}, ошибок={failed_count}",
log_type='BATCH'
)
return {
'success': success_count,
'failed': failed_count
}

38
bot/utils/__init__.py Normal file
View File

@@ -0,0 +1,38 @@
"""
Утилиты бота PrimoGuardBot
Модули:
- usernames: Работа с пользователями (username, mentions, display names)
- type_message: Типы контента и чатов
- hidden_username: Упоминания администраторов
- format_time: Форматирование времени и дат
- argument: Парсинг команд и аргументов
- state_utils: Работа с FSM состояниями
- auto_delete: Автоматическое удаление сообщений
- decorators: Декораторы для хендлеров
"""
# ================= USER INFO =================
from .usernames import *
# ================= CONTENT TYPES =================
from .type_message import *
# ================= MENTIONS =================
from .hidden_username import *
# ================= TIME FORMATTING =================
from .format_time import *
# ================= COMMANDS =================
from .argument import *
# ================= STATE UTILS =================
from .state_utils import *
# ================= AUTO DELETE =================
from .auto_delete import *
# ================= DECORATORS =================
from .decorators import *

688
bot/utils/argument.py Normal file
View File

@@ -0,0 +1,688 @@
"""
Утилиты для работы с командами бота
"""
from typing import Optional, Union, Dict, List, Tuple, Set
from dataclasses import dataclass, field
import re
from aiogram.types import Message
from configs import settings
__all__ = (
'is_command',
'find_argument',
'get_command',
'parse_arguments',
'parse_flags',
'CommandParser',
'ParsedCommand',
'parse_command',
'validate_command',
'get_command_usage',
'extract_mentions',
'extract_user_ids',
'extract_hashtags'
)
@dataclass
class ParsedCommand:
"""
Распарсенная команда.
Attributes:
command: Название команды
prefix: Префикс команды
args: Список аргументов
raw_args: Исходная строка аргументов
flags: Словарь флагов (--flag value)
bot_username: Username бота (если было упоминание)
is_group_command: Команда в группе с упоминанием бота
"""
command: str
prefix: str
args: List[str] = field(default_factory=list)
raw_args: Optional[str] = None
flags: Dict[str, Union[str, bool]] = field(default_factory=dict)
bot_username: Optional[str] = None
is_group_command: bool = False
@property
def has_args(self) -> bool:
"""Есть ли аргументы"""
return len(self.args) > 0
@property
def has_flags(self) -> bool:
"""Есть ли флаги"""
return len(self.flags) > 0
def get_arg(self, index: int, default: Optional[str] = None) -> Optional[str]:
"""Получает аргумент по индексу"""
return self.args[index] if index < len(self.args) else default
def get_flag(self, name: str, default: Optional[Union[str, bool]] = None) -> Union[str, bool, None]:
"""Получает значение флага"""
return self.flags.get(name, default)
def __repr__(self) -> str:
return (
f"ParsedCommand(command='{self.command}', "
f"args={self.args}, flags={self.flags})"
)
class CommandParser:
"""
Парсер команд бота.
Возможности:
- Поддержка нескольких префиксов
- Парсинг аргументов
- Парсинг флагов (--flag value, -f value)
- Поддержка упоминаний бота (@botname)
- Парсинг quoted аргументов ("arg with spaces")
- Валидация команд
Example:
```python
parser = CommandParser()
# Парсинг команды
parsed = parser.parse("/ban @user 7d --reason спам")
print(parsed.command) # "ban"
print(parsed.args) # ["@user", "7d"]
print(parsed.flags) # {"reason": "спам"}
```
"""
def __init__(
self,
prefixes: Optional[List[str]] = None,
bot_username: Optional[str] = None
):
"""
Args:
prefixes: Список префиксов (по умолчанию из settings)
bot_username: Username бота для проверки упоминаний
"""
self.prefixes = prefixes or settings.PREFIX
self.bot_username = bot_username
def is_command(self, text: Optional[str]) -> bool:
"""
Проверяет, является ли текст командой.
Args:
text: Текст для проверки
Returns:
bool: True если это команда
Example:
>> parser.is_command("/start")
True
>> parser.is_command("hello")
False
"""
if not text:
return False
text = text.strip()
# Проверяем все префиксы
return any(text.startswith(prefix) for prefix in self.prefixes)
def get_command(
self,
text: Optional[str],
strip_mention: bool = True
) -> Optional[str]:
"""
Извлекает название команды из текста.
Args:
text: Текст сообщения
strip_mention: Убирать упоминание бота (@botname)
Returns:
Optional[str]: Название команды или None
Example:
>> parser.get_command("/start@mybot arg")
'start'
>> parser.get_command("!help")
'help'
"""
if not self.is_command(text):
return None
text = text.strip()
# Находим префикс
prefix = next(p for p in self.prefixes if text.startswith(p))
# Убираем префикс
without_prefix = text[len(prefix):]
# Берем первое слово
command = without_prefix.split()[0] if without_prefix else ""
# Убираем упоминание бота если есть
if strip_mention and '@' in command:
command = command.split('@')[0]
return command.lower() if command else None
def find_argument(self, text: Optional[str]) -> Optional[str]:
"""
Извлекает аргументы команды (все после команды).
Args:
text: Текст сообщения
Returns:
Optional[str]: Аргументы или None
Example:
>> parser.find_argument("/start referrer")
'referrer'
>> parser.find_argument("/ban @user reason text")
'@user reason text'
"""
if not self.is_command(text):
return None
parts = text.strip().split(maxsplit=1)
return parts[1] if len(parts) > 1 else None
@staticmethod
def parse_arguments(
args_text: Optional[str],
preserve_quotes: bool = False
) -> List[str]:
"""
Парсит аргументы, поддерживает кавычки.
Args:
args_text: Строка аргументов
preserve_quotes: Сохранять кавычки в результате
Returns:
List[str]: Список аргументов
Example:
>> parser.parse_arguments('user 7d "ban reason here"')
['user', '7d', 'ban reason here']
"""
if not args_text:
return []
# Regex для парсинга с кавычками
# Поддерживает: "arg with spaces" 'arg' arg
pattern = r'''(?:[^\s"']+|"[^"]*"|'[^']*')+'''
matches = re.findall(pattern, args_text)
if preserve_quotes:
return matches
# Убираем кавычки
return [m.strip('"').strip("'") for m in matches]
@staticmethod
def parse_flags(
args: List[str]
) -> Tuple[List[str], Dict[str, Union[str, bool]]]:
"""
Парсит флаги из аргументов.
Поддерживает:
- --flag value
- --flag (boolean, True)
- -f value (короткая форма)
Args:
args: Список аргументов
Returns:
Tuple: (аргументы_без_флагов, словарь_флагов)
Example:
>> args = ['user', '--reason', 'spam', '--silent']
>> clean_args, flags = parser.parse_flags(args)
>> print(clean_args) # ['user']
>> print(flags) # {'reason': 'spam', 'silent': True}
"""
clean_args = []
flags = {}
i = 0
while i < len(args):
arg = args[i]
# Длинный флаг --flag
if arg.startswith('--'):
flag_name = arg[2:]
# Проверяем, есть ли значение
if i + 1 < len(args) and not args[i + 1].startswith('-'):
flags[flag_name] = args[i + 1]
i += 2
else:
# Boolean флаг
flags[flag_name] = True
i += 1
# Короткий флаг -f
elif arg.startswith('-') and len(arg) == 2:
flag_name = arg[1]
# Проверяем значение
if i + 1 < len(args) and not args[i + 1].startswith('-'):
flags[flag_name] = args[i + 1]
i += 2
else:
flags[flag_name] = True
i += 1
# Обычный аргумент
else:
clean_args.append(arg)
i += 1
return clean_args, flags
def parse(
self,
text: str,
parse_flags: bool = True
) -> Optional[ParsedCommand]:
"""
Полный парсинг команды.
Args:
text: Текст команды
parse_flags: Парсить флаги
Returns:
Optional[ParsedCommand]: Распарсенная команда или None
Example:
>> parsed = parser.parse('/ban @user 7d --reason "spam bot"')
>> print(parsed.command) # 'ban'
>> print(parsed.args) # ['@user', '7d']
>> print(parsed.flags) # {'reason': 'spam bot'}
"""
if not self.is_command(text):
return None
text = text.strip()
# Находим префикс
prefix = next(p for p in self.prefixes if text.startswith(p))
# Убираем префикс
without_prefix = text[len(prefix):]
# Разделяем на команду и аргументы
parts = without_prefix.split(maxsplit=1)
if not parts:
return None
command_part = parts[0]
raw_args = parts[1] if len(parts) > 1 else None
# Проверяем упоминание бота
bot_username = None
is_group_command = False
if '@' in command_part:
cmd_parts = command_part.split('@')
command_name = cmd_parts[0]
bot_username = cmd_parts[1] if len(cmd_parts) > 1 else None
is_group_command = True
else:
command_name = command_part
# Парсим аргументы
args = self.parse_arguments(raw_args) if raw_args else []
# Парсим флаги
flags = {}
if parse_flags and args:
args, flags = self.parse_flags(args)
return ParsedCommand(
command=command_name.lower(),
prefix=prefix,
args=args,
raw_args=raw_args,
flags=flags,
bot_username=bot_username,
is_group_command=is_group_command
)
def parse_from_message(
self,
message: Message,
parse_flags: bool = True
) -> Optional[ParsedCommand]:
"""
Парсит команду из объекта Message.
Args:
message: Объект сообщения
parse_flags: Парсить флаги
Returns:
Optional[ParsedCommand]: Распарсенная команда
Example:
>> parsed = parser.parse_from_message(message)
>> if parsed:
... print(f"Команда: {parsed.command}")
"""
if not message.text:
return None
return self.parse(message.text, parse_flags=parse_flags)
# Глобальный парсер
_default_parser: Optional[CommandParser] = None
def get_parser() -> CommandParser:
"""Получает глобальный парсер команд"""
global _default_parser
if _default_parser is None:
_default_parser = CommandParser()
return _default_parser
# ================= УДОБНЫЕ ФУНКЦИИ =================
def is_command(text: Optional[str]) -> bool:
"""
Проверяет, является ли текст командой.
Args:
text: Текст для проверки
Returns:
bool: True если это команда
Example:
>> is_command("/start")
True
>> is_command("hello")
False
"""
return get_parser().is_command(text)
def find_argument(text: Optional[str]) -> Optional[str]:
"""
Извлекает аргументы команды.
Args:
text: Текст команды
Returns:
Optional[str]: Аргументы или None
Example:
>> find_argument("/start referrer")
'referrer'
>> find_argument("/ban @user spam")
'@user spam'
"""
return get_parser().find_argument(text)
def get_command(text: Optional[str]) -> Optional[str]:
"""
Извлекает название команды.
Args:
text: Текст сообщения
Returns:
Optional[str]: Название команды или None
Example:
>> get_command("/start@mybot")
'start'
>> get_command("!help")
'help'
"""
return get_parser().get_command(text)
def parse_arguments(args_text: Optional[str]) -> List[str]:
"""
Парсит аргументы команды.
Args:
args_text: Строка аргументов
Returns:
List[str]: Список аргументов
Example:
>> parse_arguments('user 7d "ban reason"')
['user', '7d', 'ban reason']
"""
return get_parser().parse_arguments(args_text)
def parse_flags(args: List[str]) -> Tuple[List[str], Dict[str, Union[str, bool]]]:
"""
Парсит флаги из аргументов.
Args:
args: Список аргументов
Returns:
Tuple: (аргументы, флаги)
Example:
>> args = ['user', '--reason', 'spam', '--silent']
>> clean_args, flags = parse_flags(args)
>> print(flags) # {'reason': 'spam', 'silent': True}
"""
return get_parser().parse_flags(args)
def parse_command(text: str) -> Optional[ParsedCommand]:
"""
Полный парсинг команды.
Args:
text: Текст команды
Returns:
Optional[ParsedCommand]: Распарсенная команда
Example:
>> parsed = parse_command('/ban @user --reason spam')
>> print(parsed.command) # 'ban'
>> print(parsed.args) # ['@user']
>> print(parsed.flags) # {'reason': 'spam'}
"""
return get_parser().parse(text)
# ================= ВАЛИДАЦИЯ КОМАНД =================
def validate_command(
text: str,
expected_command: str,
min_args: int = 0,
max_args: Optional[int] = None,
required_flags: Optional[Set[str]] = None
) -> Tuple[bool, Optional[str]]:
"""
Валидирует команду.
Args:
text: Текст команды
expected_command: Ожидаемая команда
min_args: Минимальное количество аргументов
max_args: Максимальное количество аргументов
required_flags: Обязательные флаги
Returns:
Tuple[bool, Optional[str]]: (валидна, сообщение_об_ошибке)
Example:
>> valid, error = validate_command(
... '/ban user',
... 'ban',
... min_args=1,
... max_args=2
... )
>> if not valid:
... print(error)
"""
parsed = parse_command(text)
if not parsed:
return False, "Невалидная команда"
# Проверка команды
if parsed.command != expected_command:
return False, f"Ожидалась команда '{expected_command}'"
# Проверка количества аргументов
arg_count = len(parsed.args)
if arg_count < min_args:
return False, f"Недостаточно аргументов (минимум {min_args})"
if max_args is not None and arg_count > max_args:
return False, f"Слишком много аргументов (максимум {max_args})"
# Проверка обязательных флагов
if required_flags:
missing_flags = required_flags - set(parsed.flags.keys())
if missing_flags:
return False, f"Отсутствуют обязательные флаги: {', '.join(missing_flags)}"
return True, None
def get_command_usage(
command: str,
args: List[str],
flags: Optional[Dict[str, str]] = None,
description: Optional[str] = None
) -> str:
"""
Формирует строку использования команды.
Args:
command: Название команды
args: Список аргументов
flags: Словарь флагов с описанием
description: Описание команды
Returns:
str: Форматированная строка использования
Example:
>> usage = get_command_usage(
... 'ban',
... ['<user>', '[duration]'],
... {'reason': 'Причина бана', 'silent': 'Тихий бан'},
... 'Банит пользователя'
... )
>> print(usage)
"""
lines = []
# Описание
if description:
lines.append(f"📝 {description}\n")
# Использование
args_str = ' '.join(args)
lines.append(f"<b>Использование:</b>")
lines.append(f"<code>/{command} {args_str}</code>\n")
# Аргументы
if args:
lines.append("<b>Аргументы:</b>")
for arg in args:
# Определяем обязательность
if arg.startswith('<') and arg.endswith('>'):
lines.append(f"{arg} - обязательный")
elif arg.startswith('[') and arg.endswith(']'):
lines.append(f"{arg} - необязательный")
lines.append("")
# Флаги
if flags:
lines.append("<b>Флаги:</b>")
for flag, desc in flags.items():
lines.append(f"• --{flag} - {desc}")
return '\n'.join(lines)
# ================= ИЗВЛЕЧЕНИЕ УПОМИНАНИЙ =================
def extract_mentions(text: str) -> List[str]:
"""
Извлекает все упоминания (@username) из текста.
Args:
text: Текст для анализа
Returns:
List[str]: Список username (без @)
Example:
>> extract_mentions("Бан @user1 и @user2")
['user1', 'user2']
"""
pattern = r'@(\w+)'
return re.findall(pattern, text)
def extract_user_ids(text: str) -> List[int]:
"""
Извлекает все ID пользователей из текста.
Args:
text: Текст для анализа
Returns:
List[int]: Список ID
Example:
>> extract_user_ids("Бан id123456789 и id987654321")
[123456789, 987654321]
"""
pattern = r'id(\d+)'
matches = re.findall(pattern, text)
return [int(m) for m in matches]
def extract_hashtags(text: str) -> List[str]:
"""
Извлекает все хештеги из текста.
Args:
text: Текст для анализа
Returns:
List[str]: Список хештегов (без #)
Example:
>> extract_hashtags("Пост #важное #новости")
['важное', 'новости']
"""
pattern = r'#(\w+)'
return re.findall(pattern, text)

636
bot/utils/auto_delete.py Normal file
View File

@@ -0,0 +1,636 @@
"""
Утилиты для автоматического удаления сообщений
"""
from typing import Optional, Callable, Awaitable, Dict, Any
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from asyncio import sleep, create_task, Task, CancelledError
from aiogram import Bot
from aiogram.types import Message
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from middleware.loggers import logger
from .format_time import format_duration
__all__ = (
'auto_delete_message',
'schedule_delete',
'cancel_delete',
'delete_after',
'auto_delete_manager',
'AutoDeleteManager',
'DeleteTask',
'delete_both_after',
'delete_messages_after',
)
@dataclass
class DeleteTask:
"""
Задача на удаление сообщения.
Attributes:
chat_id: ID чата
message_id: ID сообщения
delete_at: Время удаления
task: Asyncio task
created_at: Время создания задачи
reason: Причина удаления
callback: Callback функция после удаления
"""
chat_id: int
message_id: int
delete_at: datetime
task: Optional[Task] = None
created_at: datetime = field(default_factory=datetime.now)
reason: Optional[str] = None
callback: Optional[Callable[[], Awaitable[None]]] = None
@property
def delay(self) -> int:
"""Задержка до удаления в секундах"""
delta = self.delete_at - datetime.now()
return max(0, int(delta.total_seconds()))
@property
def is_expired(self) -> bool:
"""Истекло ли время удаления"""
return datetime.now() >= self.delete_at
def __repr__(self) -> str:
return (
f"DeleteTask(chat={self.chat_id}, msg={self.message_id}, "
f"delay={self.delay}s, reason={self.reason})"
)
class AutoDeleteManager:
"""
Менеджер автоматического удаления сообщений.
Возможности:
- Планирование удаления с задержкой
- Отмена запланированного удаления
- Массовое удаление
- Callback функции
- История задач
- Автоматическая очистка завершенных задач
Example:
```python
from utils.auto_delete import auto_delete_manager
# Планирование удаления
await auto_delete_manager.schedule(
bot=bot,
chat_id=message.chat.id,
message_id=message.message_id,
delay=60,
reason="Временное сообщение"
)
# Отмена удаления
auto_delete_manager.cancel(message.chat.id, message.message_id)
# Получение статистики
stats = auto_delete_manager.get_stats()
```
"""
def __init__(self):
# Активные задачи: {(chat_id, message_id): DeleteTask}
self.tasks: Dict[tuple[int, int], DeleteTask] = {}
# Завершенные задачи (последние 100)
self.completed: list[DeleteTask] = []
self.max_completed = 100
# Статистика
self.total_scheduled: int = 0
self.total_deleted: int = 0
self.total_failed: int = 0
self.total_cancelled: int = 0
async def schedule(
self,
bot: Bot,
chat_id: int,
message_id: int,
delay: int,
reason: Optional[str] = None,
callback: Optional[Callable[[], Awaitable[None]]] = None,
log: bool = True
) -> DeleteTask:
"""
Планирует удаление сообщения.
Args:
bot: Экземпляр бота
chat_id: ID чата
message_id: ID сообщения
delay: Задержка в секундах
reason: Причина удаления
callback: Callback функция после удаления
log: Логировать планирование
Returns:
DeleteTask: Созданная задача
Example:
>> task = await auto_delete_manager.schedule(
... bot=bot,
... chat_id=message.chat.id,
... message_id=message.message_id,
... delay=60,
... reason="Спам"
... )
"""
# Отменяем предыдущую задачу если есть
key = (chat_id, message_id)
if key in self.tasks:
self.cancel(chat_id, message_id)
# Создаем задачу
delete_at = datetime.now() + timedelta(seconds=delay)
task_data = DeleteTask(
chat_id=chat_id,
message_id=message_id,
delete_at=delete_at,
reason=reason,
callback=callback
)
# Создаем asyncio task
task = create_task(self._delete_task(bot, task_data, log))
task_data.task = task
# Сохраняем
self.tasks[key] = task_data
self.total_scheduled += 1
if log:
delay_str = format_duration(delay)
logger.info(
f"Запланировано удаление сообщения через {delay_str}",
log_type='AUTO_DELETE'
)
return task_data
async def _delete_task(
self,
bot: Bot,
task_data: DeleteTask,
log: bool
) -> None:
"""
Внутренняя функция для выполнения задачи удаления.
Args:
bot: Экземпляр бота
task_data: Данные задачи
log: Логировать выполнение
"""
key = (task_data.chat_id, task_data.message_id)
try:
# Ждем
await sleep(task_data.delay)
# Удаляем сообщение
await bot.delete_message(
chat_id=task_data.chat_id,
message_id=task_data.message_id
)
self.total_deleted += 1
if log:
reason_str = f" (причина: {task_data.reason})" if task_data.reason else ""
logger.info(
f"Сообщение удалено автоматически{reason_str}",
log_type='AUTO_DELETE'
)
# Вызываем callback если есть
if task_data.callback:
try:
await task_data.callback()
except Exception as e:
logger.error(
f"Ошибка в callback автоудаления: {e}",
log_type='AUTO_DELETE'
)
except CancelledError:
# Задача отменена
self.total_cancelled += 1
if log:
logger.debug(
f"Удаление сообщения отменено",
log_type='AUTO_DELETE'
)
raise
except (TelegramBadRequest, TelegramForbiddenError) as e:
# Ошибка удаления
self.total_failed += 1
if log:
logger.warning(
f"Не удалось автоматически удалить сообщение: {e}",
log_type='AUTO_DELETE'
)
finally:
# Удаляем из активных задач
if key in self.tasks:
completed_task = self.tasks.pop(key)
# Сохраняем в завершенные
self.completed.append(completed_task)
if len(self.completed) > self.max_completed:
self.completed.pop(0)
def cancel(
self,
chat_id: int,
message_id: int,
log: bool = True
) -> bool:
"""
Отменяет запланированное удаление.
Args:
chat_id: ID чата
message_id: ID сообщения
log: Логировать отмену
Returns:
bool: True если задача была отменена
Example:
>> cancelled = auto_delete_manager.cancel(
... chat_id=message.chat.id,
... message_id=message.message_id
... )
"""
key = (chat_id, message_id)
if key in self.tasks:
task_data = self.tasks[key]
# Отменяем asyncio task
if task_data.task and not task_data.task.done():
task_data.task.cancel()
# Удаляем из активных
self.tasks.pop(key)
if log:
logger.debug(
f"Автоудаление отменено для сообщения {message_id}",
log_type='AUTO_DELETE'
)
return True
return False
def cancel_all(self, chat_id: Optional[int] = None) -> int:
"""
Отменяет все запланированные удаления.
Args:
chat_id: ID чата (если None, отменяет для всех чатов)
Returns:
int: Количество отмененных задач
Example:
>> # Отменить для всех чатов
>> count = auto_delete_manager.cancel_all()
>> # Отменить для конкретного чата
>> count = auto_delete_manager.cancel_all(chat_id=message.chat.id)
"""
cancelled_count = 0
# Собираем ключи для отмены
keys_to_cancel = []
for key, task_data in self.tasks.items():
if chat_id is None or task_data.chat_id == chat_id:
keys_to_cancel.append(key)
# Отменяем
for key in keys_to_cancel:
if self.cancel(key[0], key[1], log=False):
cancelled_count += 1
if cancelled_count > 0:
logger.info(
f"Отменено {cancelled_count} задач автоудаления",
log_type='AUTO_DELETE'
)
return cancelled_count
def get_task(
self,
chat_id: int,
message_id: int
) -> Optional[DeleteTask]:
"""
Получает задачу по ID чата и сообщения.
Args:
chat_id: ID чата
message_id: ID сообщения
Returns:
Optional[DeleteTask]: Задача или None
"""
key = (chat_id, message_id)
return self.tasks.get(key)
def get_chat_tasks(self, chat_id: int) -> list[DeleteTask]:
"""
Получает все задачи для чата.
Args:
chat_id: ID чата
Returns:
list[DeleteTask]: Список задач
"""
return [
task for task in self.tasks.values()
if task.chat_id == chat_id
]
def get_stats(self) -> Dict[str, Any]:
"""
Возвращает статистику менеджера.
Returns:
Dict: Словарь со статистикой
Example:
>> stats = auto_delete_manager.get_stats()
>> print(f"Активных задач: {stats['active_tasks']}")
"""
return {
'active_tasks': len(self.tasks),
'completed_tasks': len(self.completed),
'total_scheduled': self.total_scheduled,
'total_deleted': self.total_deleted,
'total_failed': self.total_failed,
'total_cancelled': self.total_cancelled,
'success_rate': (
f"{(self.total_deleted / self.total_scheduled * 100):.1f}%"
if self.total_scheduled > 0 else "0%"
)
}
def cleanup_expired(self) -> int:
"""
Удаляет истекшие задачи (которые должны были выполниться, но не выполнились).
Returns:
int: Количество удаленных задач
"""
expired_keys = [
key for key, task in self.tasks.items()
if task.is_expired and (not task.task or task.task.done())
]
for key in expired_keys:
self.tasks.pop(key)
return len(expired_keys)
# Глобальный менеджер
auto_delete_manager = AutoDeleteManager()
# ================= УДОБНЫЕ ФУНКЦИИ =================
async def auto_delete_message(
bot: Bot,
chat_id: int,
message_id: int,
delay: int = 604800,
reason: Optional[str] = None
) -> DeleteTask:
"""
Автоматически удаляет сообщение через указанное время.
Args:
bot: Экземпляр бота
chat_id: ID чата
message_id: ID сообщения
delay: Задержка в секундах (по умолчанию 7 дней)
reason: Причина удаления
Returns:
DeleteTask: Созданная задача
Example:
>> # Удалить через 1 минуту
>> await auto_delete_message(bot, chat_id, message_id, delay=60)
>> # Удалить через 7 дней (по умолчанию)
>> await auto_delete_message(bot, chat_id, message_id)
"""
return await auto_delete_manager.schedule(
bot=bot,
chat_id=chat_id,
message_id=message_id,
delay=delay,
reason=reason
)
async def schedule_delete(
message: Message,
delay: int,
reason: Optional[str] = None
) -> DeleteTask:
"""
Планирует удаление сообщения (упрощенная версия).
Args:
message: Объект сообщения
delay: Задержка в секундах
reason: Причина удаления
Returns:
DeleteTask: Созданная задача
Example:
>> # Планируем удаление через 30 секунд
>> await schedule_delete(message, delay=30, reason="Временное")
"""
return await auto_delete_manager.schedule(
bot=message.bot,
chat_id=message.chat.id,
message_id=message.message_id,
delay=delay,
reason=reason
)
def cancel_delete(message: Message) -> bool:
"""
Отменяет запланированное удаление сообщения.
Args:
message: Объект сообщения
Returns:
bool: True если удаление было отменено
Example:
>> if cancel_delete(message):
... await message.answer("Удаление отменено")
"""
return auto_delete_manager.cancel(
chat_id=message.chat.id,
message_id=message.message_id
)
async def delete_after(
message: Message,
text: str,
delay: int = 10,
**kwargs
) -> Message:
"""
Отправляет сообщение и автоматически удаляет его через указанное время.
Args:
message: Исходное сообщение
text: Текст нового сообщения
delay: Задержка до удаления в секундах
**kwargs: Дополнительные параметры для message.answer()
Returns:
Message: Отправленное сообщение
Example:
>> # Отправить и удалить через 10 секунд
>> await delete_after(message, "Это временное сообщение")
>> # Отправить и удалить через 5 секунд
>> await delete_after(
... message,
... "⚠️ Ошибка!",
... delay=5,
... parse_mode="HTML"
... )
"""
sent_message = await message.answer(text, **kwargs)
await auto_delete_manager.schedule(
bot=message.bot,
chat_id=sent_message.chat.id,
message_id=sent_message.message_id,
delay=delay,
reason="delete_after"
)
return sent_message
async def delete_both_after(
original: Message,
reply_text: str,
delay: int = 10,
**kwargs
) -> Message:
"""
Отправляет ответ и удаляет оба сообщения через указанное время.
Args:
original: Исходное сообщение
reply_text: Текст ответа
delay: Задержка до удаления
**kwargs: Дополнительные параметры
Returns:
Message: Отправленное сообщение
Example:
>> # Удалить и команду, и ответ через 5 секунд
>> await delete_both_after(
... message,
... "✅ Команда выполнена",
... delay=5
... )
"""
# Отправляем ответ
sent = await delete_after(original, reply_text, delay, **kwargs)
# Планируем удаление оригинала
await auto_delete_manager.schedule(
bot=original.bot,
chat_id=original.chat.id,
message_id=original.message_id,
delay=delay,
reason="delete_both"
)
return sent
async def delete_messages_after(
bot: Bot,
chat_id: int,
message_ids: list[int],
delay: int
) -> int:
"""
Планирует удаление нескольких сообщений.
Args:
bot: Экземпляр бота
chat_id: ID чата
message_ids: Список ID сообщений
delay: Задержка до удаления
Returns:
int: Количество запланированных удалений
Example:
>> # Удалить все сообщения через 1 час
>> count = await delete_messages_after(
... bot,
... chat_id,
... [123, 124, 125, 126],
... delay=3600
... )
"""
count = 0
for message_id in message_ids:
await auto_delete_manager.schedule(
bot=bot,
chat_id=chat_id,
message_id=message_id,
delay=delay,
reason="mass_delete",
log=False
)
count += 1
logger.info(
f"Запланировано удаление {count} сообщений через {format_duration(delay)}",
log_type='AUTO_DELETE'
)
return count

812
bot/utils/decorators.py Normal file
View File

@@ -0,0 +1,812 @@
"""
Декораторы для обработчиков бота
"""
import asyncio
from typing import Callable, Optional, Union
from functools import wraps
from datetime import datetime
from collections import defaultdict
from aiogram.types import Message, CallbackQuery
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.enums import ChatType, ChatMemberStatus
from middleware.loggers import logger
from .format_time import format_duration
__all__ = (
'admin_only',
'owner_only',
'private_only',
'group_only',
'rate_limit',
'cooldown',
'log_action',
'catch_errors',
'typing_action',
'delete_command',
'answer_on_error',
'permission_required',
'throttle',
'admin_action'
)
# ================= ХРАНИЛИЩА ДЛЯ RATE LIMIT =================
class RateLimitStorage:
"""Хранилище для rate limiting"""
def __init__(self):
# {user_id: {action: datetime}}
self._storage: dict[int, dict[str, datetime]] = defaultdict(dict)
# {user_id: {action: count}}
self._counters: dict[int, dict[str, int]] = defaultdict(lambda: defaultdict(int))
def check(
self,
user_id: int,
action: str,
limit: int,
period: int
) -> tuple[bool, Optional[int]]:
"""
Проверяет лимит.
Returns:
tuple[bool, Optional[int]]: (можно ли выполнить, секунд до сброса)
"""
now = datetime.now()
if action not in self._storage[user_id]:
# Первое использование
self._storage[user_id][action] = now
self._counters[user_id][action] = 1
return True, None
last_use = self._storage[user_id][action]
time_passed = (now - last_use).total_seconds()
# Если прошел период - сбрасываем
if time_passed >= period:
self._storage[user_id][action] = now
self._counters[user_id][action] = 1
return True, None
# Проверяем счетчик
count = self._counters[user_id][action]
if count >= limit:
# Превышен лимит
retry_after = int(period - time_passed)
return False, retry_after
# Увеличиваем счетчик
self._counters[user_id][action] += 1
return True, None
def reset(self, user_id: int, action: Optional[str] = None):
"""Сбрасывает лимит для пользователя"""
if action:
if user_id in self._storage:
self._storage[user_id].pop(action, None)
self._counters[user_id].pop(action, None)
else:
self._storage.pop(user_id, None)
self._counters.pop(user_id, None)
def cleanup(self, max_age: int = 3600):
"""Очищает старые записи"""
now = datetime.now()
expired_users = []
for user_id, actions in self._storage.items():
expired_actions = [
action for action, dt in actions.items()
if (now - dt).total_seconds() > max_age
]
for action in expired_actions:
actions.pop(action, None)
self._counters[user_id].pop(action, None)
if not actions:
expired_users.append(user_id)
for user_id in expired_users:
self._storage.pop(user_id, None)
self._counters.pop(user_id, None)
# Глобальное хранилище
_rate_limit_storage = RateLimitStorage()
_cooldown_storage = RateLimitStorage()
# ================= ПРОВЕРКА ПРАВ =================
async def _check_admin_rights(
message: Message,
user_id: Optional[int] = None
) -> bool:
"""
Проверяет, является ли пользователь администратором.
Args:
message: Объект сообщения
user_id: ID пользователя (если None, проверяется отправитель)
Returns:
bool: True если администратор
"""
# В личных сообщениях все пользователи "администраторы"
if message.chat.type == ChatType.PRIVATE:
return True
check_user_id = user_id or message.from_user.id
try:
member = await message.bot.get_chat_member(
chat_id=message.chat.id,
user_id=check_user_id
)
return member.status in {
ChatMemberStatus.CREATOR,
ChatMemberStatus.ADMINISTRATOR
}
except (TelegramBadRequest, TelegramForbiddenError):
return False
async def _check_owner_rights(message: Message) -> bool:
"""Проверяет, является ли пользователь владельцем чата"""
if message.chat.type == ChatType.PRIVATE:
return True
try:
member = await message.bot.get_chat_member(
chat_id=message.chat.id,
user_id=message.from_user.id
)
return member.status == ChatMemberStatus.CREATOR
except (TelegramBadRequest, TelegramForbiddenError):
return False
async def _check_bot_admin_rights(message: Message) -> bool:
"""Проверяет, является ли бот администратором"""
if message.chat.type == ChatType.PRIVATE:
return True
try:
bot_member = await message.bot.get_chat_member(
chat_id=message.chat.id,
user_id=message.bot.id
)
return bot_member.status in {
ChatMemberStatus.ADMINISTRATOR
}
except (TelegramBadRequest, TelegramForbiddenError):
return False
# ================= ДЕКОРАТОРЫ ДЛЯ ПРАВ =================
def admin_only(
reply_text: str = "❌ Эта команда доступна только администраторам",
check_bot: bool = False
):
"""
Декоратор: выполнение только для администраторов.
Args:
reply_text: Текст ответа если не админ
check_bot: Также проверять права бота
Example:
```python
@router.message(Command("ban"))
@admin_only()
async def ban_handler(message: Message):
await message.answer("Бан пользователя...")
```
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(update: Union[Message, CallbackQuery], *args, **kwargs):
# Получаем message
message = update if isinstance(update, Message) else update.message
if not message:
return None
# Проверяем права пользователя
if not await _check_admin_rights(message):
if isinstance(update, CallbackQuery):
await update.answer(reply_text, show_alert=True)
else:
await message.answer(reply_text)
logger.warning(
f"Попытка использования admin команды от @{message.from_user.id}",
log_type='SECURITY'
)
return None
# Проверяем права бота если нужно
if check_bot and not await _check_bot_admin_rights(message):
error_text = "❌ Бот не является администратором чата"
if isinstance(update, CallbackQuery):
await update.answer(error_text, show_alert=True)
else:
await message.answer(error_text)
return None
return await func(update, *args, **kwargs)
return wrapper
return decorator
def owner_only(reply_text: str = "❌ Эта команда доступна только владельцу чата"):
"""
Декоратор: выполнение только для владельца чата.
Args:
reply_text: Текст ответа если не владелец
Example:
```python
@router.message(Command("destroy"))
@owner_only()
async def destroy_handler(message: Message):
await message.answer("Удаление чата...")
```
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(update: Union[Message, CallbackQuery], *args, **kwargs):
message = update if isinstance(update, Message) else update.message
if not message:
return None
if not await _check_owner_rights(message):
if isinstance(update, CallbackQuery):
await update.answer(reply_text, show_alert=True)
else:
await message.answer(reply_text)
logger.warning(
f"Попытка использования owner команды от @{message.from_user.id}",
log_type='SECURITY'
)
return None
return await func(update, *args, **kwargs)
return wrapper
return decorator
def permission_required(*permissions: str):
"""
Декоратор: проверка конкретных прав администратора.
Args:
permissions: Список прав (can_delete_messages, can_restrict_members, и т.д.)
Example:
```python
@router.message(Command("pin"))
@permission_required("can_pin_messages")
async def pin_handler(message: Message):
await message.reply_to_message.pin()
```
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(update: Union[Message, CallbackQuery], *args, **kwargs):
message = update if isinstance(update, Message) else update.message
if not message:
return None
# В личных сообщениях пропускаем проверку
if message.chat.type == ChatType.PRIVATE:
return await func(update, *args, **kwargs)
try:
member = await message.bot.get_chat_member(
chat_id=message.chat.id,
user_id=message.from_user.id
)
# Владелец имеет все права
if member.status == ChatMemberStatus.CREATOR:
return await func(update, *args, **kwargs)
# Проверяем права
if member.status == ChatMemberStatus.ADMINISTRATOR:
missing_permissions = []
for perm in permissions:
if not getattr(member, perm, False):
missing_permissions.append(perm)
if missing_permissions:
error_text = (
f"❌ Недостаточно прав\n"
f"Требуются: {', '.join(missing_permissions)}"
)
if isinstance(update, CallbackQuery):
await update.answer(error_text, show_alert=True)
else:
await message.answer(error_text)
return None
return await func(update, *args, **kwargs)
# Не администратор
error_text = "❌ Эта команда доступна только администраторам"
if isinstance(update, CallbackQuery):
await update.answer(error_text, show_alert=True)
else:
await message.answer(error_text)
except (TelegramBadRequest, TelegramForbiddenError):
pass
return wrapper
return decorator
# ================= ДЕКОРАТОРЫ ДЛЯ ТИПОВ ЧАТОВ =================
def private_only(reply_text: str = "❌ Эта команда работает только в личных сообщениях"):
"""
Декоратор: выполнение только в личных сообщениях.
Example:
```python
@router.message(Command("start"))
@private_only()
async def start_handler(message: Message):
await message.answer("Приветствие...")
```
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(update: Union[Message, CallbackQuery], *args, **kwargs):
message = update if isinstance(update, Message) else update.message
if not message:
return None
if message.chat.type != ChatType.PRIVATE:
if isinstance(update, CallbackQuery):
await update.answer(reply_text, show_alert=True)
else:
await message.answer(reply_text)
return None
return await func(update, *args, **kwargs)
return wrapper
return decorator
def group_only(reply_text: str = "❌ Эта команда работает только в группах"):
"""
Декоратор: выполнение только в группах.
Example:
```python
@router.message(Command("ban"))
@group_only()
async def ban_handler(message: Message):
await message.answer("Бан пользователя...")
```
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(update: Union[Message, CallbackQuery], *args, **kwargs):
message = update if isinstance(update, Message) else update.message
if not message:
return None
if message.chat.type not in {ChatType.GROUP, ChatType.SUPERGROUP}:
if isinstance(update, CallbackQuery):
await update.answer(reply_text, show_alert=True)
else:
await message.answer(reply_text)
return None
return await func(update, *args, **kwargs)
return wrapper
return decorator
# ================= RATE LIMITING =================
def rate_limit(limit: int = 1, period: int = 60, action: Optional[str] = None):
"""
Декоратор: ограничение частоты вызовов.
Args:
limit: Количество вызовов
period: Период в секундах
action: Название действия (по умолчанию имя функции)
Example:
```python
@router.message(Command("search"))
@rate_limit(limit=3, period=60) # 3 раза в минуту
async def search_handler(message: Message):
await message.answer("Поиск...")
```
"""
def decorator(func: Callable) -> Callable:
action_name = action or func.__name__
@wraps(func)
async def wrapper(update: Union[Message, CallbackQuery], *args, **kwargs):
message = update if isinstance(update, Message) else update.message
if not message:
return None
user_id = message.from_user.id
# Проверяем лимит
allowed, retry_after = _rate_limit_storage.check(
user_id, action_name, limit, period
)
if not allowed:
retry_time = format_duration(retry_after)
error_text = f"⏳ Слишком часто! Повторите через {retry_time}"
if isinstance(update, CallbackQuery):
await update.answer(error_text, show_alert=True)
else:
await message.answer(error_text)
logger.debug(
f"Rate limit для пользователя {user_id}: {action_name}",
log_type='RATE_LIMIT'
)
return None
return await func(update, *args, **kwargs)
return wrapper
return decorator
def cooldown(seconds: int, action: Optional[str] = None):
"""
Декоратор: кулдаун между вызовами (1 раз в N секунд).
Args:
seconds: Кулдаун в секундах
action: Название действия
Example:
```python
@router.message(Command("daily"))
@cooldown(seconds=86400) # Раз в день
async def daily_handler(message: Message):
await message.answer("Ежедневная награда!")
```
"""
return rate_limit(limit=1, period=seconds, action=action)
def throttle(rate: float = 1.0):
"""
Декоратор: throttling (antiflood).
Args:
rate: Минимальный интервал в секундах между вызовами
Example:
```python
@router.message()
@throttle(rate=0.5) # Не чаще 2 раз в секунду
async def echo_handler(message: Message):
await message.answer(message.text)
```
"""
return cooldown(seconds=int(rate), action='throttle')
# ================= ЛОГИРОВАНИЕ =================
def log_action(
action_name: Optional[str] = None,
log_args: bool = False
):
"""
Декоратор: логирование действий.
Args:
action_name: Название действия (по умолчанию имя функции)
log_args: Логировать аргументы
Example:
```python
@router.message(Command("ban"))
@log_action("BAN_USER", log_args=True)
async def ban_handler(message: Message):
await message.answer("Бан...")
```
"""
def decorator(func: Callable) -> Callable:
name = action_name or func.__name__
@wraps(func)
async def wrapper(update: Union[Message, CallbackQuery], *args, **kwargs):
message = update if isinstance(update, Message) else update.message
if not message:
return await func(update, *args, **kwargs)
user_id = message.from_user.id
username = message.from_user.username or f"id{user_id}"
# Логируем начало
log_msg = f"Действие '{name}' от @{username}"
if log_args and message.text:
log_msg += f" | Аргументы: {message.text}"
logger.info(log_msg, log_type='ACTION')
try:
result = await func(update, *args, **kwargs)
logger.info(f"Действие '{name}' выполнено успешно", log_type='ACTION')
return result
except Exception as e:
logger.error(f"Ошибка в действии '{name}': {e}", log_type='ACTION')
raise
return wrapper
return decorator
# ================= ОБРАБОТКА ОШИБОК =================
def catch_errors(
notify_user: bool = True,
error_message: str = "❌ Произошла ошибка при выполнении команды"
):
"""
Декоратор: перехват ошибок.
Args:
notify_user: Уведомлять пользователя об ошибке
error_message: Текст уведомления
Example:
```python
@router.message(Command("risky"))
@catch_errors(notify_user=True)
async def risky_handler(message: Message):
# Код который может вызвать ошибку
...
```
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(update: Union[Message, CallbackQuery], *args, **kwargs):
try:
return await func(update, *args, **kwargs)
except Exception as e:
logger.error(
f"Ошибка в {func.__name__}: {e}",
log_type='ERROR'
)
if notify_user:
message = update if isinstance(update, Message) else update.message
if message:
try:
if isinstance(update, CallbackQuery):
await update.answer(error_message, show_alert=True)
else:
await message.answer(error_message)
except:
pass
return wrapper
return decorator
def answer_on_error(error_message: str = "❌ Ошибка"):
"""
Декоратор: ответ пользователю при ошибке.
Alias для catch_errors с уведомлением.
"""
return catch_errors(notify_user=True, error_message=error_message)
# ================= ДЕЙСТВИЯ =================
def typing_action():
"""
Декоратор: показывает "печатает..." во время выполнения.
Example:
```python
@router.message(Command("search"))
@typing_action()
async def search_handler(message: Message):
# Долгий поиск...
await asyncio.sleep(3)
await message.answer("Результаты поиска")
```
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(update: Union[Message, CallbackQuery], *args, **kwargs):
message = update if isinstance(update, Message) else update.message
if not message:
return await func(update, *args, **kwargs)
# Отправляем действие "печатает"
async def send_typing():
try:
while True:
await message.bot.send_chat_action(
chat_id=message.chat.id,
action="typing"
)
await asyncio.sleep(4) # Обновляем каждые 4 секунды
except asyncio.CancelledError:
pass
# Создаем задачу
typing_task = asyncio.create_task(send_typing())
try:
result = await func(update, *args, **kwargs)
return result
finally:
typing_task.cancel()
try:
await typing_task
except asyncio.CancelledError:
pass
return wrapper
return decorator
def delete_command(delay: Optional[int] = None):
"""
Декоратор: удаляет команду после выполнения.
Args:
delay: Задержка перед удалением (секунды)
Example:
```python
@router.message(Command("clean"))
@delete_command(delay=0)
async def clean_handler(message: Message):
await message.answer("Очистка...")
```
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(message: Message, *args, **kwargs):
if not isinstance(message, Message):
return await func(message, *args, **kwargs)
# Выполняем функцию
result = await func(message, *args, **kwargs)
# Удаляем команду
try:
if delay:
await asyncio.sleep(delay)
await message.delete()
except (TelegramBadRequest, TelegramForbiddenError):
pass
return result
return wrapper
return decorator
# ================= КОМБИНИРОВАННЫЕ ДЕКОРАТОРЫ =================
def admin_action(
log: bool = True,
check_bot: bool = True,
delete_cmd: bool = False
):
"""
Комбинированный декоратор для admin команд.
Args:
log: Логировать действие
check_bot: Проверять права бота
delete_cmd: Удалять команду
Example:
```python
@router.message(Command("ban"))
@admin_action(log=True, check_bot=True)
async def ban_handler(message: Message):
await message.answer("Бан...")
```
"""
def decorator(func: Callable) -> Callable:
# Применяем декораторы
decorated = func
if log:
decorated = log_action(f"ADMIN_{func.__name__.upper()}")(decorated)
decorated = admin_only(check_bot=check_bot)(decorated)
if delete_cmd:
decorated = delete_command()(decorated)
return decorated
return decorator
# ================= ОЧИСТКА ХРАНИЛИЩ =================
def cleanup_storage(max_age: int = 3600):
"""
Очищает хранилища rate limit от старых записей.
Args:
max_age: Максимальный возраст записи в секундах
"""
_rate_limit_storage.cleanup(max_age)
_cooldown_storage.cleanup(max_age)

523
bot/utils/format_time.py Normal file
View File

@@ -0,0 +1,523 @@
"""
Утилиты для форматирования времени и дат
"""
from typing import Optional, Union
from datetime import datetime, timedelta
from enum import Enum
__all__ = (
'format_duration',
'format_retry_time',
'format_timestamp',
'format_relative_time',
'parse_duration',
'TimeFormat',
'get_plural_form',
'seconds_to_human',
'time_until',
'time_since',
'format_date_range',
'is_today',
'is_yesterday',
'is_tomorrow',
'smart_date'
)
class TimeFormat(str, Enum):
"""Форматы времени"""
FULL = 'full' # 1 час 30 минут 45 секунд
SHORT = 'short' # 1ч 30м 45с
COMPACT = 'compact' # 1:30:45
MINIMAL = 'minimal' # 1ч 30м (без секунд если есть часы/минуты)
def get_plural_form(number: int, forms: tuple[str, str, str]) -> str:
"""
Возвращает правильную форму множественного числа для русского языка.
Args:
number: Число
forms: Кортеж форм (1 секунда, 2 секунды, 5 секунд)
Returns:
str: Правильная форма
Example:
>> get_plural_form(1, ('секунда', 'секунды', 'секунд'))
'секунда'
>> get_plural_form(2, ('секунда', 'секунды', 'секунд'))
'секунды'
>> get_plural_form(5, ('секунда', 'секунды', 'секунд'))
'секунд'
"""
n = abs(number)
n %= 100
if 5 <= n <= 20:
return forms[2]
n %= 10
if n == 1:
return forms[0]
elif 2 <= n <= 4:
return forms[1]
else:
return forms[2]
def format_duration(
seconds: int,
format_type: TimeFormat = TimeFormat.FULL,
include_seconds: bool = True,
max_units: Optional[int] = None
) -> str:
"""
Форматирует длительность в читаемый вид.
Args:
seconds: Длительность в секундах
format_type: Тип форматирования
include_seconds: Включать секунды в вывод
max_units: Максимальное количество единиц времени (например, только часы и минуты)
Returns:
str: Отформатированная строка
Example:
>> format_duration(3665)
'1 час 1 минута 5 секунд'
>> format_duration(3665, TimeFormat.SHORT)
'1ч 1м 5с'
>> format_duration(3665, TimeFormat.COMPACT)
'1:01:05'
>> format_duration(3665, max_units=2)
'1 час 1 минута'
"""
if seconds == 0:
if format_type == TimeFormat.FULL:
return "0 секунд"
elif format_type == TimeFormat.SHORT:
return "0с"
elif format_type == TimeFormat.COMPACT:
return "0:00"
else:
return "0с"
# Разбиваем на единицы
weeks, remainder = divmod(seconds, 604800) # 7 * 24 * 60 * 60
days, remainder = divmod(remainder, 86400) # 24 * 60 * 60
hours, remainder = divmod(remainder, 3600)
minutes, secs = divmod(remainder, 60)
# Компактный формат
if format_type == TimeFormat.COMPACT:
if weeks > 0:
return f"{weeks * 7 + days}д {hours:02d}:{minutes:02d}:{secs:02d}"
elif days > 0:
return f"{days}д {hours:02d}:{minutes:02d}:{secs:02d}"
elif hours > 0:
return f"{hours}:{minutes:02d}:{secs:02d}"
elif minutes > 0:
return f"{minutes}:{secs:02d}"
else:
return f"0:{secs:02d}"
# Собираем части
parts = []
units_count = 0
# Недели
if weeks > 0:
if format_type == TimeFormat.SHORT:
parts.append(f"{weeks}нед")
else:
week_form = get_plural_form(weeks, ('неделя', 'недели', 'недель'))
parts.append(f"{weeks} {week_form}")
units_count += 1
if max_units and units_count >= max_units:
return ' '.join(parts)
# Дни
if days > 0:
if format_type == TimeFormat.SHORT:
parts.append(f"{days}д")
else:
day_form = get_plural_form(days, ('день', 'дня', 'дней'))
parts.append(f"{days} {day_form}")
units_count += 1
if max_units and units_count >= max_units:
return ' '.join(parts)
# Часы
if hours > 0:
if format_type == TimeFormat.SHORT:
parts.append(f"{hours}ч")
else:
hour_form = get_plural_form(hours, ('час', 'часа', 'часов'))
parts.append(f"{hours} {hour_form}")
units_count += 1
if max_units and units_count >= max_units:
return ' '.join(parts)
# Минуты
if minutes > 0:
if format_type == TimeFormat.SHORT:
parts.append(f"{minutes}м")
else:
minute_form = get_plural_form(minutes, ('минута', 'минуты', 'минут'))
parts.append(f"{minutes} {minute_form}")
units_count += 1
if max_units and units_count >= max_units:
return ' '.join(parts)
# Секунды
if secs > 0 and include_seconds:
# Минимальный формат: не показываем секунды если есть часы или дни
if format_type == TimeFormat.MINIMAL and (hours > 0 or days > 0 or weeks > 0):
pass
else:
if format_type == TimeFormat.SHORT:
parts.append(f"{secs}с")
else:
second_form = get_plural_form(secs, ('секунда', 'секунды', 'секунд'))
parts.append(f"{secs} {second_form}")
return ' '.join(parts) if parts else "0 секунд"
def format_retry_time(retry_after: int, format_type: TimeFormat = TimeFormat.FULL) -> str:
"""
Форматирует время повторной попытки.
Args:
retry_after: Время в секундах до следующей попытки
format_type: Тип форматирования
Returns:
str: Отформатированная строка
Example:
>> format_retry_time(3665)
'1 час 1 минута 5 секунд'
>> format_retry_time(3665, TimeFormat.SHORT)
'1ч 1м 5с'
"""
return format_duration(retry_after, format_type=format_type)
def format_timestamp(
timestamp: Union[int, float, datetime],
format_string: str = "%d.%m.%Y %H:%M:%S",
timezone_offset: Optional[int] = None
) -> str:
"""
Форматирует timestamp в читаемую дату.
Args:
timestamp: Unix timestamp или datetime объект
format_string: Формат вывода
timezone_offset: Смещение часового пояса в часах
Returns:
str: Отформатированная дата
Example:
>> format_timestamp(1640000000)
'20.12.2021 13:33:20'
>> format_timestamp(datetime.now(), "%d %B %Y")
'17 февраля 2026'
"""
if isinstance(timestamp, (int, float)):
dt = datetime.fromtimestamp(timestamp)
else:
dt = timestamp
# Применяем смещение часового пояса
if timezone_offset is not None:
dt = dt + timedelta(hours=timezone_offset)
return dt.strftime(format_string)
def format_relative_time(
timestamp: Union[int, float, datetime],
now: Optional[datetime] = None,
detailed: bool = False
) -> str:
"""
Форматирует время относительно текущего момента.
Args:
timestamp: Unix timestamp или datetime объект
now: Текущее время (по умолчанию datetime.now())
detailed: Детальный формат (например "2 часа 30 минут назад" вместо "2 часа назад")
Returns:
str: Относительное время
Example:
>> format_relative_time(time.time() - 3600)
'1 час назад'
>> format_relative_time(time.time() + 7200)
'через 2 часа'
>> format_relative_time(time.time() - 90, detailed=True)
'1 минута 30 секунд назад'
"""
if now is None:
now = datetime.now()
if isinstance(timestamp, (int, float)):
dt = datetime.fromtimestamp(timestamp)
else:
dt = timestamp
# Вычисляем разницу
delta = now - dt
is_past = delta.total_seconds() > 0
seconds = abs(int(delta.total_seconds()))
# Если меньше минуты
if seconds < 60:
if is_past:
return "только что"
else:
return "сейчас"
# Форматируем длительность
if detailed:
duration = format_duration(seconds, TimeFormat.FULL, max_units=2)
else:
duration = format_duration(seconds, TimeFormat.FULL, max_units=1)
if is_past:
return f"{duration} назад"
else:
return f"через {duration}"
def parse_duration(duration_str: str) -> Optional[int]:
"""
Парсит строку длительности в секунды.
Args:
duration_str: Строка длительности (например "1ч 30м", "2h 15m", "90s")
Returns:
Optional[int]: Длительность в секундах или None если не удалось распарсить
Example:
>> parse_duration("1ч 30м")
5400
>> parse_duration("2h 15m 30s")
8130
>> parse_duration("90s")
90
"""
import re
# Паттерны для разных единиц
patterns = {
'weeks': r'(\d+)\s*(?:нед|w|week|weeks)',
'days': r'(\d+)\s*(?:д|d|day|days)',
'hours': r'(\d+)\s*(?:ч|h|hour|hours)',
'minutes': r'(\d+)\s*(?:м|m|min|minutes)',
'seconds': r'(\d+)\s*(?:с|s|sec|seconds)'
}
total_seconds = 0
# Ищем каждую единицу
for unit, pattern in patterns.items():
match = re.search(pattern, duration_str, re.IGNORECASE)
if match:
value = int(match.group(1))
if unit == 'weeks':
total_seconds += value * 604800
elif unit == 'days':
total_seconds += value * 86400
elif unit == 'hours':
total_seconds += value * 3600
elif unit == 'minutes':
total_seconds += value * 60
elif unit == 'seconds':
total_seconds += value
return total_seconds if total_seconds > 0 else None
# ================= ДОПОЛНИТЕЛЬНЫЕ УТИЛИТЫ =================
def seconds_to_human(seconds: int) -> str:
"""
Преобразует секунды в человекопонятный формат (самая большая единица).
Args:
seconds: Количество секунд
Returns:
str: Человекопонятный формат
Example:
>> seconds_to_human(3600)
'1 час'
>> seconds_to_human(90)
'1.5 минуты'
"""
if seconds >= 604800: # Неделя
weeks = seconds / 604800
week_form = get_plural_form(int(weeks), ('неделя', 'недели', 'недель'))
return f"{weeks:.1f} {week_form}".replace('.0', '')
elif seconds >= 86400: # День
days = seconds / 86400
day_form = get_plural_form(int(days), ('день', 'дня', 'дней'))
return f"{days:.1f} {day_form}".replace('.0', '')
elif seconds >= 3600: # Час
hours = seconds / 3600
hour_form = get_plural_form(int(hours), ('час', 'часа', 'часов'))
return f"{hours:.1f} {hour_form}".replace('.0', '')
elif seconds >= 60: # Минута
minutes = seconds / 60
minute_form = get_plural_form(int(minutes), ('минута', 'минуты', 'минут'))
return f"{minutes:.1f} {minute_form}".replace('.0', '')
else: # Секунда
second_form = get_plural_form(seconds, ('секунда', 'секунды', 'секунд'))
return f"{seconds} {second_form}"
def time_until(target_time: datetime, format_type: TimeFormat = TimeFormat.FULL) -> str:
"""
Возвращает время до указанного момента.
Args:
target_time: Целевое время
format_type: Тип форматирования
Returns:
str: Отформатированное время
Example:
>> target = datetime.now() + timedelta(hours=2, minutes=30)
>> time_until(target)
'2 часа 30 минут'
"""
now = datetime.now()
delta = target_time - now
if delta.total_seconds() <= 0:
return "уже прошло"
seconds = int(delta.total_seconds())
return format_duration(seconds, format_type=format_type)
def time_since(start_time: datetime, format_type: TimeFormat = TimeFormat.FULL) -> str:
"""
Возвращает время с указанного момента.
Args:
start_time: Начальное время
format_type: Тип форматирования
Returns:
str: Отформатированное время
Example:
>> start = datetime.now() - timedelta(hours=1, minutes=15)
>> time_since(start)
'1 час 15 минут'
"""
now = datetime.now()
delta = now - start_time
if delta.total_seconds() <= 0:
return "еще не началось"
seconds = int(delta.total_seconds())
return format_duration(seconds, format_type=format_type)
def format_date_range(start: datetime, end: datetime) -> str:
"""
Форматирует диапазон дат.
Args:
start: Начальная дата
end: Конечная дата
Returns:
str: Отформатированный диапазон
Example:
>> start = datetime(2026, 2, 17, 10, 0)
>> end = datetime(2026, 2, 17, 18, 0)
>> format_date_range(start, end)
'17.02.2026 с 10:00 до 18:00'
"""
if start.date() == end.date():
# Один день
return f"{start.strftime('%d.%m.%Y')} с {start.strftime('%H:%M')} до {end.strftime('%H:%M')}"
else:
# Разные дни
return f"с {start.strftime('%d.%m.%Y %H:%M')} до {end.strftime('%d.%m.%Y %H:%M')}"
def is_today(dt: datetime) -> bool:
"""Проверяет, является ли дата сегодняшней"""
return dt.date() == datetime.now().date()
def is_yesterday(dt: datetime) -> bool:
"""Проверяет, является ли дата вчерашней"""
yesterday = datetime.now().date() - timedelta(days=1)
return dt.date() == yesterday
def is_tomorrow(dt: datetime) -> bool:
"""Проверяет, является ли дата завтрашней"""
tomorrow = datetime.now().date() + timedelta(days=1)
return dt.date() == tomorrow
def smart_date(dt: datetime) -> str:
"""
Умное форматирование даты (сегодня, вчера, завтра, или дата).
Args:
dt: Дата для форматирования
Returns:
str: Отформатированная дата
Example:
>> smart_date(datetime.now())
'сегодня в 14:30'
>> smart_date(datetime.now() - timedelta(days=1))
'вчера в 20:15'
"""
if is_today(dt):
return f"сегодня в {dt.strftime('%H:%M')}"
elif is_yesterday(dt):
return f"вчера в {dt.strftime('%H:%M')}"
elif is_tomorrow(dt):
return f"завтра в {dt.strftime('%H:%M')}"
else:
# Если в этом году, не показываем год
if dt.year == datetime.now().year:
return dt.strftime('%d.%m в %H:%M')
else:
return dt.strftime('%d.%m.%Y в %H:%M')

View File

@@ -0,0 +1,504 @@
"""
Утилиты для упоминаний пользователей (mentions)
"""
from typing import Optional, List, Set
from datetime import datetime, timedelta
from aiogram import Bot
from aiogram.types import Message, ChatMemberAdministrator, ChatMemberOwner, User
from aiogram.utils.markdown import hide_link, hlink
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
__all__ = (
'mention_admins',
'mention_user',
'mention_users',
'get_admins_list',
'AdminCache',
'admin_cache',
'mention_moderators',
'mention_owner',
'hidden_admins_message'
)
class AdminCache:
"""
Кэш для списков администраторов чатов.
Уменьшает количество запросов к API Telegram.
"""
def __init__(self, ttl: int = 300):
"""
Args:
ttl: Время жизни кэша в секундах (по умолчанию 5 минут)
"""
self.ttl = ttl
# {chat_id: (admins_list, timestamp)}
self._cache: dict[int, tuple[List[User], datetime]] = {}
# Статистика
self.hits: int = 0
self.misses: int = 0
def get(self, chat_id: int) -> Optional[List[User]]:
"""
Получает список админов из кэша.
Args:
chat_id: ID чата
Returns:
List[User] или None если кэш устарел
"""
if chat_id in self._cache:
admins, timestamp = self._cache[chat_id]
# Проверяем актуальность
if datetime.now() - timestamp < timedelta(seconds=self.ttl):
self.hits += 1
return admins
else:
# Удаляем устаревшую запись
del self._cache[chat_id]
self.misses += 1
return None
def set(self, chat_id: int, admins: List[User]) -> None:
"""
Сохраняет список админов в кэш.
Args:
chat_id: ID чата
admins: Список администраторов
"""
self._cache[chat_id] = (admins, datetime.now())
def invalidate(self, chat_id: Optional[int] = None) -> None:
"""
Инвалидирует кэш.
Args:
chat_id: ID чата (если None, очищает весь кэш)
"""
if chat_id is None:
self._cache.clear()
elif chat_id in self._cache:
del self._cache[chat_id]
def cleanup(self) -> int:
"""
Удаляет устаревшие записи.
Returns:
int: Количество удаленных записей
"""
now = datetime.now()
expired = [
chat_id for chat_id, (_, timestamp) in self._cache.items()
if now - timestamp >= timedelta(seconds=self.ttl)
]
for chat_id in expired:
del self._cache[chat_id]
return len(expired)
def get_stats(self) -> dict:
"""Возвращает статистику кэша"""
total = self.hits + self.misses
hit_rate = (self.hits / total * 100) if total > 0 else 0
return {
'hits': self.hits,
'misses': self.misses,
'hit_rate': f"{hit_rate:.1f}%",
'cached_chats': len(self._cache)
}
# Глобальный кэш
admin_cache = AdminCache(ttl=300)
async def get_admins_list(
bot: Bot,
chat_id: int,
exclude_bots: bool = True,
exclude_users: Optional[Set[int]] = None,
include_owner_only: bool = False,
use_cache: bool = True
) -> List[User]:
"""
Получает список администраторов чата.
Args:
bot: Экземпляр бота
chat_id: ID чата
exclude_bots: Исключить ботов
exclude_users: Множество ID пользователей для исключения
include_owner_only: Только владелец чата
use_cache: Использовать кэш
Returns:
List[User]: Список администраторов
Example:
>> admins = await get_admins_list(bot, chat_id)
>> print(f"Администраторов: {len(admins)}")
"""
# Проверяем кэш
if use_cache:
cached_admins = admin_cache.get(chat_id)
if cached_admins is not None:
admins = cached_admins.copy()
else:
# Загружаем из API
try:
chat_admins = await bot.get_chat_administrators(chat_id)
admins = [admin.user for admin in chat_admins]
# Сохраняем в кэш
admin_cache.set(chat_id, admins)
except (TelegramBadRequest, TelegramForbiddenError):
return []
else:
# Без кэша
try:
chat_admins = await bot.get_chat_administrators(chat_id)
admins = [admin.user for admin in chat_admins]
except (TelegramBadRequest, TelegramForbiddenError):
return []
# Фильтрация
filtered_admins = []
for admin_user in admins:
# Исключаем ботов
if exclude_bots and admin_user.is_bot:
continue
# Исключаем конкретных пользователей
if exclude_users and admin_user.id in exclude_users:
continue
filtered_admins.append(admin_user)
# Только владелец
if include_owner_only and filtered_admins:
# Получаем информацию о владельце
try:
chat_admins = await bot.get_chat_administrators(chat_id)
owner = next(
(admin.user for admin in chat_admins if isinstance(admin, ChatMemberOwner)),
None
)
if owner:
return [owner]
except:
pass
return filtered_admins
async def mention_admins(
bot: Bot,
chat_id: int,
text: str = "",
format_type: str = "hidden",
exclude_bots: bool = True,
exclude_users: Optional[Set[int]] = None,
separator: str = " ",
use_cache: bool = True
) -> str:
"""
Формирует текст с упоминанием всех администраторов.
Args:
bot: Экземпляр бота
chat_id: ID чата
text: Основной текст сообщения
format_type: Тип форматирования:
- 'hidden': Скрытые ссылки (невидимые)
- 'mention': HTML mentions (видимые имена)
- 'username': @username (только для пользователей с username)
- 'mixed': Mentions для пользователей с именами, hidden для остальных
exclude_bots: Исключить ботов
exclude_users: Множество ID пользователей для исключения
separator: Разделитель между mentions (для видимых форматов)
use_cache: Использовать кэш
Returns:
str: Отформатированный текст с упоминаниями
Example:
>> # Скрытые упоминания
>> text = await mention_admins(bot, chat_id, "Внимание, админы!")
>> await message.answer(text, parse_mode="HTML")
>> # Видимые упоминания
>> text = await mention_admins(bot, chat_id, "Админы:", format_type="mention")
>> await message.answer(text, parse_mode="HTML")
"""
# Получаем список админов
admins = await get_admins_list(
bot=bot,
chat_id=chat_id,
exclude_bots=exclude_bots,
exclude_users=exclude_users,
use_cache=use_cache
)
if not admins:
return text
# Формируем упоминания в зависимости от типа
mentions = []
if format_type == "hidden":
# Скрытые ссылки (невидимые)
for admin in admins:
mentions.append(hide_link(f"tg://user?id={admin.id}"))
# Объединяем все ссылки и добавляем текст
return "".join(mentions) + text
elif format_type == "mention":
# HTML mentions (видимые имена)
for admin in admins:
name = admin.full_name or admin.first_name or f"User {admin.id}"
mentions.append(hlink(name, f"tg://user?id={admin.id}"))
mentions_text = separator.join(mentions)
return f"{text}\n\n{mentions_text}" if text else mentions_text
elif format_type == "username":
# Только @username
for admin in admins:
if admin.username:
mentions.append(f"@{admin.username}")
if not mentions:
# Fallback на hidden если нет username
return await mention_admins(
bot, chat_id, text, format_type="hidden",
exclude_bots=exclude_bots, exclude_users=exclude_users
)
mentions_text = separator.join(mentions)
return f"{text}\n\n{mentions_text}" if text else mentions_text
elif format_type == "mixed":
# Mentions для пользователей с именами, hidden для остальных
hidden_links = []
visible_mentions = []
for admin in admins:
if admin.username:
# Видимый mention
name = admin.full_name or admin.first_name or f"@{admin.username}"
visible_mentions.append(hlink(name, f"tg://user?id={admin.id}"))
else:
# Скрытая ссылка
hidden_links.append(hide_link(f"tg://user?id={admin.id}"))
hidden_part = "".join(hidden_links)
visible_part = separator.join(visible_mentions)
if text:
if visible_part:
return f"{hidden_part}{text}\n\n{visible_part}"
else:
return f"{hidden_part}{text}"
else:
return f"{hidden_part}{visible_part}"
# По умолчанию - hidden
return text
async def mention_user(
user: User,
format_type: str = "mention",
show_username: bool = False
) -> str:
"""
Создает упоминание одного пользователя.
Args:
user: Объект пользователя
format_type: Тип форматирования ('mention', 'hidden', 'username')
show_username: Показывать username вместо имени (для mention)
Returns:
str: Отформатированное упоминание
Example:
>> mention = await mention_user(message.from_user)
>> await message.answer(f"Привет, {mention}!", parse_mode="HTML")
"""
if format_type == "hidden":
return hide_link(f"tg://user?id={user.id}")
elif format_type == "username":
if user.username:
return f"@{user.username}"
# Fallback на mention
return await mention_user(user, format_type="mention")
else: # mention
if show_username and user.username:
display_name = f"@{user.username}"
else:
display_name = user.full_name or user.first_name or f"User {user.id}"
return hlink(display_name, f"tg://user?id={user.id}")
async def mention_users(
users: List[User],
format_type: str = "mention",
separator: str = ", ",
max_count: Optional[int] = None
) -> str:
"""
Создает упоминания списка пользователей.
Args:
users: Список пользователей
format_type: Тип форматирования
separator: Разделитель между упоминаниями
max_count: Максимальное количество упоминаний (остальные как "и еще N")
Returns:
str: Отформатированные упоминания
Example:
>> users = [msg.from_user, ...]
>> mentions = await mention_users(users, max_count=5)
>> await message.answer(f"Участники: {mentions}", parse_mode="HTML")
"""
if not users:
return ""
# Ограничиваем количество
display_users = users[:max_count] if max_count else users
remaining = len(users) - len(display_users) if max_count else 0
# Создаем упоминания
mentions = []
for user in display_users:
mention = await mention_user(user, format_type=format_type)
mentions.append(mention)
result = separator.join(mentions)
# Добавляем "и еще N"
if remaining > 0:
result += f" и еще {remaining}"
return result
# ================= СПЕЦИАЛИЗИРОВАННЫЕ ФУНКЦИИ =================
async def mention_moderators(
bot: Bot,
chat_id: int,
text: str = "",
format_type: str = "hidden"
) -> str:
"""
Упоминает только модераторов (администраторов с правами на удаление/бан).
Args:
bot: Экземпляр бота
chat_id: ID чата
text: Текст сообщения
format_type: Тип форматирования
Returns:
str: Текст с упоминаниями модераторов
"""
try:
chat_admins = await bot.get_chat_administrators(chat_id)
# Фильтруем только модераторов
moderators = []
for admin in chat_admins:
if admin.user.is_bot:
continue
# Владелец всегда модератор
if isinstance(admin, ChatMemberOwner):
moderators.append(admin.user)
continue
# Проверяем права администратора
if isinstance(admin, ChatMemberAdministrator):
if admin.can_delete_messages and admin.can_restrict_members:
moderators.append(admin.user)
# Формируем упоминания
if format_type == "hidden":
mentions = "".join(hide_link(f"tg://user?id={mod.id}") for mod in moderators)
return f"{mentions}{text}"
else:
mentions = []
for mod in moderators:
name = mod.full_name or mod.first_name or f"Moderator {mod.id}"
mentions.append(hlink(name, f"tg://user?id={mod.id}"))
mentions_text = ", ".join(mentions)
return f"{text}\n\n{mentions_text}" if text else mentions_text
except (TelegramBadRequest, TelegramForbiddenError):
return text
async def mention_owner(
bot: Bot,
chat_id: int,
format_type: str = "mention"
) -> Optional[str]:
"""
Получает упоминание владельца чата.
Args:
bot: Экземпляр бота
chat_id: ID чата
format_type: Тип форматирования
Returns:
Optional[str]: Упоминание владельца или None
"""
try:
chat_admins = await bot.get_chat_administrators(chat_id)
owner = next(
(admin.user for admin in chat_admins if isinstance(admin, ChatMemberOwner)),
None
)
if owner:
return await mention_user(owner, format_type=format_type)
except (TelegramBadRequest, TelegramForbiddenError):
pass
return None
# Алиас для обратной совместимости
async def hidden_admins_message(message: Message, text: str = "") -> str:
"""
Алиас для mention_admins с format_type="hidden".
DEPRECATED: Используйте mention_admins() вместо этого.
"""
from bot import bot
return await mention_admins(
bot=bot,
chat_id=message.chat.id,
text=text,
format_type="hidden"
)

650
bot/utils/state_utils.py Normal file
View File

@@ -0,0 +1,650 @@
"""
Утилиты для работы с FSM состояниями и обновлениями
"""
from typing import Optional, Any, Set, Union
from contextlib import suppress
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State
from aiogram.types import CallbackQuery, Message, ReplyKeyboardRemove
from aiogram.exceptions import TelegramBadRequest
from middleware.loggers import logger
__all__ = (
'clear_state',
'answer_callback',
'safe_answer_callback',
'safe_delete_message',
'safe_edit_message',
'clear_state_keep_data',
'get_state_data',
'set_state_data',
'update_state_data',
'is_state_active',
'inline_clear',
'status_clear',
'delete_messages',
'set_state_with_data',
'get_or_create_data',
'increment_state_value',
'append_to_state_list',
'remove_from_state_list',
'toggle_state_flag',
'debug_state'
)
# ================= РАБОТА С FSM СОСТОЯНИЯМИ =================
async def clear_state(
state: FSMContext,
log: bool = True
) -> None:
"""
Очищает FSM состояние.
Args:
state: Контекст FSM
log: Логировать очистку
Example:
>> await clear_state(state)
"""
current_state = await state.get_state()
if log and current_state:
logger.debug(
f"Очистка FSM состояния: {current_state}",
log_type='FSM'
)
await state.clear()
async def clear_state_keep_data(
state: FSMContext,
keep_keys: Optional[Set[str]] = None
) -> None:
"""
Очищает FSM состояние, но сохраняет определенные данные.
Args:
state: Контекст FSM
keep_keys: Множество ключей для сохранения
Example:
>> # Очищаем состояние, но сохраняем user_id и language
>> await clear_state_keep_data(state, keep_keys={'user_id', 'language'})
"""
if keep_keys:
# Получаем текущие данные
current_data = await state.get_data()
# Сохраняем только нужные ключи
saved_data = {
key: value for key, value in current_data.items()
if key in keep_keys
}
# Очищаем состояние
await state.clear()
# Восстанавливаем сохраненные данные
if saved_data:
await state.update_data(**saved_data)
logger.debug(
f"FSM очищен, сохранены ключи: {', '.join(keep_keys)}",
log_type='FSM'
)
else:
await state.clear()
async def get_state_data(
state: FSMContext,
key: Optional[str] = None,
default: Any = None
) -> Any:
"""
Получает данные из FSM состояния.
Args:
state: Контекст FSM
key: Ключ для получения (если None, возвращает все данные)
default: Значение по умолчанию
Returns:
Any: Данные из состояния
Example:
>> # Получить все данные
>> data = await get_state_data(state)
>> # Получить конкретный ключ
>> user_id = await get_state_data(state, 'user_id')
>> # С значением по умолчанию
>> lang = await get_state_data(state, 'language', default='ru')
"""
data = await state.get_data()
if key is None:
return data
return data.get(key, default)
async def set_state_data(
state: FSMContext,
key: str,
value: Any
) -> None:
"""
Устанавливает данные в FSM состояние.
Args:
state: Контекст FSM
key: Ключ
value: Значение
Example:
>> await set_state_data(state, 'user_id', 123456789)
"""
await state.update_data(**{key: value})
async def update_state_data(
state: FSMContext,
**kwargs
) -> None:
"""
Обновляет несколько полей в FSM состоянии.
Args:
state: Контекст FSM
**kwargs: Пары ключ-значение для обновления
Example:
>> await update_state_data(
... state,
... user_id=123456789,
... language='ru',
... step=1
... )
"""
await state.update_data(**kwargs)
async def is_state_active(state: FSMContext) -> bool:
"""
Проверяет, активно ли какое-либо состояние.
Args:
state: Контекст FSM
Returns:
bool: True если есть активное состояние
Example:
>> if await is_state_active(state):
... await message.answer("У вас есть незавершенное действие")
"""
current_state = await state.get_state()
return current_state is not None
# ================= РАБОТА С CALLBACK QUERIES =================
async def answer_callback(
callback: CallbackQuery,
text: Optional[str] = None,
show_alert: bool = False,
cache_time: int = 0
) -> bool:
"""
Отвечает на callback query.
Args:
callback: Callback query
text: Текст уведомления
show_alert: Показать как alert
cache_time: Время кэширования
Returns:
bool: True если успешно
Example:
>> await answer_callback(callback, "✅ Готово!")
>> await answer_callback(callback, "⚠️ Ошибка", show_alert=True)
"""
try:
await callback.answer(text=text, show_alert=show_alert, cache_time=cache_time)
return True
except TelegramBadRequest as e:
logger.warning(
f"Не удалось ответить на callback: {e}",
log_type='CALLBACK'
)
return False
async def safe_answer_callback(
callback: CallbackQuery,
text: Optional[str] = None,
show_alert: bool = False
) -> None:
"""
Безопасно отвечает на callback query (подавляет ошибки).
Args:
callback: Callback query
text: Текст уведомления
show_alert: Показать как alert
Example:
>> await safe_answer_callback(callback, "✅ Готово!")
"""
with suppress(TelegramBadRequest):
await callback.answer(text=text, show_alert=show_alert)
# ================= РАБОТА С СООБЩЕНИЯМИ =================
async def safe_delete_message(
message: Message,
log: bool = False
) -> bool:
"""
Безопасно удаляет сообщение.
Args:
message: Сообщение для удаления
log: Логировать попытку удаления
Returns:
bool: True если успешно удалено
Example:
>> await safe_delete_message(message)
"""
try:
await message.delete()
if log:
logger.debug(
f"Сообщение удалено: {message.message_id}",
log_type='MESSAGE'
)
return True
except TelegramBadRequest as e:
if log:
logger.warning(
f"Не удалось удалить сообщение: {e}",
log_type='MESSAGE'
)
return False
async def safe_edit_message(
message: Message,
text: str,
**kwargs
) -> bool:
"""
Безопасно редактирует сообщение.
Args:
message: Сообщение для редактирования
text: Новый текст
**kwargs: Дополнительные параметры (reply_markup, parse_mode, и т.д.)
Returns:
bool: True если успешно отредактировано
Example:
>> await safe_edit_message(
... message,
... "Новый текст",
... parse_mode="HTML"
... )
"""
try:
await message.edit_text(text, **kwargs)
return True
except TelegramBadRequest as e:
logger.warning(
f"Не удалось отредактировать сообщение: {e}",
log_type='MESSAGE'
)
return False
async def delete_messages(
chat_id: int,
message_ids: list[int],
bot
) -> int:
"""
Удаляет несколько сообщений.
Args:
chat_id: ID чата
message_ids: Список ID сообщений
bot: Экземпляр бота
Returns:
int: Количество успешно удаленных сообщений
Example:
>> deleted = await delete_messages(
... chat_id=message.chat.id,
... message_ids=[123, 124, 125],
... bot=bot
... )
>> print(f"Удалено {deleted} сообщений")
"""
deleted_count = 0
for message_id in message_ids:
try:
await bot.delete_message(chat_id=chat_id, message_id=message_id)
deleted_count += 1
except TelegramBadRequest:
pass
return deleted_count
# ================= КОМБИНИРОВАННЫЕ ФУНКЦИИ =================
async def inline_clear(update: Union[Message, CallbackQuery]) -> None:
"""
Очищает все инлайн взаимодействия (отвечает на callback).
Args:
update: Объект обновления (Message или CallbackQuery)
Example:
>> await inline_clear(callback)
"""
if isinstance(update, CallbackQuery):
await safe_answer_callback(update)
async def status_clear(
update: Union[Message, CallbackQuery],
state: FSMContext,
keep_data: Optional[Set[str]] = None,
remove_keyboard: bool = False
) -> None:
"""
Полная очистка: состояние FSM + ответ на callback + удаление клавиатуры.
Args:
update: Объект обновления
state: Контекст FSM
keep_data: Данные для сохранения
remove_keyboard: Удалить клавиатуру (только для Message)
Example:
>> # Полная очистка
>> await status_clear(message, state)
>> # С сохранением данных
>> await status_clear(
... callback,
... state,
... keep_data={'user_id', 'language'}
... )
>> # С удалением клавиатуры
>> await status_clear(message, state, remove_keyboard=True)
"""
# Очищаем состояние
if keep_data:
await clear_state_keep_data(state, keep_keys=keep_data)
else:
await clear_state(state, log=True)
# Отвечаем на callback
await inline_clear(update)
# Удаляем клавиатуру если нужно
if remove_keyboard and isinstance(update, Message):
with suppress(TelegramBadRequest):
await update.answer(
"Отменено",
reply_markup=ReplyKeyboardRemove()
)
# ================= УТИЛИТЫ ДЛЯ РАБОТЫ С СОСТОЯНИЯМИ =================
async def set_state_with_data(
state: FSMContext,
new_state: State,
**data
) -> None:
"""
Устанавливает новое состояние и данные одновременно.
Args:
state: Контекст FSM
new_state: Новое состояние
**data: Данные для сохранения
Example:
>> await set_state_with_data(
... state,
... FormStates.waiting_name,
... user_id=123456789,
... step=1
... )
"""
await state.set_state(new_state)
if data:
await state.update_data(**data)
logger.debug(
f"Установлено состояние: {new_state.state}",
log_type='FSM'
)
async def get_or_create_data(
state: FSMContext,
key: str,
factory: Any
) -> Any:
"""
Получает данные из состояния или создает их если их нет.
Args:
state: Контекст FSM
key: Ключ данных
factory: Значение по умолчанию или функция для создания
Returns:
Any: Данные из состояния или созданные
Example:
>> # С простым значением
>> items = await get_or_create_data(state, 'items', [])
>> # С функцией
>> data = await get_or_create_data(state, 'data', lambda: {'count': 0})
"""
data = await state.get_data()
if key not in data:
# Создаем значение
if callable(factory):
value = factory()
else:
value = factory
await state.update_data(**{key: value})
return value
return data[key]
async def increment_state_value(
state: FSMContext,
key: str,
amount: int = 1
) -> int:
"""
Инкрементирует числовое значение в состоянии.
Args:
state: Контекст FSM
key: Ключ значения
amount: Величина инкремента
Returns:
int: Новое значение
Example:
>> # Увеличиваем счетчик
>> new_count = await increment_state_value(state, 'attempts')
>> if new_count >= 3:
... await message.answer("Слишком много попыток!")
"""
data = await state.get_data()
current = data.get(key, 0)
new_value = current + amount
await state.update_data(**{key: new_value})
return new_value
async def append_to_state_list(
state: FSMContext,
key: str,
value: Any
) -> list:
"""
Добавляет значение в список в состоянии.
Args:
state: Контекст FSM
key: Ключ списка
value: Значение для добавления
Returns:
list: Обновленный список
Example:
>> # Добавляем товар в корзину
>> cart = await append_to_state_list(state, 'cart', product_id)
>> await message.answer(f"В корзине {len(cart)} товаров")
"""
data = await state.get_data()
current_list = data.get(key, [])
if not isinstance(current_list, list):
current_list = []
current_list.append(value)
await state.update_data(**{key: current_list})
return current_list
async def remove_from_state_list(
state: FSMContext,
key: str,
value: Any
) -> list:
"""
Удаляет значение из списка в состоянии.
Args:
state: Контекст FSM
key: Ключ списка
value: Значение для удаления
Returns:
list: Обновленный список
Example:
>> # Удаляем товар из корзины
>> cart = await remove_from_state_list(state, 'cart', product_id)
"""
data = await state.get_data()
current_list = data.get(key, [])
if isinstance(current_list, list) and value in current_list:
current_list.remove(value)
await state.update_data(**{key: current_list})
return current_list
async def toggle_state_flag(
state: FSMContext,
key: str
) -> bool:
"""
Переключает boolean флаг в состоянии.
Args:
state: Контекст FSM
key: Ключ флага
Returns:
bool: Новое значение флага
Example:
>> # Переключаем режим
>> is_active = await toggle_state_flag(state, 'notifications')
>> await message.answer(
... f"Уведомления: {'включены' if is_active else 'выключены'}"
... )
"""
data = await state.get_data()
current = data.get(key, False)
new_value = not current
await state.update_data(**{key: new_value})
return new_value
# ================= ОТЛАДКА =================
async def debug_state(state: FSMContext) -> str:
"""
Возвращает отладочную информацию о состоянии.
Args:
state: Контекст FSM
Returns:
str: Форматированная информация о состоянии
Example:
>> debug_info = await debug_state(state)
>> print(debug_info)
"""
current_state = await state.get_state()
data = await state.get_data()
lines = [
"🔍 <b>Debug FSM:</b>\n",
f"📊 Состояние: <code>{current_state or 'None'}</code>\n",
f"📦 Данных: {len(data)}\n"
]
if data:
lines.append("\n<b>Данные:</b>")
for key, value in data.items():
value_str = str(value)
if len(value_str) > 50:
value_str = value_str[:50] + "..."
lines.append(f"{key}: <code>{value_str}</code>")
return "\n".join(lines)

613
bot/utils/type_message.py Normal file
View File

@@ -0,0 +1,613 @@
"""
Утилиты для работы с типами контента и чатов
"""
from typing import Final, Optional, Dict, Any
from enum import Enum
from aiogram.types import Message
from aiogram.enums import ContentType, ChatType
__all__ = (
'CHAT_TYPES_RU',
'CONTENT_TYPES_RU',
'CONTENT_EMOJI',
'get_chat_type',
'get_content_type',
'get_content_text',
'get_content_emoji',
'get_media_info',
'has_media',
'has_text',
'format_content_info',
'ContentCategory',
'get_content_category',
'is_private_chat',
'is_group_chat',
'is_channel',
'type_msg',
'type_chat'
)
# ==================== КОНСТАНТЫ ====================
# Типы чатов на русском
CHAT_TYPES_RU: Final[Dict[str, str]] = {
ChatType.PRIVATE: "Личные сообщения",
ChatType.GROUP: "Группа",
ChatType.SUPERGROUP: "Супергруппа",
ChatType.CHANNEL: "Канал",
"private": "Личные сообщения",
"group": "Группа",
"supergroup": "Супергруппа",
"channel": "Канал",
}
# Типы контента на русском
CONTENT_TYPES_RU: Final[Dict[str, str]] = {
# Текст и медиа
ContentType.TEXT: "Текст",
ContentType.ANIMATION: "GIF анимация",
ContentType.AUDIO: "Аудиофайл",
ContentType.DOCUMENT: "Документ",
ContentType.PHOTO: "Фотография",
ContentType.STICKER: "Стикер",
ContentType.VIDEO: "Видео",
ContentType.VIDEO_NOTE: "Видеосообщение",
ContentType.VOICE: "Голосовое сообщение",
# Контакты и локации
ContentType.CONTACT: "Контакт",
ContentType.LOCATION: "Геолокация",
ContentType.VENUE: "Место на карте",
# Игры и развлечения
ContentType.DICE: "Игральная кость",
ContentType.GAME: "Игра",
ContentType.POLL: "Опрос",
# События чата
ContentType.NEW_CHAT_MEMBERS: "Новые участники",
ContentType.LEFT_CHAT_MEMBER: "Участник покинул чат",
ContentType.NEW_CHAT_TITLE: "Изменено название чата",
ContentType.NEW_CHAT_PHOTO: "Изменена аватарка чата",
ContentType.DELETE_CHAT_PHOTO: "Удалена аватарка чата",
ContentType.GROUP_CHAT_CREATED: "Группа создана",
ContentType.SUPERGROUP_CHAT_CREATED: "Супергруппа создана",
ContentType.CHANNEL_CHAT_CREATED: "Канал создан",
ContentType.MESSAGE_AUTO_DELETE_TIMER_CHANGED: "Изменён таймер автоудаления",
ContentType.MIGRATE_TO_CHAT_ID: "Миграция в супергруппу",
ContentType.MIGRATE_FROM_CHAT_ID: "Миграция из группы",
ContentType.PINNED_MESSAGE: "Закреплено сообщение",
# Платежи
ContentType.INVOICE: "Счёт на оплату",
ContentType.SUCCESSFUL_PAYMENT: "Успешная оплата",
# Другое
ContentType.CONNECTED_WEBSITE: "Подключён сайт",
ContentType.PASSPORT_DATA: "Данные Telegram Passport",
ContentType.PROXIMITY_ALERT_TRIGGERED: "Сработал алерт приближения",
# Видеочаты
ContentType.VIDEO_CHAT_SCHEDULED: "Запланирован видеочат",
ContentType.VIDEO_CHAT_STARTED: "Начался видеочат",
ContentType.VIDEO_CHAT_ENDED: "Завершён видеочат",
ContentType.VIDEO_CHAT_PARTICIPANTS_INVITED: "Приглашены в видеочат",
# Web App
ContentType.WEB_APP_DATA: "Данные Web App",
# Форумы
ContentType.FORUM_TOPIC_CREATED: "Создана тема форума",
ContentType.FORUM_TOPIC_EDITED: "Изменена тема форума",
ContentType.FORUM_TOPIC_CLOSED: "Закрыта тема форума",
ContentType.FORUM_TOPIC_REOPENED: "Открыта тема форума",
ContentType.GENERAL_FORUM_TOPIC_HIDDEN: "Скрыта общая тема",
ContentType.GENERAL_FORUM_TOPIC_UNHIDDEN: "Показана общая тема",
# Розыгрыши
ContentType.GIVEAWAY_CREATED: "Создан розыгрыш",
ContentType.GIVEAWAY: "Розыгрыш",
ContentType.GIVEAWAY_WINNERS: "Победители розыгрыша",
ContentType.GIVEAWAY_COMPLETED: "Завершён розыгрыш",
# Истории и реакции
ContentType.STORY: "История",
}
# Эмодзи для типов контента
CONTENT_EMOJI: Final[Dict[str, str]] = {
ContentType.TEXT: "💬",
ContentType.ANIMATION: "🎞️",
ContentType.AUDIO: "🎵",
ContentType.DOCUMENT: "📄",
ContentType.PHOTO: "📷",
ContentType.STICKER: "🎨",
ContentType.VIDEO: "🎥",
ContentType.VIDEO_NOTE: "🎬",
ContentType.VOICE: "🎤",
ContentType.CONTACT: "👤",
ContentType.LOCATION: "📍",
ContentType.VENUE: "🏢",
ContentType.DICE: "🎲",
ContentType.GAME: "🎮",
ContentType.POLL: "📊",
ContentType.INVOICE: "💰",
ContentType.SUCCESSFUL_PAYMENT: "",
}
class ContentCategory(str, Enum):
"""Категории контента"""
TEXT = "text" # Текстовые сообщения
MEDIA = "media" # Медиа (фото, видео, и т.д.)
FILE = "file" # Файлы и документы
VOICE = "voice" # Голосовые сообщения
LOCATION = "location" # Локации и места
INTERACTION = "interaction" # Игры, опросы, кости
SERVICE = "service" # Служебные сообщения
PAYMENT = "payment" # Платежи
UNKNOWN = "unknown" # Неизвестный тип
# ==================== ОСНОВНЫЕ ФУНКЦИИ ====================
def get_chat_type(message: Message, russian: bool = True) -> str:
"""
Возвращает тип чата.
Args:
message: Объект сообщения
russian: Вернуть на русском языке
Returns:
str: Тип чата
Example:
>>> get_chat_type(message)
'Личные сообщения'
>>> get_chat_type(message, russian=False)
'private'
"""
chat_type = message.chat.type
if russian:
return CHAT_TYPES_RU.get(chat_type, f"Неизвестный тип ({chat_type})")
return chat_type
def get_content_type(message: Message, russian: bool = True) -> str:
"""
Возвращает тип контента сообщения.
Args:
message: Объект сообщения
russian: Вернуть на русском языке
Returns:
str: Тип контента
Example:
>>> get_content_type(message)
'Фотография'
>>> get_content_type(message, russian=False)
'photo'
"""
content_type = message.content_type
if russian:
return CONTENT_TYPES_RU.get(content_type, f"Неизвестный тип ({content_type})")
return content_type
def get_content_emoji(message: Message) -> str:
"""
Возвращает эмодзи для типа контента.
Args:
message: Объект сообщения
Returns:
str: Эмодзи
Example:
>>> get_content_emoji(message)
'📷'
"""
return CONTENT_EMOJI.get(message.content_type, "📎")
def get_content_text(message: Message, max_length: Optional[int] = None) -> Optional[str]:
"""
Извлекает текст из сообщения (текст или caption).
Args:
message: Объект сообщения
max_length: Максимальная длина текста (обрезает если больше)
Returns:
Optional[str]: Текст сообщения или None
Example:
>>> get_content_text(message)
'Привет, мир!'
>>> get_content_text(message) # Фото с подписью
'Красивое фото'
>>> get_content_text(message, max_length=10)
'Привет,...'
"""
text = message.text or message.caption
if text and max_length and len(text) > max_length:
return f"{text[:max_length]}..."
return text
def has_media(message: Message) -> bool:
"""
Проверяет, содержит ли сообщение медиа.
Args:
message: Объект сообщения
Returns:
bool: True если есть медиа
Example:
>>> has_media(message)
True
"""
media_types = {
ContentType.PHOTO,
ContentType.VIDEO,
ContentType.ANIMATION,
ContentType.AUDIO,
ContentType.VOICE,
ContentType.VIDEO_NOTE,
ContentType.DOCUMENT,
ContentType.STICKER
}
return message.content_type in media_types
def has_text(message: Message) -> bool:
"""
Проверяет, есть ли в сообщении текст (или caption).
Args:
message: Объект сообщения
Returns:
bool: True если есть текст
Example:
>>> has_text(message)
True
"""
return bool(message.text or message.caption)
# ==================== ДЕТАЛЬНАЯ ИНФОРМАЦИЯ О МЕДИА ====================
def get_media_info(message: Message) -> Dict[str, Any]:
"""
Возвращает детальную информацию о медиа в сообщении.
Args:
message: Объект сообщения
Returns:
Dict: Словарь с информацией о медиа
Example:
>>> get_media_info(message)
{
'type': 'photo',
'type_ru': 'Фотография',
'emoji': '📷',
'has_caption': True,
'caption': 'Красивое фото',
'file_size': 123456,
'file_size_mb': 0.12,
'width': 1920,
'height': 1080,
'duration': None
}
"""
info = {
'type': message.content_type,
'type_ru': get_content_type(message),
'emoji': get_content_emoji(message),
'has_caption': bool(message.caption),
'caption': message.caption,
'has_text': bool(message.text),
'text': message.text,
}
# Фото
if message.photo:
largest_photo = max(message.photo, key=lambda p: p.file_size or 0)
info.update({
'file_id': largest_photo.file_id,
'file_unique_id': largest_photo.file_unique_id,
'file_size': largest_photo.file_size,
'file_size_kb': round(largest_photo.file_size / 1024, 2) if largest_photo.file_size else None,
'width': largest_photo.width,
'height': largest_photo.height,
'count': len(message.photo) # Количество размеров
})
# Видео
elif message.video:
info.update({
'file_id': message.video.file_id,
'file_unique_id': message.video.file_unique_id,
'file_size': message.video.file_size,
'file_size_mb': round(message.video.file_size / (1024 * 1024), 2) if message.video.file_size else None,
'width': message.video.width,
'height': message.video.height,
'duration': message.video.duration,
'duration_formatted': _format_duration(message.video.duration) if message.video.duration else None,
'mime_type': message.video.mime_type,
'file_name': message.video.file_name
})
# Документ
elif message.document:
info.update({
'file_id': message.document.file_id,
'file_unique_id': message.document.file_unique_id,
'file_size': message.document.file_size,
'file_size_mb': round(message.document.file_size / (1024 * 1024),
2) if message.document.file_size else None,
'file_name': message.document.file_name,
'mime_type': message.document.mime_type
})
# Аудио
elif message.audio:
info.update({
'file_id': message.audio.file_id,
'file_unique_id': message.audio.file_unique_id,
'file_size': message.audio.file_size,
'file_size_mb': round(message.audio.file_size / (1024 * 1024), 2) if message.audio.file_size else None,
'duration': message.audio.duration,
'duration_formatted': _format_duration(message.audio.duration) if message.audio.duration else None,
'performer': message.audio.performer,
'title': message.audio.title,
'mime_type': message.audio.mime_type,
'file_name': message.audio.file_name
})
# Голосовое сообщение
elif message.voice:
info.update({
'file_id': message.voice.file_id,
'file_unique_id': message.voice.file_unique_id,
'file_size': message.voice.file_size,
'file_size_kb': round(message.voice.file_size / 1024, 2) if message.voice.file_size else None,
'duration': message.voice.duration,
'duration_formatted': _format_duration(message.voice.duration) if message.voice.duration else None,
'mime_type': message.voice.mime_type
})
# Видеосообщение
elif message.video_note:
info.update({
'file_id': message.video_note.file_id,
'file_unique_id': message.video_note.file_unique_id,
'file_size': message.video_note.file_size,
'file_size_kb': round(message.video_note.file_size / 1024, 2) if message.video_note.file_size else None,
'duration': message.video_note.duration,
'duration_formatted': _format_duration(
message.video_note.duration) if message.video_note.duration else None,
'length': message.video_note.length # Диаметр
})
# Анимация (GIF)
elif message.animation:
info.update({
'file_id': message.animation.file_id,
'file_unique_id': message.animation.file_unique_id,
'file_size': message.animation.file_size,
'file_size_mb': round(message.animation.file_size / (1024 * 1024),
2) if message.animation.file_size else None,
'width': message.animation.width,
'height': message.animation.height,
'duration': message.animation.duration,
'duration_formatted': _format_duration(message.animation.duration) if message.animation.duration else None,
'mime_type': message.animation.mime_type,
'file_name': message.animation.file_name
})
# Стикер
elif message.sticker:
info.update({
'file_id': message.sticker.file_id,
'file_unique_id': message.sticker.file_unique_id,
'file_size': message.sticker.file_size,
'width': message.sticker.width,
'height': message.sticker.height,
'is_animated': message.sticker.is_animated,
'is_video': message.sticker.is_video,
'emoji': message.sticker.emoji,
'set_name': message.sticker.set_name
})
return info
def format_content_info(message: Message, include_text: bool = True, max_text_length: int = 50) -> str:
"""
Форматирует информацию о контенте в читаемую строку.
Args:
message: Объект сообщения
include_text: Включать текст/caption в описание
max_text_length: Максимальная длина текста
Returns:
str: Отформатированная строка
Example:
>>> format_content_info(message)
'📷 Фотография (1920x1080, 123 KB) + "Красивое фото"'
>>> format_content_info(message)
'🎥 Видео (1920x1080, 5.2 MB, 1:30) + "Смотрите это видео"'
"""
emoji = get_content_emoji(message)
content_type = get_content_type(message)
parts = [f"{emoji} {content_type}"]
# Добавляем детали медиа
if message.photo:
largest = max(message.photo, key=lambda p: p.file_size or 0)
size_kb = largest.file_size / 1024 if largest.file_size else 0
parts.append(f"({largest.width}x{largest.height}, {size_kb:.1f} KB)")
elif message.video:
size_mb = message.video.file_size / (1024 * 1024) if message.video.file_size else 0
duration = _format_duration(message.video.duration) if message.video.duration else "?"
parts.append(f"({message.video.width}x{message.video.height}, {size_mb:.1f} MB, {duration})")
elif message.document:
size_mb = message.document.file_size / (1024 * 1024) if message.document.file_size else 0
file_name = message.document.file_name or "без имени"
parts.append(f'("{file_name}", {size_mb:.2f} MB)')
elif message.audio:
duration = _format_duration(message.audio.duration) if message.audio.duration else "?"
title = message.audio.title or "без названия"
parts.append(f'("{title}", {duration})')
elif message.voice:
duration = _format_duration(message.voice.duration) if message.voice.duration else "?"
parts.append(f"({duration})")
elif message.video_note:
duration = _format_duration(message.video_note.duration) if message.video_note.duration else "?"
parts.append(f"({duration})")
elif message.sticker:
emoji_text = message.sticker.emoji or ""
parts.append(f"({emoji_text})")
# Добавляем текст/caption
if include_text:
text = get_content_text(message, max_length=max_text_length)
if text:
parts.append(f'+ "{text}"')
return ' '.join(parts)
def get_content_category(message: Message) -> ContentCategory:
"""
Определяет категорию контента.
Args:
message: Объект сообщения
Returns:
ContentCategory: Категория контента
Example:
>>> get_content_category(message)
ContentCategory.MEDIA
"""
content_type = message.content_type
# Текст
if content_type == ContentType.TEXT:
return ContentCategory.TEXT
# Медиа
if content_type in {ContentType.PHOTO, ContentType.VIDEO, ContentType.ANIMATION, ContentType.STICKER}:
return ContentCategory.MEDIA
# Файлы
if content_type in {ContentType.DOCUMENT, ContentType.AUDIO}:
return ContentCategory.FILE
# Голосовые
if content_type in {ContentType.VOICE, ContentType.VIDEO_NOTE}:
return ContentCategory.VOICE
# Локации
if content_type in {ContentType.LOCATION, ContentType.VENUE}:
return ContentCategory.LOCATION
# Интерактивные
if content_type in {ContentType.DICE, ContentType.GAME, ContentType.POLL}:
return ContentCategory.INTERACTION
# Платежи
if content_type in {ContentType.INVOICE, ContentType.SUCCESSFUL_PAYMENT}:
return ContentCategory.PAYMENT
# Служебные
if content_type in {
ContentType.NEW_CHAT_MEMBERS,
ContentType.LEFT_CHAT_MEMBER,
ContentType.NEW_CHAT_TITLE,
ContentType.PINNED_MESSAGE
}:
return ContentCategory.SERVICE
return ContentCategory.UNKNOWN
# ==================== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ====================
def _format_duration(seconds: int) -> str:
"""
Форматирует длительность в читаемый вид.
Args:
seconds: Длительность в секундах
Returns:
str: Отформатированная строка (MM:SS или HH:MM:SS)
Example:
>>> _format_duration(90)
'1:30'
>>> _format_duration(3661)
'1:01:01'
"""
hours = seconds // 3600
minutes = (seconds % 3600) // 60
secs = seconds % 60
if hours > 0:
return f"{hours}:{minutes:02d}:{secs:02d}"
else:
return f"{minutes}:{secs:02d}"
def is_private_chat(message: Message) -> bool:
"""Проверяет, является ли чат личным"""
return message.chat.type == ChatType.PRIVATE
def is_group_chat(message: Message) -> bool:
"""Проверяет, является ли чат группой"""
return message.chat.type in {ChatType.GROUP, ChatType.SUPERGROUP}
def is_channel(message: Message) -> bool:
"""Проверяет, является ли чат каналом"""
return message.chat.type == ChatType.CHANNEL
# Алиасы для обратной совместимости
type_msg = get_content_type
type_chat = get_chat_type

409
bot/utils/usernames.py Normal file
View File

@@ -0,0 +1,409 @@
"""
Утилиты для работы с информацией о пользователях
"""
from typing import Optional, Union
from enum import Enum
from aiogram.types import Message, CallbackQuery, User, InlineQuery, ChatMemberUpdated
__all__ = (
'get_user_display_name',
'get_user_mention',
'get_user_id',
'username',
'format_user',
'UserFormat',
'is_bot',
'has_username',
'is_premium',
'get_language_code',
'compare_users',
'get_user_info_dict'
)
class UserFormat(str, Enum):
"""Форматы отображения пользователя"""
USERNAME = 'username' # @username или @id123
FULL_NAME = 'full_name' # Имя Фамилия
MENTION = 'mention' # HTML mention
MENTION_MARKDOWN = 'markdown' # Markdown mention
FIRST_NAME = 'first_name' # Только имя
ID_ONLY = 'id' # Только ID
DETAILED = 'detailed' # @username (Имя Фамилия, ID: 123)
# Тип для всех событий с пользователем
EventType = Union[Message, CallbackQuery, InlineQuery, ChatMemberUpdated]
def _extract_user(event: EventType) -> Optional[User]:
"""
Извлекает объект User из события.
Args:
event: Объект события
Returns:
User или None
"""
if isinstance(event, (Message, CallbackQuery, InlineQuery)):
return event.from_user
elif isinstance(event, ChatMemberUpdated):
return event.from_user or event.new_chat_member.user
return None
def get_user_display_name(
event: EventType,
default: str = "Unknown User"
) -> str:
"""
Возвращает отображаемое имя пользователя (Full Name).
Args:
event: Объект события (Message, CallbackQuery, и т.д.)
default: Значение по умолчанию если пользователь не найден
Returns:
str: Полное имя пользователя
Example:
>> get_user_display_name(message)
'John Doe'
>> get_user_display_name(message)
'John' # Если нет фамилии
"""
user = _extract_user(event)
if not user:
return default
# Полное имя (приоритет)
if user.full_name:
return user.full_name
# Только имя
if user.first_name:
return user.first_name
# Username как запасной вариант
if user.username:
return f"@{user.username}"
# ID как последний вариант
return f"User {user.id}"
def get_user_mention(
event: EventType,
parse_mode: str = 'HTML',
show_username: bool = False
) -> str:
"""
Возвращает упоминание пользователя (кликабельное).
Args:
event: Объект события
parse_mode: Режим парсинга ('HTML' или 'Markdown')
show_username: Показывать username вместо имени
Returns:
str: HTML/Markdown упоминание
Example:
>> get_user_mention(message)
'<a href="tg://user?id=123456789">John Doe</a>'
>> get_user_mention(message, parse_mode='Markdown')
'[John Doe](tg://user?id=123456789)'
>> get_user_mention(message, show_username=True)
'<a href="tg://user?id=123456789">@johndoe</a>'
"""
user = _extract_user(event)
if not user:
return "Unknown User"
# Определяем текст для отображения
if show_username and user.username:
display_text = f"@{user.username}"
else:
display_text = user.full_name or user.first_name or f"User {user.id}"
# Формируем ссылку
user_link = f"tg://user?id={user.id}"
if parse_mode.upper() == 'HTML':
return f'<a href="{user_link}">{display_text}</a>'
elif parse_mode.upper() in ('MARKDOWN', 'MARKDOWNV2'):
# Экранируем специальные символы для Markdown
display_text = display_text.replace('[', '\\[').replace(']', '\\]')
return f'[{display_text}]({user_link})'
else:
return display_text
def get_user_id(event: EventType) -> Optional[int]:
"""
Возвращает ID пользователя.
Args:
event: Объект события
Returns:
int или None: ID пользователя
Example:
>> get_user_id(message)
123456789
"""
user = _extract_user(event)
return user.id if user else None
def username(
event: EventType,
with_at: bool = True,
fallback_to_id: bool = True
) -> str:
"""
Возвращает username пользователя или ID если username отсутствует.
Это основная функция для получения идентификатора пользователя
в формате @username или @id123.
Args:
event: Объект события (Message, CallbackQuery, и т.д.)
with_at: Добавлять @ в начало
fallback_to_id: Использовать ID если нет username
Returns:
str: Username или ID пользователя
Raises:
ValueError: Если информация о пользователе отсутствует
Example:
>> username(message)
'@johndoe'
>> username(message) # Нет username
'@123456789'
>> username(message, with_at=False)
'johndoe'
>> username(message, fallback_to_id=False)
'' # Если нет username
"""
user = _extract_user(event)
if not user:
raise ValueError("Информация о пользователе отсутствует в событии")
# Если есть username
if user.username:
return f"@{user.username}" if with_at else user.username
# Fallback на ID
if fallback_to_id:
return f"@{user.id}" if with_at else str(user.id)
# Если ничего нет
return ""
def format_user(
event: EventType,
format_type: UserFormat = UserFormat.USERNAME,
default: str = "@System"
) -> str:
"""
Универсальная функция форматирования пользователя.
Args:
event: Объект события
format_type: Тип форматирования (из enum UserFormat)
default: Значение по умолчанию
Returns:
str: Отформатированная информация о пользователе
Example:
>> format_user(message, UserFormat.USERNAME)
'@johndoe'
>> format_user(message, UserFormat.FULL_NAME)
'John Doe'
>> format_user(message, UserFormat.MENTION)
'<a href="tg://user?id=123">John Doe</a>'
>> format_user(message, UserFormat.DETAILED)
'@johndoe (John Doe, ID: 123456789)'
"""
user = _extract_user(event)
if not user:
return default
# USERNAME: @username или @id
if format_type == UserFormat.USERNAME:
if user.username:
return f"@{user.username}"
return f"@{user.id}"
# FULL_NAME: Имя Фамилия
elif format_type == UserFormat.FULL_NAME:
return user.full_name or user.first_name or f"User {user.id}"
# MENTION: HTML упоминание
elif format_type == UserFormat.MENTION:
display = user.full_name or user.first_name or f"User {user.id}"
return f'<a href="tg://user?id={user.id}">{display}</a>'
# MENTION_MARKDOWN: Markdown упоминание
elif format_type == UserFormat.MENTION_MARKDOWN:
display = user.full_name or user.first_name or f"User {user.id}"
display = display.replace('[', '\\[').replace(']', '\\]')
return f'[{display}](tg://user?id={user.id})'
# FIRST_NAME: Только имя
elif format_type == UserFormat.FIRST_NAME:
return user.first_name or f"User {user.id}"
# ID_ONLY: Только ID
elif format_type == UserFormat.ID_ONLY:
return str(user.id)
# DETAILED: Подробная информация
elif format_type == UserFormat.DETAILED:
parts = []
# Username
if user.username:
parts.append(f"@{user.username}")
# Full name
if user.full_name:
parts.append(f"({user.full_name}")
elif user.first_name:
parts.append(f"({user.first_name}")
# ID
parts.append(f"ID: {user.id})")
return ' '.join(parts) if parts else f"User {user.id}"
# По умолчанию
return default
# ================= ДОПОЛНИТЕЛЬНЫЕ УТИЛИТЫ =================
def is_bot(event: EventType) -> bool:
"""
Проверяет, является ли пользователь ботом.
Args:
event: Объект события
Returns:
bool: True если бот
"""
user = _extract_user(event)
return user.is_bot if user else False
def has_username(event: EventType) -> bool:
"""
Проверяет, есть ли у пользователя username.
Args:
event: Объект события
Returns:
bool: True если есть username
"""
user = _extract_user(event)
return bool(user and user.username)
def is_premium(event: EventType) -> bool:
"""
Проверяет, есть ли у пользователя Telegram Premium.
Args:
event: Объект события
Returns:
bool: True если Premium
"""
user = _extract_user(event)
return user.is_premium if user else False
def get_language_code(event: EventType) -> Optional[str]:
"""
Возвращает код языка пользователя.
Args:
event: Объект события
Returns:
Optional[str]: Код языка ('ru', 'en', и т.д.)
"""
user = _extract_user(event)
return user.language_code if user else None
def compare_users(event1: EventType, event2: EventType) -> bool:
"""
Сравнивает двух пользователей по ID.
Args:
event1: Первое событие
event2: Второе событие
Returns:
bool: True если это один и тот же пользователь
"""
user1 = _extract_user(event1)
user2 = _extract_user(event2)
if not user1 or not user2:
return False
return user1.id == user2.id
def get_user_info_dict(event: EventType) -> dict:
"""
Возвращает всю информацию о пользователе в виде словаря.
Args:
event: Объект события
Returns:
dict: Словарь с информацией о пользователе
"""
user = _extract_user(event)
if not user:
return {}
return {
'id': user.id,
'username': user.username,
'first_name': user.first_name,
'last_name': user.last_name,
'full_name': user.full_name,
'is_bot': user.is_bot,
'is_premium': user.is_premium,
'language_code': user.language_code,
'mention': get_user_mention(event),
'display_name': get_user_display_name(event)
}