Первый коммит
This commit is contained in:
4
bot/__init__.py
Normal file
4
bot/__init__.py
Normal 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
5
bot/core/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Модуль управления ботом
|
||||
"""
|
||||
from .bots import *
|
||||
from .webhook import *
|
||||
398
bot/core/bots.py
Normal file
398
bot/core/bots.py
Normal 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
259
bot/core/webhook.py
Normal 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
11
bot/filters/__init__.py
Normal 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
109
bot/filters/admin.py
Normal 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
253
bot/filters/callback.py
Normal 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
324
bot/filters/chat_rights.py
Normal 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
105
bot/filters/chat_type.py
Normal 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
184
bot/filters/modes.py
Normal 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
395
bot/filters/msg_content.py
Normal 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
111
bot/filters/spam.py
Normal 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
246
bot/filters/subscription.py
Normal 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
14
bot/handlers/__init__.py
Normal 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,
|
||||
)
|
||||
16
bot/handlers/commands/__init__.py
Normal file
16
bot/handlers/commands/__init__.py
Normal 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,
|
||||
)
|
||||
18
bot/handlers/commands/admins/__init__.py
Normal file
18
bot/handlers/commands/admins/__init__.py
Normal 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,
|
||||
|
||||
)
|
||||
81
bot/handlers/commands/admins/all_cmd.py
Normal file
81
bot/handlers/commands/admins/all_cmd.py
Normal 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}")
|
||||
258
bot/handlers/commands/admins/ban_cmd.py
Normal file
258
bot/handlers/commands/admins/ban_cmd.py
Normal 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("❌ Не удалось получить список забаненных пользователей")
|
||||
277
bot/handlers/commands/admins/kick_cmd.py
Normal file
277
bot/handlers/commands/admins/kick_cmd.py
Normal 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)
|
||||
77
bot/handlers/commands/admins/pin_cmd.py
Normal file
77
bot/handlers/commands/admins/pin_cmd.py
Normal 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)
|
||||
51
bot/handlers/commands/admins/settings_cmd.py
Normal file
51
bot/handlers/commands/admins/settings_cmd.py
Normal 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)
|
||||
19
bot/handlers/commands/settings/__init__.py
Normal file
19
bot/handlers/commands/settings/__init__.py
Normal 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,
|
||||
)
|
||||
173
bot/handlers/commands/settings/set_description_cmd.py
Normal file
173
bot/handlers/commands/settings/set_description_cmd.py
Normal 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)
|
||||
157
bot/handlers/commands/settings/set_name_cmd.py
Normal file
157
bot/handlers/commands/settings/set_name_cmd.py
Normal 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)
|
||||
174
bot/handlers/commands/settings/set_widget_cmd.py
Normal file
174
bot/handlers/commands/settings/set_widget_cmd.py
Normal 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)
|
||||
48
bot/handlers/commands/settings/settings_cmd.py
Normal file
48
bot/handlers/commands/settings/settings_cmd.py
Normal 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)
|
||||
33
bot/handlers/commands/users/__init__.py
Normal file
33
bot/handlers/commands/users/__init__.py
Normal 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,
|
||||
)
|
||||
434
bot/handlers/commands/users/admins.py
Normal file
434
bot/handlers/commands/users/admins.py
Normal 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")
|
||||
435
bot/handlers/commands/users/conflict.py
Normal file
435
bot/handlers/commands/users/conflict.py
Normal 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")
|
||||
215
bot/handlers/commands/users/emoji.py
Normal file
215
bot/handlers/commands/users/emoji.py
Normal 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
|
||||
# ================= КОМАНДА /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><tg-emoji emoji-id=\"ID\">fallback</tg-emoji></code>\n\n"
|
||||
"📌 <b>Пример использования в коде:</b>\n"
|
||||
"<code>text = 'Привет <tg-emoji emoji-id=\"5368324170671202286\">👍</tg-emoji>'\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")
|
||||
221
bot/handlers/commands/users/id.py
Normal file
221
bot/handlers/commands/users/id.py
Normal 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
|
||||
)
|
||||
238
bot/handlers/commands/users/listwords.py
Normal file
238
bot/handlers/commands/users/listwords.py
Normal 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")
|
||||
118
bot/handlers/commands/users/notifications.py
Normal file
118
bot/handlers/commands/users/notifications.py
Normal 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)
|
||||
447
bot/handlers/commands/users/report.py
Normal file
447
bot/handlers/commands/users/report.py
Normal 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")
|
||||
346
bot/handlers/commands/users/slience.py
Normal file
346
bot/handlers/commands/users/slience.py
Normal 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")
|
||||
168
bot/handlers/commands/users/start_cmd.py
Normal file
168
bot/handlers/commands/users/start_cmd.py
Normal 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)
|
||||
589
bot/handlers/commands/users/stats.py
Normal file
589
bot/handlers/commands/users/stats.py
Normal 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")
|
||||
|
||||
546
bot/handlers/commands/users/word.py
Normal file
546
bot/handlers/commands/users/word.py
Normal 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")
|
||||
15
bot/handlers/messages/__init__.py
Normal file
15
bot/handlers/messages/__init__.py
Normal 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)
|
||||
11
bot/handlers/messages/default_msg.py
Normal file
11
bot/handlers/messages/default_msg.py
Normal 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
|
||||
32
bot/handlers/messages/ping_test.py
Normal file
32
bot/handlers/messages/ping_test.py
Normal 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
|
||||
2
bot/keyboards/__init__.py
Normal file
2
bot/keyboards/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .inline import *
|
||||
from .reply import *
|
||||
17
bot/keyboards/inline.py
Normal file
17
bot/keyboards/inline.py
Normal 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()
|
||||
1
bot/keyboards/inline/__init__.py
Normal file
1
bot/keyboards/inline/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .decision import *
|
||||
18
bot/keyboards/inline/decision.py
Normal file
18
bot/keyboards/inline/decision.py
Normal 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
0
bot/keyboards/reply.py
Normal file
0
bot/keyboards/reply/__init__.py
Normal file
0
bot/keyboards/reply/__init__.py
Normal file
137
bot/middlewares/__init__.py
Normal file
137
bot/middlewares/__init__.py
Normal 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
|
||||
337
bot/middlewares/banwords_mdw.py
Normal file
337
bot/middlewares/banwords_mdw.py
Normal 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
674
bot/middlewares/error_mdw.py
Normal file
674
bot/middlewares/error_mdw.py
Normal 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
|
||||
350
bot/middlewares/logging_mdw.py
Normal file
350
bot/middlewares/logging_mdw.py
Normal 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']}"
|
||||
)
|
||||
544
bot/middlewares/referal_mdw.py
Normal file
544
bot/middlewares/referal_mdw.py
Normal 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
575
bot/middlewares/spam_mdw.py
Normal 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
553
bot/middlewares/sub_mdw.py
Normal 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
311
bot/middlewares/time_mdw.py
Normal 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
1
bot/special/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .text_processing import *
|
||||
290
bot/special/text_processing.py
Normal file
290
bot/special/text_processing.py
Normal 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("privet")
|
||||
"привет"
|
||||
>> 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
0
bot/states/__init__.py
Normal file
1
bot/templates/__init__.py
Normal file
1
bot/templates/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .message_callback import *
|
||||
818
bot/templates/message_callback.py
Normal file
818
bot/templates/message_callback.py
Normal 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
38
bot/utils/__init__.py
Normal 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
688
bot/utils/argument.py
Normal 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
636
bot/utils/auto_delete.py
Normal 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
812
bot/utils/decorators.py
Normal 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
523
bot/utils/format_time.py
Normal 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')
|
||||
504
bot/utils/hidden_username.py
Normal file
504
bot/utils/hidden_username.py
Normal 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
650
bot/utils/state_utils.py
Normal 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
613
bot/utils/type_message.py
Normal 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
409
bot/utils/usernames.py
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user